diff --git a/.gitignore b/.gitignore index 236f6a4..aa9a37a 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,9 @@ __screenshots__/ .pack/ e2e/fixtures/*/node_modules e2e/fixtures/*/dist +e2e/fixtures/*/.next +e2e/fixtures/*/next-env.d.ts +e2e/fixtures/*/tsconfig.tsbuildinfo # Benchmark app (npm run bench); bench/results/ is committed bench/app/node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md index 787982c..067585a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ --- +## [3.10.1] — 2026-10-13 + +### Fixed + +- **RTL: header filter operator symbols are no longer mirrored.** The browser's bidi rules drew `∋` (contains) as `∈` (is any of) in right-to-left grids, so the two operators swapped their look. The operator button is now laid out left to right in every direction. + +### Performance + +- **Select all at 1,000,000 rows ~60–90 ms (3.10.0: ~130 ms), deselect all under 50 ms (~95 ms).** The check that drops selected ids of removed rows now skips a selection it already checked against the row store's current maps, and selections the grid built from it by adding rendered rows (checkbox, row click, select-all). Rows are removed only by replacing the rows, which gives the store new maps, so any replacement still triggers the full check; ids set through `apiRef.selectRows` are always checked. Deselecting all after selecting all returns the empty selection without deleting 1M ids one by one. + +- **Grouping 1,000,000 rows takes ~190 ms instead of ~540 ms.** Leaf rows are recorded as one id → row-index entry; their tree nodes and `GridRowMeta` objects are made the first time something reads them (only rendered rows do) and kept, so each row still has exactly one metadata object. Rows are partitioned by index, so each leaf's id comes from the row store's id list instead of a lookup per row. + +- **The quick filter's first search at 1,000,000 rows takes ~1.6–2 s instead of ~2.7 s**, later keystrokes ~210 ms instead of 250–530 ms. Locale dates are formatted once per calendar day instead of once per cell (Intl formatting was ~0.5 s of it), and the shown texts of date, boolean and select columns are lower-cased once per distinct text; a select option whose label is its value is stored once. What remains is reading and formatting every cell once (your `valueFormatter` included) for the per-row text cache that makes later keystrokes fast. For instant search over 1M rows use a `dataSource`. + +### Internal + +- A unit test runs row grouping on 5,000 rows and checks that the whole-dataset pass creates no leaf tree node and that a row's metadata object is made once and reused, so the lazy leaf maps cannot silently turn eager again. + +- **Next.js fixture in the package smoke suite** (`e2e/fixtures/nextjs`, `e2e/nextjs.spec.ts`): a Next.js 16 App Router app built with `next build` (Turbopack) against the packed tarball, without the optional export peers and without `next.config` aliases. In Chromium, Firefox and WebKit the server-rendered HTML must contain the grid, the page must hydrate without console errors, sorting must work and `exportToExcelAdvanced` must reject with its install hint. The claims of `docs/features/nextjs.md` were checked once by hand before; CI now re-checks them on every run. + +--- + ## [3.10.0] — 2026-10-13 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index d0bcd1c..46c8137 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ This file is auto-loaded by Claude Code and other AI coding assistants. It provi - **Build:** `npm run build:lib` produces the published `dist/opengridx.es.js` / `dist/opengridx.umd.js`. `npm run build` type-checks the lib and builds the **demo site**. - **Published:** yes — on npm as `@opencorestack/opengridx` with external consumers. Treat public types and behaviour changes as semver-relevant. - **Lint:** `npm run lint` (ESLint). Must pass before every commit. -- **Current version:** 3.10.0 +- **Current version:** 3.10.1 --- @@ -128,7 +128,7 @@ Full doc: `docs/architecture/grid-row-meta.md` - Browser tests run in Chromium, Firefox and WebKit: `npx playwright install chromium firefox webkit` once. One engine only: `npx vitest run --project browser --browser.name=firefox` - Browser test files run one at a time (`fileParallelism: false`): several test pages open at once in one Firefox steal focus and pointer input from each other - **Vitest vs Playwright Test:** render `` with props and assert → Vitest (`*.test.tsx`, or `*.browser.test.tsx` for layout). Needs the built package, a real app, a dev/preview server or a file download → the e2e package smoke suite (`e2e/`, `npm run test:smoke`). Vitest aliases the package to `lib/`, so only the smoke suite catches packaging bugs (bundled `react/jsx-runtime`, missing CSS in `dist/`, `.d.ts` that fails `skipLibCheck: false`) -- Smoke suite: `scripts/smoke.mjs` builds + packs to `.pack/`, `npm ci`s each fixture in `e2e/fixtures/{react19,react18,nopeers}` and unpacks the tarball there, builds them (strict tsc + vite), then runs `e2e/smoke.spec.ts` in Chromium/Firefox/WebKit × both fixtures against `vite preview`. Scenarios via `?scenario=` (`basic`, `flex`, `grouping`, `editing`; React 19 also `pinned`, `dark`). Any console error/warning fails a test. `nopeers` is a webpack 5 app without `exceljs`/`jspdf` (`nopeers.spec.ts`, port 4321): every optional-peer `import()` must stay inside a `try` block, which webpack and Turbopack treat as optional (Vite tolerates a missing peer anyway, so the Vite fixtures cannot catch this). A specifier hidden in a variable (`import(name)`) builds but breaks apps that do install the peers: browsers cannot resolve bare specifiers, and Vite's lib build strips `webpackIgnore` / `@vite-ignore` comments. Fixtures are outside root lint/typecheck/vitest; their lockfiles must not carry `integrity` for the tarball entry. Guide: `docs/contributing/testing.md` +- Smoke suite: `scripts/smoke.mjs` builds + packs to `.pack/`, `npm ci`s each fixture in `e2e/fixtures/{react19,react18,nopeers,nextjs}` and unpacks the tarball there, builds them (strict tsc + vite), then runs `e2e/smoke.spec.ts` in Chromium/Firefox/WebKit × both fixtures against `vite preview`. Scenarios via `?scenario=` (`basic`, `flex`, `grouping`, `editing`; React 19 also `pinned`, `dark`). Any console error/warning fails a test. `nopeers` is a webpack 5 app without `exceljs`/`jspdf` (`nopeers.spec.ts`, port 4321): every optional-peer `import()` must stay inside a `try` block, which webpack and Turbopack treat as optional (Vite tolerates a missing peer anyway, so the Vite fixtures cannot catch this). `nextjs` is a Next.js 16 App Router app (Turbopack, `next build` + `next start`, port 4323, `e2e/nextjs.spec.ts`): Server Component → `'use client'` grid, stylesheet in the layout, no optional peers, no aliases; its `next.config.ts` only pins `turbopack.root` to the fixture so the repo's own `exceljs` is not found. A specifier hidden in a variable (`import(name)`) builds but breaks apps that do install the peers: browsers cannot resolve bare specifiers, and Vite's lib build strips `webpackIgnore` / `@vite-ignore` comments. Fixtures are outside root lint/typecheck/vitest; their lockfiles must not carry `integrity` for the tarball entry. Guide: `docs/contributing/testing.md` - Keep browser tests engine-neutral: no CDP (`cdp()` is Chromium-only; use a Playwright-backed command such as `commands.setColorScheme` from `vitest.config.ts`), Tab-order anchors are text inputs (WebKit on macOS skips buttons on Tab), compare scroll positions with what the browser actually took (Firefox snaps them to device pixels), and wait for CSS animations to finish before measuring. Skip an engine only with `it.skipIf(server.browser === '...')` (`server` from `vitest/browser`) and a comment saying why --- @@ -177,6 +177,7 @@ The GitHub wiki is generated from `docs/` + `wiki/` by `scripts/build-wiki.mjs` - **Row store edits are in place.** `REPLACE_ROW(S)` (`replaceStoredRows` in `useDataGrid.ts`) mutate `state.rows.idRowsLookup` / `idByRow` / `indexById` and only copy `state.rows.rows` (the stored rows in order; `effectiveRows` is that array). Never detect an edit by those maps' identity; depend on `rows`. The writes must stay idempotent (StrictMode re-runs reducers; `DataGrid.storeInPlace.test.tsx`). `SET_ROWS` still builds new maps, which gives `getRowIdOf` a new identity - Id lists over a row view go through `getRowIndex(rows, getRowId, view)` (`lib/utils/rowIndex.ts`): it returns the previous object when every position keeps its id, so memos on it survive edits. Don't write `rows.map(getRowId)` over the renderable or data rows +- Row grouping records leaves as one `id → row index` map (`GridLeafIndex`); leaf tree nodes and leaf `GridRowMeta` are `LazyLeafTreeNodes` / `LazyLeafMetaMap` (`lib/utils/grouping/lazyLeafMaps.ts`), made on first read and cached (one object per row). Rows are partitioned by index and leaf ids come from the store's `allRows` (`rowIds` param), so `rows` passed to `useRowGrouping` must stay aligned with `rowIds`. Iterating these maps materializes every leaf: keep it off hot paths - Row grouping's `rowMetaMap` is a `LayeredMap` (leaf metas built once per tree + group metas per toggle); internal `rowMetaMap` parameters are `ReadonlyMap`. Nothing may rely on its iteration order ## v3.7.0 — significant changes diff --git a/demo/public/llms.txt b/demo/public/llms.txt index ba2ea0b..eb15d2d 100644 --- a/demo/public/llms.txt +++ b/demo/public/llms.txt @@ -1,6 +1,6 @@ # OpenGridX — AI Agent Context # Package: @opencorestack/opengridx -# Version: 3.10.0 +# Version: 3.10.1 # Docs: https://opencorestack.github.io/OpenGridX/ # This file is machine-readable. AI developers: start here. @@ -735,3 +735,4 @@ Arrows move between cells (skipping hidden columns); Home/End, Ctrl+Home/End, Pa 27. **Theme keys added in 3.9**: `grid.checkboxCheckedBackground`, `checkboxCheckmark`, `expandIconColor`, `expandIconHoverColor`; `overlays.itemIndicatorColor` (menu selection dot), `itemSelectedIcon`, `iconColor`, `sectionLabelText`, `dividerColor`. The demo's Theme Builder edits all of them. 28. **3.10**: the AI assistant's change chips are `localeText` keys too (`aiAssistantChipSort(field, direction)`, `aiAssistantChipSummary(fn, field)`, …). Select-all at 1M rows takes ~130 ms. The library forbids `as unknown as` outside documented boundaries and `any` everywhere (ESLint), and CI checks that `llms.txt`, CLAUDE.md and the CHANGELOG name the `package.json` version. 29. **Large client-side datasets**: sorting or filtering 1M rows blocks for ~0.4–0.5 s (one native sort). There is no time-sliced mode (measured and rejected: 1.5–2× slower overall). For interactive sorting and filtering at that size use a `dataSource`. +30. **3.10.1**: at 1M rows select-all ~60–90 ms, deselect-all < 50 ms, applying a grouping ~190 ms, the quick filter's first search ~1.6–2 s (later keystrokes ~210 ms). Grouping creates leaf tree nodes and row metadata only when read, still one `params.rowMeta` object per row. A Next.js 16 App Router fixture in the package smoke suite re-checks the Next.js guide on every CI run. diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index 4500895..b44c3ed 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -31,7 +31,7 @@ Vitest tests import `lib/` source directly, so they cannot catch packaging mista `npm run test:smoke` (`scripts/smoke.mjs`): 1. `npm run build:lib`, then `npm pack` into `.pack/opencorestack-opengridx.tgz`. -2. For each fixture in `e2e/fixtures/` (`react19`, `react18`, `nopeers`): `npm ci` from its committed lockfile (skipped while the lockfile is unchanged), then unpack the fresh tarball into its `node_modules`. +2. For each fixture in `e2e/fixtures/` (`react19`, `react18`, `nopeers`, `nextjs`): `npm ci` from its committed lockfile (skipped while the lockfile is unchanged), then unpack the fresh tarball into its `node_modules`. 3. Build each fixture: `tsc` in strict mode with `skipLibCheck: false` against the shipped types, then `vite build`. The build is itself a typing test. `nopeers` is a plain-JavaScript webpack 5 app that does **not** install the optional export peers (`exceljs`, `jspdf`, `jspdf-autotable`); its `webpack` build is the check that the peer imports stay optional (Vite builds without them either way, webpack and Turbopack do not unless each peer `import()` is inside a `try` block). 4. `playwright test` (`e2e/playwright.config.ts`): Chromium, Firefox and WebKit against `vite preview` of each Vite fixture (ports 4319 and 4318) running `smoke.spec.ts`, and `nopeers` served by its `serve.mjs` (port 4321; 4320 is the benchmark) running `nopeers.spec.ts` (the grid renders, both exports reject with the install hint). diff --git a/docs/features/nextjs.md b/docs/features/nextjs.md index d8c3ece..a9f6905 100644 --- a/docs/features/nextjs.md +++ b/docs/features/nextjs.md @@ -4,6 +4,8 @@ OpenGridX is a client component: it uses state, effects, context and the DOM. In Everything on this page was built with `next build`, served with `next start` and loaded in Chromium, Firefox and WebKit without console errors, on Next.js 16.4 (Turbopack; `next build --webpack` also builds) and 15.5 (webpack), React 19.3. +Since v3.10.1 the package smoke suite builds and runs a Next.js 16 App Router fixture on every CI run (`e2e/fixtures/nextjs`: a Server Component passing rows to a `'use client'` grid, the stylesheet imported in the layout, no optional peers and no `next.config` aliases), so these claims are re-checked for each release. + ## 1. Install and import the stylesheet ```bash diff --git a/docs/features/rtl.md b/docs/features/rtl.md index a1edd0c..b2c82a9 100644 --- a/docs/features/rtl.md +++ b/docs/features/rtl.md @@ -43,7 +43,7 @@ The column menu is labelled by screen side: in RTL *Pin to Left* adds the column ## Known limitations - ~~Menu and panel labels are hard-coded English~~: since v3.9 every built-in string, including the column menu (`columnMenuPinLeft`, `columnMenuSortAscending`, …), is a `localeText` key. See *Locale text for every UI string* in [`API_REFERENCE.md`](../API_REFERENCE.md). -- **Header filter operator symbols** are mirrored by the browser's bidi rules (`∋` shows as `∈`), as in any RTL text. +- ~~Header filter operator symbols are mirrored by the bidi rules~~: since v3.10.1 the operator button is laid out left to right, so `∋` (contains) and `∈` (is any of) look the same in both directions. - **`direction` set through CSS** (a class or stylesheet, not the `dir` attribute) and changed at runtime is not detected; scroll the grid or remount it with a `key`. - **Headless Chromium tests:** Playwright launches Chromium with `--hide-scrollbars`. With classic scrollbars (Linux) `scrollbar-gutter: stable` still reserves the gutter, and in RTL Chromium then cannot scroll the last gutter-width of the start into view (`scrollLeft` stops at minus the gutter). A plain scroller without the grid does the same and real browsers with visible scrollbars do not. Tests must compare with the browser's scroll origin, as `DataGrid.rtl.browser.test.tsx` does. To reproduce on macOS: `defaults write chrome-headless-shell AppleShowScrollBars Always` (undo with `defaults delete chrome-headless-shell`). diff --git a/docs/performance.md b/docs/performance.md index 4ce474d..cbc3cc1 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -174,7 +174,9 @@ Measured in Chromium on an Apple M1, 1,000,000 rows, from the event to the end o | Fill 1,000 cells | 809 ms | ~50 ms | | | Expand a group | 279 ms | ~135 ms | leaf row metadata built once per tree | | Collapse a group | ~120 ms | ~1 ms | | -| Select all | ~270 ms | ~270 ms (~130 ms in 3.10) | 3.10 builds the 1M-id set once and reuses it for the model it returns; the rest is the live-selection check | +| Quick filter, first search at 1M rows | ~2.7 s (3.10.0) | ~1.6–2 s (3.10.1) | dates formatted once per day; shown texts lower-cased once per distinct text; later keystrokes ~210 ms | +| Group 1M rows (apply a grouping) | ~540 ms (3.10.0) | ~190 ms (3.10.1) | leaf nodes and row metadata made on first read; ids from the store, not looked up | +| Select all | ~270 ms | ~270 ms (~130 ms in 3.10.0, ~60–90 ms in 3.10.1) | 3.10 builds the 1M-id set once and reuses it for the model it returns; the rest is the live-selection check | 3.9.0 keeps rows memoised: selecting a row, starting or committing an edit, and vertical scrolling re-render only the rows they change (a click re-rendered every visible row, now one). React's own work per interaction is about 3–5 ms at 100k rows. diff --git a/docs/roadmap.md b/docs/roadmap.md index 226496e..1a22afc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -94,6 +94,7 @@ This document tracks the current status of features in `OpenGridX` and outlines | **3.9.0** ✅ | shipped 13 Oct | **`localeText` for every UI string** (~200 keys) | 9 theme keys + Theme Builder controls; rows stay memoised through selection and editing; demo sidebar by priority | | **3.9.1** ✅ | shipped 13 Oct | **npm package no longer ships 1.6 MB of test baselines** | `check:package` guard in CI; version badges read `package.json`; docs brought up to date | | **3.10.0** ✅ | shipped 13 Oct | **Backlog cleared**: refactors (R4–R6), CI guards, select-all ~2× faster | AI change chips translatable; demo syntax highlighting | +| **3.10.1** ✅ | shipped 13 Oct | **1M-row performance**: select-all ~60–90 ms, grouping ~190 ms, quick filter first search ~1.6–2 s | Next.js smoke fixture; RTL operator symbols not mirrored | | Freeze | 19 Dec – 5 Jan | Bug fixes only | — | ### Now: backlog first (feature hold since 13 Oct; backlog cleared in 3.10) diff --git a/docs/superpowers/plans/2026-10-13-audit-tracker.md b/docs/superpowers/plans/2026-10-13-audit-tracker.md index 82595a4..9f5b67e 100644 --- a/docs/superpowers/plans/2026-10-13-audit-tracker.md +++ b/docs/superpowers/plans/2026-10-13-audit-tracker.md @@ -80,3 +80,4 @@ Baselines (Chromium, M1, 1M rows): edit 508 ms · paste/undo/redo ~790 ms · fil - 2026-10-13 — Select-all at 1M: profiled (live check 75, all-selected count 73, Set from model 62, next model 55, GC 48 ms); no API change needed after all: set built once and reused (WeakMap by model array), select-all marks the set as containing the selectable ids. ~260 → ~130 ms. Live check (54 ms) left. - 2026-10-13 — Time-sliced pipeline closed (user agreed): proposal status updated, branch archived as `archive/timesliced-pipeline-2026-10-12`, both agent worktrees removed. Backlog under the feature hold is now empty except R-items done; ready to lift the hold (ask user). - 2026-10-13 — Release 3.10.0 via PR. +- 2026-10-13 — 3.10.1 work: Next.js smoke fixture; perf #5 select-all ~60–90 ms / deselect <50 ms (live-check markers), #2 grouping 540 → 190 ms (lazy leaf maps, index partition), #3 quick filter first search 2.7 → ~1.6–2 s (per-day date memo, shown-text lower-case cache). check:compiler caught a getter-in-hook regression during #2 (fixed before push). diff --git a/e2e/fixtures/nextjs/app/OrdersGrid.tsx b/e2e/fixtures/nextjs/app/OrdersGrid.tsx new file mode 100644 index 0000000..f389b96 --- /dev/null +++ b/e2e/fixtures/nextjs/app/OrdersGrid.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { useState } from 'react'; +import { DataGrid, GridToolbar, exportToExcelAdvanced, type GridColDef } from '@opencorestack/opengridx'; +import type { Order } from '../lib/orders'; + +// Column functions such as valueFormatter cannot cross from a Server Component: columns live here. +const columns: GridColDef[] = [ + { field: 'id', headerName: 'ID', type: 'number', width: 80 }, + { field: 'customer', headerName: 'Customer', flex: 1 }, + { field: 'amount', headerName: 'Amount', type: 'number', width: 140, valueFormatter: ({ value }) => `$${Number(value).toFixed(2)}` }, + { field: 'shipped', headerName: 'Shipped', type: 'boolean', width: 110 }, +]; + +export function OrdersGrid({ rows }: { rows: Order[] }) { + const [status, setStatus] = useState(''); + return ( + <> + + {status} + + + ); +} diff --git a/e2e/fixtures/nextjs/app/layout.tsx b/e2e/fixtures/nextjs/app/layout.tsx new file mode 100644 index 0000000..882f362 --- /dev/null +++ b/e2e/fixtures/nextjs/app/layout.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react'; +// The grid stylesheet, imported once at the root (docs/features/nextjs.md). +import '@opencorestack/opengridx/styles'; + +export const metadata = { title: 'OpenGridX on Next.js' }; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/e2e/fixtures/nextjs/app/page.tsx b/e2e/fixtures/nextjs/app/page.tsx new file mode 100644 index 0000000..b0561a0 --- /dev/null +++ b/e2e/fixtures/nextjs/app/page.tsx @@ -0,0 +1,13 @@ +import { getOrders } from '../lib/orders'; +import { OrdersGrid } from './OrdersGrid'; + +// A Server Component: fetches the rows and passes them (serialisable data only) to the client grid. +export default async function Page() { + const rows = await getOrders(); + return ( +
+

Orders

+ +
+ ); +} diff --git a/e2e/fixtures/nextjs/lib/orders.ts b/e2e/fixtures/nextjs/lib/orders.ts new file mode 100644 index 0000000..d2ee41d --- /dev/null +++ b/e2e/fixtures/nextjs/lib/orders.ts @@ -0,0 +1,18 @@ +export interface Order { + id: number; + customer: string; + amount: number; + shipped: boolean; +} + +const CUSTOMERS = ['Anna', 'Bob', 'Chen', 'Dara', 'Eli']; + +// Stands in for a database query run on the server. +export async function getOrders(): Promise { + return Array.from({ length: 100 }, (_, i) => ({ + id: i + 1, + customer: CUSTOMERS[i % CUSTOMERS.length], + amount: ((i * 37) % 1000) + 10, + shipped: i % 3 === 0, + })); +} diff --git a/e2e/fixtures/nextjs/next.config.ts b/e2e/fixtures/nextjs/next.config.ts new file mode 100644 index 0000000..d5b0f58 --- /dev/null +++ b/e2e/fixtures/nextjs/next.config.ts @@ -0,0 +1,14 @@ +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { NextConfig } from 'next'; + +// No aliases: since 3.6 the optional export peers (exceljs, jspdf) need none (docs/features/nextjs.md). +// +// Test isolation only, not something apps need: this fixture lives inside the OpenGridX repository, +// whose own node_modules has exceljs. Turbopack would otherwise resolve it from that parent folder +// and the "peer not installed" case would never be tested. +const nextConfig: NextConfig = { + turbopack: { root: dirname(fileURLToPath(import.meta.url)) }, +}; + +export default nextConfig; diff --git a/e2e/fixtures/nextjs/package-lock.json b/e2e/fixtures/nextjs/package-lock.json new file mode 100644 index 0000000..e2731ca --- /dev/null +++ b/e2e/fixtures/nextjs/package-lock.json @@ -0,0 +1,1040 @@ +{ + "name": "opengridx-smoke-nextjs", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "opengridx-smoke-nextjs", + "version": "0.0.0", + "dependencies": { + "@opencorestack/opengridx": "file:../../../.pack/opencorestack-opengridx.tgz", + "next": "^16.4.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@types/node": "^22", + "@types/react": "^19", + "@types/react-dom": "^19", + "typescript": "^5.9.3" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.0.tgz", + "integrity": "sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.0.tgz", + "integrity": "sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.0.tgz", + "integrity": "sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.0.tgz", + "integrity": "sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.0.tgz", + "integrity": "sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.0.tgz", + "integrity": "sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.0.tgz", + "integrity": "sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.0.tgz", + "integrity": "sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.0.tgz", + "integrity": "sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@opencorestack/opengridx": { + "version": "3.10.0", + "resolved": "file:../../../.pack/opencorestack-opengridx.tgz", + "license": "MIT", + "peerDependencies": { + "exceljs": ">=4.0.0", + "jspdf": ">=2.5.0", + "jspdf-autotable": ">=3.8.0", + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "peerDependenciesMeta": { + "exceljs": { + "optional": true + }, + "jspdf": { + "optional": true + }, + "jspdf-autotable": { + "optional": true + } + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001816", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", + "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/next/-/next-16.4.0.tgz", + "integrity": "sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg==", + "license": "MIT", + "dependencies": { + "@next/env": "16.4.0", + "@swc/helpers": "0.5.23", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.5.23", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.4.0", + "@next/swc-darwin-x64": "16.4.0", + "@next/swc-linux-arm64-gnu": "16.4.0", + "@next/swc-linux-arm64-musl": "16.4.0", + "@next/swc-linux-x64-gnu": "16.4.0", + "@next/swc-linux-x64-musl": "16.4.0", + "@next/swc-win32-arm64-msvc": "16.4.0", + "@next/swc-win32-x64-msvc": "16.4.0", + "sharp": "^0.35.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/e2e/fixtures/nextjs/package.json b/e2e/fixtures/nextjs/package.json new file mode 100644 index 0000000..701493c --- /dev/null +++ b/e2e/fixtures/nextjs/package.json @@ -0,0 +1,25 @@ +{ + "name": "opengridx-smoke-nextjs", + "private": true, + "version": "0.0.0", + "description": "Consumer fixture for the package smoke suite: a Next.js App Router app (Turbopack) without the optional export peers. A Server Component passes rows to a 'use client' grid; `next build` must succeed with no next.config aliases, as docs/features/nextjs.md says.", + "scripts": { + "build": "next build", + "preview": "next start -p 4323" + }, + "dependencies": { + "@opencorestack/opengridx": "file:../../../.pack/opencorestack-opengridx.tgz", + "next": "^16.4.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@types/node": "^22", + "@types/react": "^19", + "@types/react-dom": "^19", + "typescript": "^5.9.3" + }, + "overrides": { + "source-map-js": "^1.2.2" + } +} diff --git a/e2e/fixtures/nextjs/tsconfig.json b/e2e/fixtures/nextjs/tsconfig.json new file mode 100644 index 0000000..7dadfaf --- /dev/null +++ b/e2e/fixtures/nextjs/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "incremental": true, + "module": "esnext", + "esModuleInterop": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + ".next/types/**/*.ts", + "**/*.ts", + "**/*.tsx", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/e2e/nextjs.spec.ts b/e2e/nextjs.spec.ts new file mode 100644 index 0000000..7172f55 --- /dev/null +++ b/e2e/nextjs.spec.ts @@ -0,0 +1,34 @@ +import { test, expect, type Page } from '@playwright/test'; + +// The nextjs fixture: a Next.js App Router app (Turbopack, `next build` + `next start`) without the +// optional export peers. That it built is the first check (scripts/smoke.mjs fails otherwise). Here the +// server-rendered page must contain the grid, hydrate without console errors, and an optional-peer +// export must reject with an install hint. + +function collectErrors(page: Page): string[] { + const errors: string[] = []; + page.on('console', (m) => { + if (m.type() === 'error' || m.type() === 'warning') errors.push(m.text()); + }); + page.on('pageerror', (e) => errors.push(String(e))); + return errors; +} + +test('the server-rendered page holds the grid, and it hydrates without errors', async ({ page, request }) => { + const html = await (await request.get('/')).text(); + expect(html).toContain('role="grid"'); + const errors = collectErrors(page); + await page.goto('/'); + await expect(page.locator('.ogx__viewport [role="row"][data-rowindex="0"] [data-field="customer"]')).toHaveText('Anna'); + await page.locator('.ogx__header [data-field="customer"] .ogx__header-cell-title').click(); + await expect(page.locator('.ogx__header [data-field="customer"]')).toHaveAttribute('aria-sort', 'ascending'); + expect(errors).toEqual([]); +}); + +test('an optional-peer export rejects with an install hint', async ({ page }) => { + const errors = collectErrors(page); + await page.goto('/'); + await page.getByTestId('export-xlsx').click(); + await expect(page.getByTestId('status')).toHaveText("exportToExcelAdvanced requires 'exceljs'. Run: npm install exceljs"); + expect(errors).toEqual([]); +}); diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 02809e7..82d0136 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -10,6 +10,8 @@ const fixtures = [ // A webpack 5 app without the optional export peers (exceljs, jspdf): nopeers.spec.ts only. const NOPEERS_PORT = 4321; +// A Next.js App Router app (Turbopack) without the optional peers: nextjs.spec.ts only. +const NEXTJS_PORT = 4323; const browsers = [ { name: 'chromium', device: devices['Desktop Chrome'] }, @@ -50,6 +52,12 @@ export default defineConfig({ metadata: { fixture: 'nopeers' }, use: { ...b.device, viewport: { width: 1400, height: 900 }, baseURL: `http://localhost:${NOPEERS_PORT}` }, }, + { + name: `${b.name}-nextjs`, + testMatch: 'nextjs.spec.ts', + metadata: { fixture: 'nextjs' }, + use: { ...b.device, viewport: { width: 1400, height: 900 }, baseURL: `http://localhost:${NEXTJS_PORT}` }, + }, ]), webServer: [ ...fixtures.map((f) => ({ @@ -67,5 +75,12 @@ export default defineConfig({ reuseExistingServer: false, timeout: 60_000, }, + { + command: `npx next start -p ${NEXTJS_PORT}`, + cwd: 'fixtures/nextjs', + url: `http://localhost:${NEXTJS_PORT}`, + reuseExistingServer: false, + timeout: 60_000, + }, ], }); diff --git a/lib/components/DataGrid/DataGrid.tsx b/lib/components/DataGrid/DataGrid.tsx index ad4af3f..5a455fa 100644 --- a/lib/components/DataGrid/DataGrid.tsx +++ b/lib/components/DataGrid/DataGrid.tsx @@ -213,6 +213,8 @@ export function DataGrid(props: DataGridP const { treeDataHandlers, rowGroupingHandlers, activeHierarchyHandlers, rowMetaMap } = useGridHierarchy({ rows: effectiveRows, + // The store's ids, aligned with its rows: grouping reads each leaf's id from here. + rowIds: state.rows.allRows, getRowId: getRowIdOf, getTreeDataPath, isTreeDataRequested, isTreeData, columns: activeColumns, diff --git a/lib/components/HeaderFilter/HeaderFilter.test.tsx b/lib/components/HeaderFilter/HeaderFilter.test.tsx index 61bc80d..9b64668 100644 --- a/lib/components/HeaderFilter/HeaderFilter.test.tsx +++ b/lib/components/HeaderFilter/HeaderFilter.test.tsx @@ -423,3 +423,12 @@ describe('header filter row — keyboard', () => { expect(document.activeElement?.closest('[role="row"]')?.getAttribute('data-rowindex')).toBe('0'); }); }); + +describe('header filter operator symbol in RTL (3.10.1)', () => { + it('lays the operator button out left to right, so the bidi rules do not mirror its symbol', () => { + const { container } = render(
); + const buttons = container.querySelectorAll('.ogx__header-filter-operator'); + expect(buttons.length).toBeGreaterThan(0); + buttons.forEach(b => expect(b.getAttribute('dir')).toBe('ltr')); + }); +}); diff --git a/lib/components/HeaderFilter/HeaderFilterCell.tsx b/lib/components/HeaderFilter/HeaderFilterCell.tsx index 2918d56..1872fd5 100644 --- a/lib/components/HeaderFilter/HeaderFilterCell.tsx +++ b/lib/components/HeaderFilter/HeaderFilterCell.tsx @@ -281,6 +281,9 @@ export function HeaderFilterCell({ colDef, state, style, className, ariaColIndex type="button" className={`ogx__header-filter-operator${menuAnchor ? ' ogx__header-filter-operator--open' : ''}`} tabIndex={-1} + // The operator symbol is a glyph, not text: left-to-right in every direction, so the + // bidi rules do not mirror it in RTL (∋ "contains" would show as ∈ "is any of"). + dir="ltr" aria-haspopup="menu" aria-expanded={menuAnchor !== null} aria-label={locale.operatorButtonLabel(label, operatorLabel)} diff --git a/lib/hooks/core/useGridHierarchy.ts b/lib/hooks/core/useGridHierarchy.ts index 9189a16..3f44fb5 100644 --- a/lib/hooks/core/useGridHierarchy.ts +++ b/lib/hooks/core/useGridHierarchy.ts @@ -8,6 +8,7 @@ const EMPTY_ROW_META_MAP: ReadonlyMap = new Map(); export interface UseGridHierarchyParams extends Pick< UseRowGroupingParams, | 'rows' + | 'rowIds' | 'getRowId' | 'columns' | 'aggregationModel' @@ -35,7 +36,7 @@ export interface UseGridHierarchyParams extends Pick< */ export function useGridHierarchy(params: UseGridHierarchyParams) { const { - rows, getRowId, getTreeDataPath, isTreeDataRequested, isTreeData, columns, rowGroupingModel, isRowGrouping, + rows, rowIds, getRowId, getTreeDataPath, isTreeDataRequested, isTreeData, columns, rowGroupingModel, isRowGrouping, aggregationModel, defaultGroupingExpansionDepth, filterModel, sortModel, getAggregationPosition, columnLookup, filterMode, sortingMode, } = params; @@ -56,6 +57,7 @@ export function useGridHierarchy(params: UseGridHierarch const rowGroupingHandlers = useRowGrouping({ rows, + rowIds, getRowId, columns, rowGroupingModel, diff --git a/lib/hooks/core/useGridLiveRowSelection.ts b/lib/hooks/core/useGridLiveRowSelection.ts index 6883abd..ba14732 100644 --- a/lib/hooks/core/useGridLiveRowSelection.ts +++ b/lib/hooks/core/useGridLiveRowSelection.ts @@ -1,5 +1,6 @@ import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'; import type { GridRowId, GridRowMeta, GridRowModel } from '../../types'; +import { isKnownLive, markValidated } from '../../utils/selectionSets'; export interface UseGridLiveRowSelectionParams { /** The selection model (controlled prop or internal state). */ @@ -36,8 +37,16 @@ export function useGridLiveRowSelection(params: UseGridLiveRowSelectionParams): const { selectedRowIds, rowsLookup, rowMetaMap, enabled, onSelectionModelChange } = params; const liveRowIds = useMemo(() => { - if (!enabled || selectedRowIds.size === 0) return selectedRowIds; - // Check first, copy only when something is stale: select-all at 1M rows selects 1M ids. + if (!enabled) return selectedRowIds; + // An empty selection names no rows: trivially live, and the parent of the first selection. + if (selectedRowIds.size === 0) { + markValidated(selectedRowIds, rowsLookup, rowMetaMap); + return selectedRowIds; + } + // Already known to name only existing rows (checked against these maps, or built from such a + // set by the grid's own toggles): select-all at 1M rows would otherwise check 1M ids again. + if (isKnownLive(selectedRowIds, rowsLookup, rowMetaMap)) return selectedRowIds; + // Check first, copy only when something is stale. let stale = false; for (const id of selectedRowIds) { if (!rowsLookup.has(id) && !rowMetaMap.has(id)) { @@ -45,11 +54,15 @@ export function useGridLiveRowSelection(params: UseGridLiveRowSelectionParams): break; } } - if (!stale) return selectedRowIds; + if (!stale) { + markValidated(selectedRowIds, rowsLookup, rowMetaMap); + return selectedRowIds; + } const live = new Set(); for (const id of selectedRowIds) { if (rowsLookup.has(id) || rowMetaMap.has(id)) live.add(id); } + markValidated(live, rowsLookup, rowMetaMap); return live; }, [enabled, selectedRowIds, rowsLookup, rowMetaMap]); diff --git a/lib/hooks/core/useGridRowSelection.ts b/lib/hooks/core/useGridRowSelection.ts index 1eb58c0..d8809c3 100644 --- a/lib/hooks/core/useGridRowSelection.ts +++ b/lib/hooks/core/useGridRowSelection.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo } from 'react'; import type { GridRowId } from '../../types'; -import { isKnownToContainAll, markContainsAll, rememberSelectionSet } from '../../utils/selectionSets'; +import { isKnownToContainAll, markContainsAll, markDerivedFromLive, rememberSelectionSet, selectionSetOf } from '../../utils/selectionSets'; // ── Pure selection transitions (shared by the UI handlers and the imperative API) ────────── @@ -14,6 +14,9 @@ export function nextSelectionForRows( if (isSelected && single) { return ids.length > 0 ? [ids[ids.length - 1]] : Array.from(current); } + // Deselecting every id of a set built by selecting exactly these ids (deselect-all after + // select-all): nothing is left, so the 1M deletions are skipped. + if (!isSelected && current.size === ids.length && isKnownToContainAll(current, ids)) return []; const next = new Set(current); ids.forEach(id => (isSelected ? next.add(id) : next.delete(id))); const model = Array.from(next); @@ -61,8 +64,11 @@ export interface UseGridRowSelectionReturn { export function useGridRowSelection(params: UseGridRowSelectionParams): UseGridRowSelectionReturn { const { selectedRowIds, onSelectionModelChange, disableMultipleRowSelection, selectableRowIds } = params; + // Every commit here adds only ids of rendered or selectable rows to the live selection, so the new + // set is live wherever the current one is (the live-selection check then skips it). const commit = useCallback((next: GridRowId[]) => { if (isSameSelection(selectedRowIds, next)) return; + markDerivedFromLive(selectionSetOf(next), selectedRowIds); onSelectionModelChange(next); }, [selectedRowIds, onSelectionModelChange]); diff --git a/lib/hooks/useRowGrouping.test.ts b/lib/hooks/useRowGrouping.test.ts index c788917..139c06e 100644 --- a/lib/hooks/useRowGrouping.test.ts +++ b/lib/hooks/useRowGrouping.test.ts @@ -481,3 +481,30 @@ describe('useRowGrouping — models passed inline', () => { expect(result.current.treeNodes).toBe(treeNodes); }); }); + +describe('useRowGrouping — leaf objects are made lazily (3.10.1)', () => { + it('getVisibleRows over every row makes no leaf node or metadata; reading a few makes only those', async () => { + const { LazyLeafTreeNodes } = await import('../utils/grouping/lazyLeafMaps'); + const rows = Array.from({ length: 5000 }, (_, i) => ({ id: i, dept: `d${i % 5}`, name: `n${i}` })); + const { result } = renderHook(() => useRowGrouping({ + rows, + getRowId: (r: { id: number }) => r.id, + columns: [{ field: 'dept' }, { field: 'name' }], + rowGroupingModel: ['dept'], + defaultGroupingExpansionDepth: -1, + })); + const visible = result.current.getVisibleRows(); + expect(visible?.length).toBe(5005); + const nodes = result.current.treeNodes; + expect(nodes).toBeInstanceOf(LazyLeafTreeNodes); + // The whole-dataset pass (row pipeline) must not touch the leaves' objects. + expect((nodes as InstanceType).madeLeafCount).toBe(0); + // Rendering reads the metadata of the rows on screen only: one object per row, the same on + // every read, so writes to params.rowMeta stay on that row. + const first = result.current.rowMetaMap.get(7); + expect(first).toEqual({ hasChildren: false, treeDepth: 1, isGroupRow: false }); + expect(result.current.rowMetaMap.get(7)).toBe(first); + expect(result.current.rowMetaMap.get(8)).not.toBe(first); + expect((nodes as InstanceType).madeLeafCount).toBe(0); + }); +}); diff --git a/lib/hooks/useRowGrouping.ts b/lib/hooks/useRowGrouping.ts index 7b54c54..0214a76 100644 --- a/lib/hooks/useRowGrouping.ts +++ b/lib/hooks/useRowGrouping.ts @@ -16,6 +16,7 @@ import { callSafely, getCellValue } from '../utils/values'; import type { GridColumnLookup } from '../utils/columnLookup'; import { computeAggregations } from '../utils/aggregation'; import { LayeredMap } from '../utils/grouping/layeredMap'; +import { LazyLeafMetaMap, LazyLeafTreeNodes, RowIndexLeafIndex, type GridLeafIndex } from '../utils/grouping/lazyLeafMaps'; /** Where a group's aggregates are shown: on the group row, on a subtotal row after its children, or not at all. */ export type GridGroupAggregationPosition = 'inline' | 'footer' | null; @@ -23,6 +24,11 @@ export type GridGroupAggregationPosition = 'inline' | 'footer' | null; export interface UseRowGroupingParams { rows: R[]; getRowId: (row: R) => GridRowId; + /** + * The ids of `rows`, in the same order (the row store's `allRows`). Saves one id lookup per row + * when grouping is applied; without it the ids are read with `getRowId`. + */ + rowIds?: readonly GridRowId[]; columns?: GridColDef[]; rowGroupingModel?: GridRowGroupingModel; aggregationModel?: GridAggregationModel; @@ -127,13 +133,15 @@ interface GroupRowVariants { interface GroupingBuild { treeNodes: Map; + /** The leaf rows (leaf nodes and metadata are made from it on demand). */ + leafIndex: GridLeafIndex; rootIds: GridRowId[]; groupIds: GridRowId[]; groupRowVariants: Map>; /** Synthetic rows by id: every group row (with its aggregates) and every subtotal row. */ groupingRows: Map; /** The leaf rows in the tree, by id. */ - rowLookup: Map; + rowLookup: { get(id: GridRowId): R | undefined }; } const encodePosition = (position: GridGroupAggregationPosition): string => @@ -146,6 +154,7 @@ export function useRowGrouping(params: UseRowGroupingPar const { rows, getRowId, + rowIds: givenRowIds, columns, rowGroupingModel = EMPTY_ROW_GROUPING_MODEL, aggregationModel = EMPTY_AGGREGATION_MODEL, @@ -209,40 +218,53 @@ export function useRowGrouping(params: UseRowGroupingPar }, [isActive, rows, filterMode, filterModel, columnLookup]); const build = useMemo>(() => { - const treeNodes = new Map(); + // Leaves (every data row) are recorded compactly: id -> parent id. Their tree nodes and row + // metadata are made on first read (lazyLeafMaps); only group nodes are built here. + // One map write per leaf: id -> row index. Its row is rows[index], its group parentAt[index]. + const leafAt = new Map(); + const parentAt = new Array(rows.length); + const leafIndex: GridLeafIndex = new RowIndexLeafIndex(leafAt, parentAt); + const rowLookup = { + get: (id: GridRowId): R | undefined => { + const index = leafAt.get(id); + return index === undefined ? undefined : rows[index]; + }, + }; + const treeNodes = new LazyLeafTreeNodes(leafIndex, groupingFields.length); const rootIds: GridRowId[] = []; const groupIds: GridRowId[] = []; const groupRowVariants = new Map>(); const groupingRows = new Map(); - const rowLookup = new Map(); if (!isActive) { - return { treeNodes, rootIds, groupIds, groupRowVariants, groupingRows, rowLookup }; + return { treeNodes, leafIndex, rootIds, groupIds, groupRowVariants, groupingRows, rowLookup }; } - const groupRows = (currentRows: R[], depth: number, parentId: GridRowId | null): GridRowId[] => { + // Rows are partitioned by index, so a leaf's id comes from the aligned id list, not a lookup. + const ids = givenRowIds && givenRowIds.length === rows.length ? givenRowIds : null; + const idAt = (index: number): GridRowId => (ids ? ids[index] : getRowId(rows[index])); + + const groupRows = (currentRows: number[], depth: number, parentId: GridRowId | null): GridRowId[] => { if (depth >= groupingFields.length) { - const leaves = matchingRows ? currentRows.filter(row => matchingRows.has(row)) : currentRows; - return leaves.map(row => { - const id = getRowId(row); - rowLookup.set(id, row); - treeNodes.set(id, { - id, - parentId, - depth, - groupingKey: '', - isExpanded: false - }); - return id; - }); + const leafIds: GridRowId[] = []; + for (const index of currentRows) { + const row = rows[index]; + if (matchingRows && !matchingRows.has(row)) continue; + const id = idAt(index); + leafAt.set(id, index); + parentAt[index] = parentId; + leafIds.push(id); + } + return leafIds; } const field = groupingFields[depth]; const colDef = columnsLookup.get(field); // Bucket by the value the cell shows (through valueGetter), keyed so distinct values stay apart. - const buckets = new Map(); - for (const row of currentRows) { + const buckets = new Map(); + for (const index of currentRows) { + const row = rows[index]; const value = getCellValue(row, field, colDef as GridColDef | undefined); const key = groupKeyOf(value); let bucket = buckets.get(key); @@ -250,17 +272,18 @@ export function useRowGrouping(params: UseRowGroupingPar bucket = { value, rows: [] }; buckets.set(key, bucket); } - bucket.rows.push(row); + bucket.rows.push(index); } - const ids: GridRowId[] = []; + const groupIdsHere: GridRowId[] = []; buckets.forEach((bucket, key) => { // Group order is the order of first appearance among all rows, so filtering never // reorders the groups; a group with no matching row is left out. - const matching = matchingRows ? bucket.rows.filter(row => matchingRows.has(row)) : bucket.rows; + const bucketRows = bucket.rows.map(index => rows[index]); + const matching = matchingRows ? bucketRows.filter(row => matchingRows.has(row)) : bucketRows; if (matching.length === 0) return; const groupId = groupIdOf(field, key, parentId); - ids.push(groupId); + groupIdsHere.push(groupId); groupIds.push(groupId); // Aggregated over this group's (filtered) leaf rows. @@ -293,14 +316,16 @@ export function useRowGrouping(params: UseRowGroupingPar treeNodes.set(groupId, treeNode); treeNode.children = groupRows(bucket.rows, depth + 1, groupId); }); - return ids; + return groupIdsHere; }; - rootIds.push(...groupRows(rows, 0, null)); - return { treeNodes, rootIds, groupIds, groupRowVariants, groupingRows, rowLookup }; - }, [isActive, rows, matchingRows, groupingFields, hasAggregation, stableAggregationModel, getRowId, columnsLookup]); + const all = new Array(rows.length); + for (let i = 0; i < rows.length; i++) all[i] = i; + rootIds.push(...groupRows(all, 0, null)); + return { treeNodes, leafIndex, rootIds, groupIds, groupRowVariants, groupingRows, rowLookup }; + }, [isActive, rows, givenRowIds, matchingRows, groupingFields, hasAggregation, stableAggregationModel, getRowId, columnsLookup]); - const { treeNodes, rootIds, groupIds, groupRowVariants, groupingRows, rowLookup } = build; + const { treeNodes, leafIndex, rootIds, groupIds, groupRowVariants, groupingRows, rowLookup } = build; const isDefaultExpanded = useCallback((node: GridTreeNode | undefined) => ( Boolean(node?.children && node.children.length > 0) && @@ -439,16 +464,10 @@ export function useRowGrouping(params: UseRowGroupingPar // Leaf entries do not depend on expansion: built once per tree, so expanding or collapsing a // group rebuilds only the group entries (a LayeredMap reads both without copying). - const leafMetaMap = useMemo>(() => { - const map = new Map(); - treeNodes.forEach((node, id) => { - if (groupRowVariants.has(id)) return; - // One object per row (not shared per depth): `params.rowMeta` is public, and a consumer that - // writes to it must not change other rows. - map.set(id, { hasChildren: false, treeDepth: node.depth, isGroupRow: false }); - }); - return map; - }, [treeNodes, groupRowVariants]); + const leafMetaMap = useMemo>( + () => new LazyLeafMetaMap(leafIndex, groupingFields.length), + [leafIndex, groupingFields.length] + ); const rowMetaMap = useMemo>(() => { const map = new Map(); diff --git a/lib/utils/filtering/index.ts b/lib/utils/filtering/index.ts index d936fe7..00af33a 100644 --- a/lib/utils/filtering/index.ts +++ b/lib/utils/filtering/index.ts @@ -221,6 +221,20 @@ function pushSearchText(out: string[], value: unknown): void { // Other objects have no meaningful text ("[object Object]") and are skipped. } +// Shown texts of date, boolean and singleSelect columns repeat across rows (a few labels, Yes / No, +// some days): lower-case each once instead of once per cell, which allocated a new string per row of +// a 1M-row first search. Bounded; free text (names, notes) does not go through it. +const lowerShownCache = new Map(); +function lowerShownText(text: string): string { + let lower = lowerShownCache.get(text); + if (lower === undefined) { + lower = text.toLowerCase(); + if (lowerShownCache.size >= 10_000) lowerShownCache.clear(); + lowerShownCache.set(text, lower); + } + return lower; +} + function getQuickFilterTexts(row: GridRowModel, columns: GridColumnLookup | undefined): string[] { const texts: string[] = []; if (!columns) { @@ -235,7 +249,12 @@ function getQuickFilterTexts(row: GridRowModel, columns: GridColumnLookup | unde } else if (col.type === 'date' || col.type === 'boolean' || col.type === 'singleSelect') { // Without a formatter the grid shows a locale date, Yes / No or the option label: search that too. const shown = formatValueByType(value, col); - if (shown !== '') texts.push(shown.toLowerCase()); + if (shown !== '') { + const lower = lowerShownText(shown); + texts.push(lower); + // A string option whose label is the value itself: the same text once is enough. + if (typeof value === 'string' && value.length === lower.length && value.toLowerCase() === lower) continue; + } } pushSearchText(texts, value); } diff --git a/lib/utils/grouping/lazyLeafMaps.test.ts b/lib/utils/grouping/lazyLeafMaps.test.ts new file mode 100644 index 0000000..2daf878 --- /dev/null +++ b/lib/utils/grouping/lazyLeafMaps.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { LazyLeafMetaMap, LazyLeafTreeNodes, type GridLeafIndex } from './lazyLeafMaps'; +import type { GridRowId, GridTreeNode } from '../../types'; + +describe('lazy leaf maps', () => { + const parents = new Map([[1, 'g1'], [2, 'g1'], [3, 'g2']]); + const leafParent: GridLeafIndex = { + get size() { return parents.size; }, + has: id => parents.has(id), + parentOf: id => parents.get(id), + keys: () => parents.keys(), + }; + + it('tree nodes: groups as stored, leaves made once on first read', () => { + const nodes = new LazyLeafTreeNodes(leafParent, 1); + const group: GridTreeNode = { id: 'g1', parentId: null, depth: 0, groupingKey: 'a', isExpanded: false, children: [1, 2] }; + nodes.set('g1', group); + expect(nodes.get('g1')).toBe(group); + const leaf = nodes.get(1); + expect(leaf).toEqual({ id: 1, parentId: 'g1', depth: 1, groupingKey: '', isExpanded: false }); + expect(nodes.get(1)).toBe(leaf); + expect(nodes.get(99)).toBeUndefined(); + expect(nodes.has(3)).toBe(true); + expect(nodes.has(99)).toBe(false); + expect(nodes.size).toBe(4); + expect([...nodes.keys()]).toEqual(['g1', 1, 2, 3]); + expect([...nodes.values()].map(n => n.id)).toEqual(['g1', 1, 2, 3]); + const seen: GridRowId[] = []; + nodes.forEach((_n, id) => seen.push(id)); + expect(seen).toEqual(['g1', 1, 2, 3]); + }); + + it('row metadata: one object per leaf, made on first read', () => { + const metas = new LazyLeafMetaMap(leafParent, 2); + const m = metas.get(3); + expect(m).toEqual({ hasChildren: false, treeDepth: 2, isGroupRow: false }); + expect(metas.get(3)).toBe(m); + expect(metas.get(1)).not.toBe(m); + expect(metas.get('g1')).toBeUndefined(); + expect(metas.size).toBe(3); + expect([...metas.keys()]).toEqual([1, 2, 3]); + }); +}); diff --git a/lib/utils/grouping/lazyLeafMaps.ts b/lib/utils/grouping/lazyLeafMaps.ts new file mode 100644 index 0000000..b102b04 --- /dev/null +++ b/lib/utils/grouping/lazyLeafMaps.ts @@ -0,0 +1,160 @@ +import type { GridRowId, GridRowMeta, GridTreeNode } from '../../types'; + +/** The leaf rows of a grouping: which ids are leaves and the group each belongs to. */ +export interface GridLeafIndex { + readonly size: number; + has(id: GridRowId): boolean; + /** The leaf's group id (`null` at the root), or undefined when `id` is not a leaf. */ + parentOf(id: GridRowId): GridRowId | null | undefined; + keys(): MapIterator; +} + +// Row grouping at 1M rows spent ~300 ms creating one tree node and one row-metadata object per leaf +// row, although only the rendered rows (a few dozen) ever read them. These maps hold real objects +// for the group rows and create a leaf's object the first time it is read, then keep it (so each +// row still has exactly one object, which `params.rowMeta` relies on). Iterating them creates every +// leaf object; nothing in the grid iterates them. + +/** + * The leaves of a grouping as `id -> row index` plus each row index's group: one map write per leaf. + * A class rather than an object with a getter, which React Compiler cannot compile inside a hook. + */ +export class RowIndexLeafIndex implements GridLeafIndex { + private readonly leafAt: ReadonlyMap; + private readonly parentAt: readonly (GridRowId | null)[]; + + constructor(leafAt: ReadonlyMap, parentAt: readonly (GridRowId | null)[]) { + this.leafAt = leafAt; + this.parentAt = parentAt; + } + + get size(): number { + return this.leafAt.size; + } + + has(id: GridRowId): boolean { + return this.leafAt.has(id); + } + + parentOf(id: GridRowId): GridRowId | null | undefined { + const index = this.leafAt.get(id); + return index === undefined ? undefined : this.parentAt[index]; + } + + keys(): MapIterator { + return this.leafAt.keys(); + } +} + +/** + * `treeNodes` of row grouping: group nodes stored as given, leaf nodes made on first `get` from the + * leaf index and the (shared) leaf depth. + */ +export class LazyLeafTreeNodes extends Map { + private readonly leafIndex: GridLeafIndex; + private readonly leafDepth: number; + private readonly leaves = new Map(); + + constructor(leafIndex: GridLeafIndex, leafDepth: number) { + super(); + this.leafIndex = leafIndex; + this.leafDepth = leafDepth; + } + + override get(id: GridRowId): GridTreeNode | undefined { + const group = super.get(id); + if (group !== undefined) return group; + let leaf = this.leaves.get(id); + if (leaf === undefined && this.leafIndex.has(id)) { + leaf = { id, parentId: this.leafIndex.parentOf(id) ?? null, depth: this.leafDepth, groupingKey: '', isExpanded: false }; + this.leaves.set(id, leaf); + } + return leaf; + } + + override has(id: GridRowId): boolean { + return super.has(id) || this.leafIndex.has(id); + } + + /** How many leaf nodes were made so far (tests: rendering must not make one per row). */ + get madeLeafCount(): number { + return this.leaves.size; + } + + override get size(): number { + return super.size + this.leafIndex.size; + } + + override forEach(callback: (value: GridTreeNode, key: GridRowId, map: Map) => void, thisArg?: unknown): void { + for (const [id, node] of this.entries()) callback.call(thisArg, node, id, this); + } + + override *entries(): MapIterator<[GridRowId, GridTreeNode]> { + yield* super.entries(); + for (const id of this.leafIndex.keys()) yield [id, this.get(id) as GridTreeNode]; + } + + override *keys(): MapIterator { + yield* super.keys(); + yield* this.leafIndex.keys(); + } + + override *values(): MapIterator { + for (const [, node] of this.entries()) yield node; + } + + override [Symbol.iterator](): MapIterator<[GridRowId, GridTreeNode]> { + return this.entries(); + } +} + +/** The leaf rows' `GridRowMeta`, made on first read: one object per row, kept once made. */ +export class LazyLeafMetaMap implements ReadonlyMap { + private readonly leafIndex: GridLeafIndex; + private readonly leafDepth: number; + private readonly made = new Map(); + + constructor(leafIndex: GridLeafIndex, leafDepth: number) { + this.leafIndex = leafIndex; + this.leafDepth = leafDepth; + } + + get(id: GridRowId): GridRowMeta | undefined { + let meta = this.made.get(id); + if (meta === undefined && this.leafIndex.has(id)) { + // One object per row (not shared per depth): `params.rowMeta` is public, and a consumer that + // writes to it must not change other rows. + meta = { hasChildren: false, treeDepth: this.leafDepth, isGroupRow: false }; + this.made.set(id, meta); + } + return meta; + } + + has(id: GridRowId): boolean { + return this.leafIndex.has(id); + } + + get size(): number { + return this.leafIndex.size; + } + + forEach(callback: (value: GridRowMeta, key: GridRowId, map: ReadonlyMap) => void, thisArg?: unknown): void { + for (const id of this.leafIndex.keys()) callback.call(thisArg, this.get(id) as GridRowMeta, id, this); + } + + *entries(): MapIterator<[GridRowId, GridRowMeta]> { + for (const id of this.leafIndex.keys()) yield [id, this.get(id) as GridRowMeta]; + } + + keys(): MapIterator { + return this.leafIndex.keys(); + } + + *values(): MapIterator { + for (const id of this.leafIndex.keys()) yield this.get(id) as GridRowMeta; + } + + [Symbol.iterator](): MapIterator<[GridRowId, GridRowMeta]> { + return this.entries(); + } +} diff --git a/lib/utils/selectionSets.test.ts b/lib/utils/selectionSets.test.ts index 2dcfe05..3aee73d 100644 --- a/lib/utils/selectionSets.test.ts +++ b/lib/utils/selectionSets.test.ts @@ -35,3 +35,39 @@ describe('selection sets', () => { expect(isKnownToContainAll(selectionSetOf(cleared), selectable)).toBe(false); }); }); + +describe('live-selection markers', () => { + it('trusts a set checked against the same maps, and sets derived from it', async () => { + const { markValidated, markDerivedFromLive, isKnownLive } = await import('./selectionSets'); + const rows = new Map(); const meta = new Map(); + const parent = new Set([1]); + markValidated(parent, rows, meta); + const child = new Set([1, 2]); + markDerivedFromLive(child, parent); + expect(isKnownLive(parent, rows, meta)).toBe(true); + expect(isKnownLive(child, rows, meta)).toBe(true); + }); + + it('does not trust it once the rows were replaced (new maps) or for unrelated sets', async () => { + const { markValidated, markDerivedFromLive, isKnownLive } = await import('./selectionSets'); + const rows = new Map(); const meta = new Map(); + const parent = new Set([1]); + markValidated(parent, rows, meta); + const child = new Set([1, 2]); + markDerivedFromLive(child, parent); + expect(isKnownLive(child, new Map(), meta)).toBe(false); + expect(isKnownLive(child, rows, new Map())).toBe(false); + expect(isKnownLive(new Set([1]), rows, meta)).toBe(false); + }); +}); + +describe('deselect-all after select-all', () => { + it('returns an empty model without deleting id by id, and is exact otherwise', () => { + const selectable = [1, 2, 3]; + const all = selectionSetOf(nextSelectionForRows(new Set(), selectable, true, false)); + expect(nextSelectionForRows(all, selectable, false, false)).toEqual([]); + // A set holding more than the selectable ids keeps the rest. + const more = selectionSetOf(nextSelectionForRows(new Set([9]), selectable, true, false)); + expect(nextSelectionForRows(more, selectable, false, false)).toEqual([9]); + }); +}); diff --git a/lib/utils/selectionSets.ts b/lib/utils/selectionSets.ts index fe04c58..75415fe 100644 --- a/lib/utils/selectionSets.ts +++ b/lib/utils/selectionSets.ts @@ -34,3 +34,32 @@ export function markContainsAll(set: ReadonlySet, ids: readonly GridR export function isKnownToContainAll(set: ReadonlySet, ids: readonly GridRowId[]): boolean { return containsAll.get(set) === ids; } + +// Live-selection check: a set already checked against the row store's current maps, or built from +// such a set by adding ids of rendered rows (the grid's own toggles, row clicks and select-all), holds +// only ids of existing rows. Rows are removed only by replacing the rows, which gives the store new +// maps, so the check is skipped while the maps are the same objects. +const validatedAgainst = new WeakMap, { rows: object; meta: object }>(); +const derivedFrom = new WeakMap, ReadonlySet>(); + +/** Records that every id of `set` named an existing row in these maps. */ +export function markValidated(set: ReadonlySet, rows: object, meta: object): void { + validatedAgainst.set(set, { rows, meta }); +} + +/** + * Records that `set` was built from `parent` by adding ids of rendered rows only (never ids from the + * API, which may name anything), so it is live wherever `parent` is. + */ +export function markDerivedFromLive(set: ReadonlySet, parent: ReadonlySet): void { + if (set !== parent) derivedFrom.set(set, parent); +} + +/** Whether `set` (or the set it was derived from) was checked against these very maps. */ +export function isKnownLive(set: ReadonlySet, rows: object, meta: object): boolean { + for (let s: ReadonlySet | undefined = set, depth = 0; s && depth < 4; s = derivedFrom.get(s), depth++) { + const v = validatedAgainst.get(s); + if (v && v.rows === rows && v.meta === meta) return true; + } + return false; +} diff --git a/lib/utils/values.test.ts b/lib/utils/values.test.ts index 2841d00..ebf3d5f 100644 --- a/lib/utils/values.test.ts +++ b/lib/utils/values.test.ts @@ -14,3 +14,14 @@ describe('toNumber — text that only Number() reads as a number', () => { expect(toNumber('007')).toBe(7); }); }); + +describe('formatLocalDate (memoized per day)', () => { + it('matches Intl.DateTimeFormat for every date, including different times on one day', async () => { + const { formatLocalDate } = await import('./values'); + const intl = new Intl.DateTimeFormat(); + const dates = [new Date(2024, 0, 5, 1, 0), new Date(2024, 0, 5, 23, 59), new Date(1999, 11, 31), new Date(2024, 1, 29)]; + for (const d of dates) expect(formatLocalDate(d)).toBe(intl.format(d)); + // An invalid date throws like Intl does (callers pass only dates from toDate, never invalid ones). + expect(() => formatLocalDate(new Date(NaN))).toThrow(RangeError); + }); +}); diff --git a/lib/utils/values.ts b/lib/utils/values.ts index bd999c5..0bafe5f 100644 --- a/lib/utils/values.ts +++ b/lib/utils/values.ts @@ -198,9 +198,22 @@ let localDateFormat: Intl.DateTimeFormat | null = null; * `date.toLocaleDateString()` (the default locale's numeric date), with the formatter created * once: building a new one per call is several times slower on large grids. */ +// Intl formatting costs ~0.5 µs a call, which a whole-dataset pass (the quick filter's first search +// reads every date cell) pays a million times. The default format shows only the calendar day, so +// the text is memoized per local day; real columns hold far fewer distinct days than rows. Bounded. +const localDateTextByDay = new Map(); +const LOCAL_DATE_CACHE_LIMIT = 10_000; + export function formatLocalDate(date: Date): string { - localDateFormat ??= new Intl.DateTimeFormat(); - return localDateFormat.format(date); + const day = date.getFullYear() * 10000 + date.getMonth() * 100 + date.getDate(); + let text = localDateTextByDay.get(day); + if (text === undefined) { + localDateFormat ??= new Intl.DateTimeFormat(); + text = localDateFormat.format(date); + if (localDateTextByDay.size >= LOCAL_DATE_CACHE_LIMIT) localDateTextByDay.clear(); + localDateTextByDay.set(day, text); + } + return text; } type ValueOption = string | number | { value: unknown; label: string }; diff --git a/llms.txt b/llms.txt index ba2ea0b..eb15d2d 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # OpenGridX — AI Agent Context # Package: @opencorestack/opengridx -# Version: 3.10.0 +# Version: 3.10.1 # Docs: https://opencorestack.github.io/OpenGridX/ # This file is machine-readable. AI developers: start here. @@ -735,3 +735,4 @@ Arrows move between cells (skipping hidden columns); Home/End, Ctrl+Home/End, Pa 27. **Theme keys added in 3.9**: `grid.checkboxCheckedBackground`, `checkboxCheckmark`, `expandIconColor`, `expandIconHoverColor`; `overlays.itemIndicatorColor` (menu selection dot), `itemSelectedIcon`, `iconColor`, `sectionLabelText`, `dividerColor`. The demo's Theme Builder edits all of them. 28. **3.10**: the AI assistant's change chips are `localeText` keys too (`aiAssistantChipSort(field, direction)`, `aiAssistantChipSummary(fn, field)`, …). Select-all at 1M rows takes ~130 ms. The library forbids `as unknown as` outside documented boundaries and `any` everywhere (ESLint), and CI checks that `llms.txt`, CLAUDE.md and the CHANGELOG name the `package.json` version. 29. **Large client-side datasets**: sorting or filtering 1M rows blocks for ~0.4–0.5 s (one native sort). There is no time-sliced mode (measured and rejected: 1.5–2× slower overall). For interactive sorting and filtering at that size use a `dataSource`. +30. **3.10.1**: at 1M rows select-all ~60–90 ms, deselect-all < 50 ms, applying a grouping ~190 ms, the quick filter's first search ~1.6–2 s (later keystrokes ~210 ms). Grouping creates leaf tree nodes and row metadata only when read, still one `params.rowMeta` object per row. A Next.js 16 App Router fixture in the package smoke suite re-checks the Next.js guide on every CI run. diff --git a/package-lock.json b/package-lock.json index 7214bdd..e28891c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opencorestack/opengridx", - "version": "3.10.0", + "version": "3.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@opencorestack/opengridx", - "version": "3.10.0", + "version": "3.10.1", "license": "MIT", "devDependencies": { "@babel/core": "^7.29.7", diff --git a/package.json b/package.json index f69c3fc..7fced01 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opencorestack/opengridx", - "version": "3.10.0", + "version": "3.10.1", "description": "OpenGridX: High-performance React data infrastructure. Unlock advanced Row Grouping, Excel Export, and Column Pinning without the usual \"Pro\" gatekeeping. Built for speed, scale, and complete architectural freedom. Fully open, virtualization-ready, and feature-complete.", "type": "module", "main": "./dist/opengridx.umd.js",