Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand Down Expand Up @@ -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 `<DataGrid>` 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

---
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion demo/public/llms.txt
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/contributing/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
2 changes: 2 additions & 0 deletions docs/features/nextjs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/features/rtl.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Expand Down
4 changes: 3 additions & 1 deletion docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions docs/superpowers/plans/2026-10-13-audit-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
32 changes: 32 additions & 0 deletions e2e/fixtures/nextjs/app/OrdersGrid.tsx
Original file line number Diff line number Diff line change
@@ -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<Order>[] = [
{ 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 (
<>
<button
type="button"
data-testid="export-xlsx"
onClick={() => {
exportToExcelAdvanced(rows, columns).then(() => setStatus('exported'), (e: unknown) => setStatus(e instanceof Error ? e.message : String(e)));
}}
>
Export .xlsx
</button>
<output data-testid="status">{status}</output>
<DataGrid rows={rows} columns={columns} height={480} checkboxSelection headerFilters slots={{ toolbar: GridToolbar }} />
</>
);
}
13 changes: 13 additions & 0 deletions e2e/fixtures/nextjs/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<html lang="en">
<body>{children}</body>
</html>
);
}
13 changes: 13 additions & 0 deletions e2e/fixtures/nextjs/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main>
<h1>Orders</h1>
<OrdersGrid rows={rows} />
</main>
);
}
Loading
Loading