This file is auto-loaded by Claude Code and other AI coding assistants. It provides the orientation needed to work in this codebase without exploring files first.
@opencorestack/opengridx — a high-performance React DataGrid component library.
- Language: TypeScript 5.9 strict mode. Zero
any, zeroeslint-disable. - Framework: React 19
- Tests: Vitest 4 +
@testing-library/react. Two projects:unit(jsdom,npm test) andbrowser(real Chromium, Firefox and WebKit via Playwright,npm run test:browser) - Build:
npm run build:libproduces the publisheddist/opengridx.es.js/dist/opengridx.umd.js.npm run buildtype-checks the lib and builds the demo site. - Published: yes — on npm as
@opencorestack/opengridxwith 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.1
Props enter DataGrid.tsx (the orchestration layer — no logic lives here, only hook calls and one props object for GridRoot). Put new logic in a hook or pure util and call it from there. Hook call order is load-bearing (effects run in call order; several hooks install apiRef methods): the full ordered list is in docs/architecture/datagrid-orchestration.md. Contiguous runs of hooks are wrapped in composite hooks (useGridEditPipeline, useGridSelectionAndStateApi, useGridViewApis, useGridScrollViewport, useGridRangeEditing, useGridRenderWindow, useGridToolbarStack) that call their members in the same order; a composite may only wrap consecutive steps.
DataGridProps
│
├─ useGridControlledState — normalises controlled/uncontrolled prop pairs
│ (sort, pagination, selection, column visibility, pivot, aggregation)
├─ useGridPivot → resolveGridModes (utils/gridModes) — which features are in effect
├─ useGridGroupingColumn — the synthetic __group__ column + its auto-pin
├─ useDataGrid — row store, dimensions, live apiRef
│ useGridRowIdOf / useGridApiRefBinding
├─ useGridHierarchy — useTreeData + useRowGrouping → active handlers + rowMetaMap
├─ useGridEditPipeline — edit commit channel → batch edit → useGridEditing
├─ useGridLiveRowSelection, useGridDataSource, useGridDetailPanel
│
├─ useGridColumns — inject hierarchy renderers; resolve order/visibility
│ reads rowMetaMap instead of row._* fields
├─ useGridRowPipeline — filter → sort → paginate → split pinned rows
├─ useGridSelectionAndStateApi — useGridRowInteractions (selection + row click / checkbox handlers)
│ → useGridApiMethods (imperative state API)
├─ useAggregation, useGridAggregationApi, useLayout, useGridSpanning, useGridCellSelection
├─ useGridViewApis — useGridClipboardApi, useGridScrollToIndexesApi, useGridColumnAutosize
│
├─ useGridScrollViewport — useGridViewport (scroll sync + measurement) → useGridMaxContentHeight
│ → useGridVirtualization (render window)
├─ useGridKeyboardNavigation — focus + keys; useGridSortHandlers / useGridPointerFocusHandlers /
│ useGridColumnMenuHandlers for header and cell events
├─ useGridRangeEditing — cell selection API → paste / range clear → undo/redo → fill handle
├─ useGridRenderWindow — useGridAriaRows → span row window → useGridVisibleRows → span column window
├─ useGridToolbarStack — columns panel, stylesheet warning, useGridAiAssistant, useGridToolbarProps
│
└─ renders <GridRoot>: root element, toolbar, AI panel, columns panel, GridListView (list view) or
<GridViewport> (Header, GridPinnedRows, GridVirtualRows, GridAggregationFooter),
cell selection stats, footer slot or GridPaginationArea, GridLiveRegion, overlays
Hierarchy (tree data, row grouping) is handled by useTreeData / useRowGrouping. They produce flat renderable row arrays of the consumer's own row objects (unchanged — nothing is injected since v3.0) and a rowMetaMap: Map<GridRowId, GridRowMeta> that carries all hierarchy information.
| File | Purpose |
|---|---|
lib/types/ |
All public types, one file per domain: rows.ts (GridRowId, GridRowModel, GridRowMeta, pinned rows, row params), columns.ts (GridColDef, cell/value/render params, column pinning/order/visibility), sorting.ts, filtering.ts, selection.ts (row + cell selection), editing.ts (batch edit, paste, undo/redo, fill), grouping.ts (grouping, aggregation, pivot models), export.ts (PdfExportOptions, GridGroupedExportRow), dataSource.ts (data source, pagination model), state.ts, locale.ts (GridLocaleText), props.ts (DataGridProps), slots.ts, api.ts (GridApi), ai.ts (GridAi*). index.ts only re-exports them (export type *), so import from types, never from a domain file outside lib/types/ |
lib/components/DataGrid/DataGrid.tsx |
Orchestration only — hooks + one props object for GridRoot, no business logic. Hook order: docs/architecture/datagrid-orchestration.md |
lib/components/DataGrid/GridRoot.tsx |
The grid's root element and its children (toolbar, panels, list view or viewport, footer, live region, overlays); passes the viewport's props on to GridViewport |
lib/components/DataGrid/GridViewport.tsx |
The role="grid" viewport: header, pinned rows, empty state, virtual rows, aggregation footer; builds rowRenderProps |
lib/hooks/core/ |
Core pipeline hooks (always active): useGridControlledState, useGridRowPipeline, useGridColumns, useGridVirtualization, useGridVisibleRows, useGridScrollSync, useGridKeyboardNavigation, useLayout; DataGrid wiring: useGridHierarchy, useGridGroupingColumn, useGridRowIdOf, useGridDetailPanel, useGridRowInteractions, useGridViewport, useGridAriaRows, useGridToolbarProps, useGridHeaderHandlers, useGridFocusHandlers, and the apiRef installers useGridApiRefBinding / useGridApiMethods / useGridAggregationApi / useGridClipboardApi / useGridScrollToIndexesApi; composite hooks that wrap consecutive steps of that order: useGridEditPipeline, useGridSelectionAndStateApi, useGridViewApis, useGridScrollViewport, useGridRangeEditing, useGridRenderWindow, useGridToolbarStack |
lib/utils/gridModes.ts |
resolveGridModes: the one place features override each other (pivot vs tree/grouping/reorder, grouping vs pagination, infinite vs pages, list view needs listViewColumn) |
lib/hooks/features/ |
Opt-in feature hooks: useAggregation, usePivot, useGridEditing, useGridSpanning, useGridDataSource, useGridClipboard |
lib/hooks/useTreeData.ts |
Tree-data hierarchy — flat row array + rowMetaMap |
lib/hooks/useRowGrouping.ts |
Row-grouping hierarchy — flat row array + rowMetaMap |
lib/components/Cell/Cell.tsx |
Cell render — invokes renderCell, wraps with CellErrorBoundary |
lib/components/Cell/CellErrorBoundary.tsx |
Class error boundary wrapping renderCell output |
lib/utils/filtering/ |
Client-side filter operators |
lib/utils/sorting/ |
Client-side sort comparators |
lib/utils/aggregation/ |
The single set of aggregation functions (sum/avg/count/min/max/unique) used by footer, row grouping and exports — do not re-implement them elsewhere |
lib/hooks/core/useGridDevWarnings.ts |
Dev-only console.warns (unbounded container, pagination + grouping) |
| Task | Where |
|---|---|
| New filter operator for a column type | lib/utils/filtering/filtering.ts + GridFilterOperator in lib/types/filtering.ts |
| New feature (aggregation, pivot, etc.) | New hook in lib/hooks/features/, consumed by DataGrid.tsx |
| New core pipeline stage | New hook in lib/hooks/core/, added to DataGrid.tsx |
| New component (slot, overlay, etc.) | lib/components/<Name>/ |
New public prop on DataGrid |
Add to DataGridProps in lib/types/props.ts, destructure in DataGrid.tsx |
| New column type | the type union on GridColDef in lib/types/columns.ts, operators in getOperatorsForType (lib/utils/filtering/index.ts), paste parsing in lib/utils/parsing.ts |
| New slot | Add to the slots / slotProps of DataGridProps in lib/types/props.ts (slot prop types in lib/types/slots.ts), wire in DataGrid.tsx via slots?.mySlot |
- CSS classes:
ogx__BEM prefix (e.g.ogx__cell,ogx__cell--focused) - CSS variables:
--ogx-*(e.g.--ogx-row-height,--ogx-color-primary) - Hook names:
use-grid-*for core hooks,use*for feature hooks - Exported types:
Grid*prefix (e.g.GridColDef,GridRowMeta,GridLocaleText) - Never inject fields onto consumer row objects. Hierarchy info goes in
rowMetaMap/params.rowMeta; the old_hasChildren-style runtime fields were removed in v3.0.
Hierarchy metadata (hasChildren, treeDepth, groupingField, etc.) lives in a Map<GridRowId, GridRowMeta> returned by useTreeData/useRowGrouping, not on the row object. Access it in renderCell via params.rowMeta.
The underscore fields (_hasChildren, _treeDepth, …) were injected onto copied rows from v1.1 to v2.x as a backward-compat shim and removed in v3.0 — rows now pass through unchanged, so params.row is the consumer's own object. Migration: docs/migration/v2-to-v3.md.
Full doc: docs/architecture/grid-row-meta.md
- Test files are co-located with the file they test:
useGridRowPipeline.test.tslives inlib/hooks/core/ - Import pattern:
import { describe, it, expect, vi } from 'vitest'+import { renderHook, act } from '@testing-library/react' - Hook tests use
renderHook(() => useMyHook(params))— no DOM needed for pure hooks - Component tests use
renderfrom@testing-library/react - Run unit tests:
npm test(jsdom). Real-browser tests:npm run test:browser. Both:npm run test:all - Run a single file:
npx vitest run --project unit lib/hooks/core/useGridRowPipeline.test.ts - Anything that depends on layout,
ResizeObserver, or scrolling (virtualization, container height, drag) must be a*.browser.test.tsx— jsdom has no layout engine and reports a 0px viewport (the grid falls back to 600px), so such bugs are invisible there - Browser tests run in Chromium, Firefox and WebKit:
npx playwright install chromium firefox webkitonce. 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.tsxfor 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 tolib/, so only the smoke suite catches packaging bugs (bundledreact/jsx-runtime, missing CSS indist/,.d.tsthat failsskipLibCheck: false) - Smoke suite:
scripts/smoke.mjsbuilds + packs to.pack/,npm cis each fixture ine2e/fixtures/{react19,react18,nopeers,nextjs}and unpacks the tarball there, builds them (strict tsc + vite), then runse2e/smoke.spec.tsin Chromium/Firefox/WebKit × both fixtures againstvite preview. Scenarios via?scenario=(basic,flex,grouping,editing; React 19 alsopinned,dark). Any console error/warning fails a test.nopeersis a webpack 5 app withoutexceljs/jspdf(nopeers.spec.ts, port 4321): every optional-peerimport()must stay inside atryblock, which webpack and Turbopack treat as optional (Vite tolerates a missing peer anyway, so the Vite fixtures cannot catch this).nextjsis 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; itsnext.config.tsonly pinsturbopack.rootto the fixture so the repo's ownexceljsis 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 stripswebpackIgnore/@vite-ignorecomments. Fixtures are outside root lint/typecheck/vitest; their lockfiles must not carryintegrityfor 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 ascommands.setColorSchemefromvitest.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 withit.skipIf(server.browser === '...')(serverfromvitest/browser) and a comment saying why
| Content | Location |
|---|---|
| Component public API (props, slots, events) | docs/components/<name>.md |
| Internal hook architecture | docs/architecture/<hook-name>.md |
| Feature guides (usage patterns, code examples) | docs/features/<feature>.md |
| Full public API reference | docs/API_REFERENCE.md |
| Roadmap and feature status | docs/roadmap.md |
| Wiki-only pages (Home, Getting Started, FAQ) | wiki/ |
The GitHub wiki is generated from docs/ + wiki/ by scripts/build-wiki.mjs (.github/workflows/wiki.yml, on push to main). Never edit the wiki directly. A new doc page needs an entry in SECTIONS there; npm run wiki:build warns about unlisted docs
npm run lint && npm run buildmust pass after every change- Zero
eslint-disableor@ts-ignore— fix the root cause - Zero
any— useunknownor explicit interfaces - Never add
Co-Authored-ByAI attribution to commit messages
- CI guards (each fails the build):
check:versions(llms.txt+ its demo copy, CLAUDE.md "Current version" and a CHANGELOG entry must matchpackage.json; bump them together),check:compiler(React Compiler: only the allowlistedEXPECTEDresults inscripts/check-react-compiler.mjs),check:package(no test artifacts, DataGrid-only size budget), ESLintno-explicit-any(nowerror, also in the demo) andno-restricted-syntaxagainstas unknown asinlib/outside the boundary files listed ineslint.config.js - Types (R4): a
GridColDef<R>passed to an untyped public surface (callback params, toolbar, store) goes throughuntypedColumn/untypedColumns(lib/utils/untypedColumns.ts), the one place that widening is written; narrowing untyped →GridColDef<R>/R[]is a plainas.useDataGridtypessetRows/replaceRows/rowsLookupwithR; call generic hooks with<R>explicitly when an untyped argument would otherwise inferGridRowModel. Remainingas unknown as(15) are synthetic rows, third-party module typings,window/ CSS custom properties: do not add new ones elsewhere - R5 splits (no behaviour change):
GridToolbarpanels inAggregationPanel/ColumnsPanelWrapper/FilterPanelWrapperwith shareduseToolbarPanel(portal, position, Escape / outside press;onClosethrough a ref); exporters one file per format (exportCsv,exportExcelHtml,exportJson,printGrid, sharedexportRows;utils/export/index.tsonly re-exports); xlsx helpers inexcelCells.ts; the range drag inuseGridCellRangeDrag, called from insideuseGridCellSelection(hook order unchanged); keydown handling inhandleGridKeyDown(event, context)(hooks/core/gridKeyDown.ts, a plain function over a typedGridKeyDownContext, unit-testable without rendering;useGridKeyboardNavigationcalls it from auseCallbackwith the same dependencies). A hook that takes refs as parameters must list them in its dependency arrays, or React Compiler skips it (PreserveManualMemo) - Demo code samples go through
demo/components/CodeBlock.tsx(prism-react-renderer, demo-only devDependency); live output panels (JSON state, logs) stay plain<pre>
npm run check:package(afterbuild:lib; inci.ymlandnpm-publish.yml): fails ifnpm packwould ship a test artifact (*.test.*,__baseline__,__screenshots__,__snapshots__,__fixtures__) and ifDataGridalone, minified with esbuild and gzipped, exceedsDATAGRID_GZIP_BUDGET(92 KB; 85.5 KB at 3.9.0).filesin package.json works by exclusion: add a negation for any new kind of test folder underlib/. Raise the budget only in the commit that needs it
- Rows stay memoised. Every prop passed to
Rowmust keep its identity unless that row changed: handlers go through the latest-ref pattern (useGridRowInteractions: stablehandleRowClick/handleSelectionChange), per-row state is passed only to the row it concerns (editingCell,focusedCellField,cellRange), anduseGridVirtualizationbuildsvirtualColumnsin its own memo over column inputs only.DataGrid.rowMemo.test.tsxcountsRowrenders throughisCellEditable(Cellis memoised, sorenderCellwould not show them) - 9 theme keys (checkbox checked fill / check mark, expand chevron, menu indicator / selected icon / icons / section labels / dividers) read
var(--x, <old value>), so the default look is unchanged
- Row store edits are in place.
REPLACE_ROW(S)(replaceStoredRowsinuseDataGrid.ts) mutatestate.rows.idRowsLookup/idByRow/indexByIdand only copystate.rows.rows(the stored rows in order;effectiveRowsis that array). Never detect an edit by those maps' identity; depend onrows. The writes must stay idempotent (StrictMode re-runs reducers;DataGrid.storeInPlace.test.tsx).SET_ROWSstill builds new maps, which givesgetRowIdOfa 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 writerows.map(getRowId)over the renderable or data rows - Row grouping records leaves as one
id → row indexmap (GridLeafIndex); leaf tree nodes and leafGridRowMetaareLazyLeafTreeNodes/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'sallRows(rowIdsparam), sorowspassed touseRowGroupingmust stay aligned withrowIds. Iterating these maps materializes every leaf: keep it off hot paths - Row grouping's
rowMetaMapis aLayeredMap(leaf metas built once per tree + group metas per toggle); internalrowMetaMapparameters areReadonlyMap. Nothing may rely on its iteration order
- Types live in domain files under
lib/types/(columns,rows,props,api,editing,selection, …);lib/types/index.tsonly re-exports. Import fromtypes, add a type to its domain file DataGrid.tsxrenders throughGridRoot→GridViewport, and wraps contiguous hook runs in composite hooks (useGridEditPipeline,useGridSelectionAndStateApi,useGridViewApis,useGridScrollViewport,useGridRangeEditing,useGridRenderWindow,useGridToolbarStack). Never reorder hooks across a composite; the true numbered order is indocs/architecture/datagrid-orchestration.md. Each extracted component takes exactly one child position (keepsuseIdvalues stable in SSR)- 16 theme keys (fonts, header padding, border width/radius, divider widths/colours, overlay font). Variables without a stylesheet value are read as
var(--x, fallback)so they follow a provider's base token; divider width 0 hides a divider.lib/theme/defaultThemeBaseline.browser.test.tsxcompares the default theme's computed styles withlib/theme/__baseline__/default-theme.darwin.<engine>.jsonon macOS only (text metrics differ per OS); an intended default-look change must regenerate them - Demo smoke:
npm run test:demo(e2e/demo.spec.ts, port 4322, base/OpenGridX/) loads every route ofexamplesConfigindemo/App.tsx;@heavypages also in Firefox/WebKit. It runs inci.ymland is the build step ofdeploy.yml. A route can setfullWidth: true source-map-jsis overridden to^1.2.2(root, fixtures, bench). Thesprintf-jsalert has no fix whilevite-plugin-dtsrelies on api-extractor- Browser tests that drag must release the mouse over the page (and test files reset with
commands.pointerUp()inafterEach): a release outside the frame leaves WebKit's button pressed and swallows the next file'spointerdown
- Sorting encodes each key into typed arrays (
encodeSortColumn): rank 0–4 (number, date, boolean, string, other), empty = 5, plus a number per row; strings/other get their position among the distinct texts in collator order. Only keys where an item carries bothcomparatorandparamsuse the per-paircallSortComparatorpath. Never call the collator inside a comparator over rows getCellValuereturnsrow[field]without allocating when the column has novalueGetter— keep it allocation-free. Built-in aggregations go throughaggregateRows(row by row, same addition order);AGGREGATION_FUNCTIONSstays the value-array API for pivot and cell selection- Quick filter:
filterRowskeeps texts by index for the last rows array perGridColumnLookup(reused when the same object is at the same index);createRowFilterkeeps a per-row WeakMap. Joining texts into one string was measured and rejected - Row-grouping leaf
GridRowMetaobjects are one per row (sharing per depth was reverted:params.rowMetais public). Equivalence tests with the old code as oracles:*.equivalence.test.ts dist/opengridx.es.jsstarts with'use client'(banner invite.config.lib.js, checked by check-bundle); the UMD build has none- Every optional-peer
import()(exceljs,jspdf,jspdf-autotable) must stay insidetry— webpack and Turbopack only treat it as optional there; a hidden variable specifier breaks apps that install the peers. Thenopeerssmoke fixture (webpack 5, no peers, port 4321,e2e/nopeers.spec.ts) guards it; fixtures must keep webpack from resolving the rootnode_modules validateGridAiStateskips a top-level stringmessage; the prompt handler surfaces it asresult.messagefor object, JSON-string and fenced input- RTL: read/write horizontal scroll only through
getScrollStart/setScrollStart(lib/utils/direction.ts; scroll state is the offset from the inline start, never negative); map arrows withlogicalArrowKey(key, isRtl(el)); panels withgetPanelPosition.pinnedColumns.left/rightare logical (inline start / end): sticky offsets useinsetInlineStart/insetInlineEnd(jsdom:style.getPropertyValue('inset-inline-start')), bordersborder-inline-*, box-shadow x-offsets need:dir(rtl)mirrors. The column menu pins by screen side.useGridScrollSyncobservesdirmutations - Testing RTL: Playwright's headless Chromium runs with
--hide-scrollbars; with classic scrollbars andscrollbar-gutter: stable, RTL cannot reachscrollLeft0 — compare against the scroll origin. Reproduce on macOS withdefaults write chrome-headless-shell AppleShowScrollBars Always. The grid'sscrollbar-width/scrollbar-colordisable::-webkit-scrollbarin Chromium - Header filter strings are in
localeText(headerFilter*, resolved inlib/utils/headerFilterLocale.ts) - The time-sliced pipeline (Option A of
docs/superpowers/specs/2026-10-12-nonblocking-pipeline-proposal.md) is closed: 1.5–2× slower in total; brancharchive/timesliced-pipeline-2026-10-12kept for reference
- Content height is capped to the browser's limit. Firefox lays out elements up to about 17.9M px and collapses a taller one (it does not clamp like Chromium/WebKit at about 33.5M px), which made large grids unscrollable there.
useGridMaxContentHeight(hook 32a, beforeuseGridVirtualization) measures the limit once per document with an invisible probe (lib/utils/maxElementHeight.ts:getMaxElementHeight,countReachableRows;undefineduntil measured,Infinitywithout a layout engine), anduseGridVirtualizationreturnsvirtualContainerHeightcapped to it — always render the rows' container with that, nevertotalHeight - pinned heights - The tall-content dev warning waits for the measurement and fires only when rows are cut off, naming the reachable count.
DataGrid.heightCap.browser.test.tsx(500k rows) fails without the cap in Firefox - Benchmark:
npm run bench(bench/, port 4320) measures the packed package at 10k–1M rows in 3 engines; results inbench/results/, tables indocs/performance.md(node bench/report.mjs --write docs/performance.md). Run it on an idle machine; the weekly CI pass is for trends only
Design: docs/superpowers/specs/2026-10-09-ai-assistant-header-filters-fill-design.md.
- AI assistant (
aiAssistant): core holds the panel (useGridAiAssistant, hook 45a, beforeuseGridToolbarProps; UI inlib/components/AiAssistant/),/aiholds model logic (createGridAiPromptHandler,createGridAgentTools). The core bundle must contain no AI code and/aino React (check-bundle;/aibudget 8 kB, ≈ 6.7 kB now)./aistrings must never contain the word "react" or a lowercaseimport/require((check-bundle regexes) - Brand rule:
validateGridAiStatebrands result andstatewith the non-enumerableSymbol.for('opengridx.ai.validated')(lib/utils/aiBrand.ts); the grid applies onlyisGridAiValidated(result.state). Parts the validator emptied are skipped; column visibility is merged; Undo restores a snapshot of touched parts (view undo, separate fromundoRedo) GridAiState/GridAiPart/GridAiValidationErrorand assistant types live inlib/types/ai.ts; allGridAi*/GridAgent*types are exported from/aionly (publicApi rule/^Grid(?:Ai|Agent)[A-Z]/)- Agent tools are all-or-nothing on validation errors and never throw;
get_row_summaryexists only withallowRowAccess. They readapiRef.currentat call time — create them in a handler/effect, not during render (react-hooks/refs) rowGroupingModelis a controlled/uncontrolled pair (onRowGroupingModelChange) inuseGridControlledState. NewGridApi:setSortModel,setRowGroupingModel,setAggregationModel,setColumnVisibilityModel,setPivotModel,getGridAiState,openAiAssistant,closeAiAssistant- Two buttons swapped by a ternary inside a
<form>need distinctkeys, or the click on the outgoing one submits the form (the Stop button re-sent the prompt) - Header filters (
headerFilters): each cell owns only the rootfilterModelitemid: 'header:<field>'(lib/utils/headerFilters.ts:getHeaderFilterState,setHeaderFilterItem, which returns the same object for a model it can't show). Custom ='or'root with items, field inside a group, or an untagged/second root item on the field. Off in pivot mode - The filter row renders inside
.ogx__header-wrapafter.ogx__header; column cells of both rows must usegetHeaderColumnStyle(lib/components/Header/headerColumnStyle.ts). Focus:FocusedCell.headerFilter; column header is row -2 when header filters are on, filter row -1.useGridAriaRows({ headerFilterRow }). "Custom filter" opens the panel viaGridToolbarHost.registerFiltersPanel+forceFiltersOpen/onFiltersPanelClose - Fill handle:
useGridFillHandle(hook 36d, afteruseGridUndoRedo, insideuseGridRangeEditing) →rowRenderProps.fillHandle(built inGridViewport) →fillHandleColon the handle's row only;RANGE_FILL_HANDLE(32) flag;.ogx__cell-fill-handle(24 px hit area inside the corner cell). Drags go throughcellSelection.startTrackedDrag(pointer events, touch, auto-scroll, Escape cancels). Pure fill logic inlib/utils/editing/fill.ts(no regexes); commits viaapplyEdits(edits, 'fill'); Ctrl/Cmd+D / R always copy - Smoke suites of parallel agents share ports 4318/4319 — never run two at once
- Batch edits: paste, range clear, undo and redo commit through
useGridBatchEdit.applyEdits— oneprocessRowUpdateper row (rows in parallel, a failing row reported throughonProcessRowUpdateErrorwithout blocking others) and oneREPLACE_ROWSstore update for all successful rows.getRowsees rows stored since the last render. Single edits store throughstoreRow. Shared helpers inlib/utils/editing/commit.ts; edit targets (rows × range columns, same indices ascellSelection.getCurrentRange().resolved) inlib/utils/editing/targets.ts - Hook order (see
docs/architecture/datagrid-orchestration.md):useGridEditCommitChannel(12a) anduseGridBatchEdit(12b) beforeuseGridEditing;useGridClipboardPaste(36b) afteruseGridCellSelectionApi, thenuseGridUndoRedo(36c). Commits reach the history through the channel'semit(changes, source); history ignores'undo'/'redo'sources - Paste, Delete/Backspace and Ctrl/Cmd+Z / Shift+Z / Ctrl+Y are native listeners on the grid root, gated by
classifyKeyTarget('grid'/'control') and no open editor, so an editor's own paste and undo keep working - History: an action is recorded only after
processRowUpdatesucceeds, as{ id, field, before, after }withafterread from the stored row viagetCellValue. Undo writesbeforeexpectingafter(redo the reverse); stale cells are skipped'changed', removed rows'missing'; values are compared, not row identity - Parsing:
lib/utils/parsing.ts(parseTsv, per-typeparse*Text,parseValueByType,parseCellTextwithvalueParser) is pure and the single parser for paste and/ai(lib/ai/coerce.tsonly adds typed-JSON handling on top). Keep its regexes linear /aientry point (lib/ai/, no React, no dependencies): ships asdist/ai.es.js,dist/ai.cjs(.cjsbecause the package is"type": "module") anddist/ai.d.ts, built by a secondvite.config.lib.jsrun (--mode ai).check-bundleenforces no imports, no react, ≤ 5 KB gzipped (≈ 4.6 KB now) and that the core bundles don't contain the marker'OpenGridX grid state'. Demo, Vitest and tsconfig alias@opencorestack/opengridx/aibefore the root alias.lib/ai/columns.tsis the single source of which columns each part allows; the validator reads own keys only, never__proto__, caps lists at 200 and errors at 100, and never throws (fuzz test)- Shared inputs:
InputisforwardRefwithvariant: 'field' | 'cell'andinputClassName(classNamestays on the wrapper);errorsetsaria-invalid. Built-in search boxes, the filter value box and text/number/date editors render throughInput; the boolean editor and list-view checkbox throughCheckbox(hidden<input>: tests click.ogx-checkbox__box). The boolean editor needspreventDefaulton mousedown on the whole.ogx__edit-booleandiv. Browser tests that click styled controls must importlib/styles/opengridx.css - Browser paste tests copy from a textarea and press the paste shortcut (
ControlOrMeta+V); Firefox dropsclipboardDataon a syntheticClipboardEvent
- Cell range selection (
cellSelection, design:docs/superpowers/specs/2026-10-01-cell-range-selection-design.md).useGridCellSelectionruns afteruseGridSpanningand beforeuseGridKeyboardNavigation(feeds itcellSelection.keyboard);useGridCellSelectionApiruns afteruseGridPointerFocusHandlers, keeps the anchor in sync withfocusedCelland installs the sixapiRefmethods. Pure helpers inlib/utils/cellSelection.ts - The model stores corner row ids + fields and is resolved each render against
allRenderableRowsand the data columns ofnavigationColumns, so a range follows sorting; a missing corner clears it with reason'dataChange'. Only the first range is used.isRangeColumnFieldexcludes system fields and__group__; synthetic rows are highlighted but skipped by copy andgetSelectedCells RowisReact.memo(areRowPropsEqual,cellRangecompared by value); row renderers passgetRowCellRange(...),nulloutside the range. Handlers passed to rows must keep a stable identity, or every row re-renders- With
cellSelectionon, the Ctrl/Cmd+C shortcut copies the range (or the focused cell) throughuseGridClipboard'sgetShortcutText, no header line;copySelectedRowsis unchanged. A drag clears the page text selection (WebKit otherwise extends it outside the grid and takes over Ctrl+C) - Browser tests drive held-mouse drags with the Playwright-backed
commands.pointerMoveTo/pointerDown/pointerUpfromvitest.config.ts - Wrapped header text:
wrapHeaderText(grid) andGridColDef.wrapHeaderText(column wins);lib/utils/headerWrap.ts(getHeaderLineClamp,HEADER_WRAP_LINE_HEIGHT16 must matchHeader.css). The header never grows; classogx__header-cell--wrap - React Compiler: ESLint runs
react-hooksrecommended-latest(onlyset-state-in-effectoff).npm run check:compilershould report onlyuseGridDataSource(try/finally) plus the two'use no memo'wrappers (InlineToolbar,CellRenderTarget— they call consumer functions that may use hooks).REACT_COMPILER=1 npx vitest run …testslib/compiled. In components and hooks: no?./??/||/ternaries insidetry(useattempt()fromlib/utils/attempt.ts), notry/finally, no callback naming itself (go through a ref), default array/object props as module-level constants - Vitest's unit project excludes
.claude/**(agent worktrees)
- Regexes that run on cell text or consumer input must be linear (CodeQL scans every push): no adjacent overlapping quantifiers such as
\s*(px)?\s*or[\d\s]*\d[\d\s]*; use a lookahead, trim first, or a plain loop. Tests inlib/utils/export/exportShared.test.tstime 50k-char worst cases exceljsis a devDependency as well as an optional peer (likejspdf);uuidis overridden to^11.1.1in the root and both e2e fixtures
- Boolean edit checkbox calls
preventDefault()on mousedown (GridEditInputCell.tsx): Safari does not focus a checkbox on click, so the cell blur committed the old value first - Package smoke suite
npm run test:smoke(e2e/,scripts/smoke.mjs) runs inci.yml(PRs, branches, manual) and beforenpm publish; weeklynightly.yml. Fixture lockfiles must not carry anintegrityfield for the tarball
exportToPdfis time-sliced (runPdfExport+PdfExportTuninginlib/utils/export/exportToPdf.ts; internal, not exported from the package). Yields usesetTimeout(0), notscheduler.yield()(which resumes ahead of queued tasks and starves React's scheduler). The body is drawn as several autoTable calls: continuation chunks start atstartY: MARGINand awillDrawPagehook movesdata.cursor.yto the previousfinalYand setssettings.showHead = 'never'for that page only (bypasses autoTable's fit-below-startY page break, so breaks match a single call). Chunks are even-sized (alternate shading parity); foot only on the last chunk.exportToPdf.browser.test.tschecks drawing ops are identical to a single call- New options
onProgress,signal(DOMException'AbortError', nothing saved),maxRows(defaultPDF_MAX_ROWS_DEFAULT20000, dev warning only)
GridColDef.sortComparator(v1, v2, params1, params2): ascending comparator; sees null/undefined (no built-in nulls-last for that column); negated for desc. Sorting utils (lib/utils/sorting) take it throughGridSortValue.comparator+params— two items use it only when both carry the same comparator, otherwise the built-in key comparison applies (tree auto-parents, group rows sorted by a non-grouping field). Always call it throughcallSortComparator(throw / NaN → 0, warn once). Row grouping passes the grouping column's comparator for group rows; pivot uses the source column's for row-label fields- Column auto-size:
useGridColumnAutosize(hooks/core, afteruseGridScrollToIndexesApi) measures vialib/utils/columnAutosize(.ogx__cell-content/.ogx__header-cell-contentatmax-content+ cell padding/borders, scoped to the grid's own.ogxroot so nested grids are skipped) and writes throughhandleColumnResize. InstallsapiRef.autosizeColumn/autosizeColumns;ColumnResizeHandlecalls it on double-click and Enter. Only rendered rows are measured
- Without a
heightprop the root getsogx--fill(height: 100%; flex: 1 1 auto; min-height: 0) and fills its container; in an auto-height container it still grows to fit. The unbounded warning inuseGridDevWarningsalso requires the viewport to be at least as tall as the rows - The list view does not virtualize;
useGridDevWarningswarns aboveLIST_VIEW_WARN_THRESHOLD(2000) items - The column resize handle lies inside its header cell (the cell clips overflow); right-pinned columns take
margin-left: autoon their first cell and.ogx__contentismax(100%, totalWidth)when right-pinned columns exist GridApi<R>/useGridApiRef<R>()type the row getters;apiRefprop acceptsMutableRefObject<GridApi<R>> | MutableRefObject<GridApi>.ogx-list-view__rowshastabIndex={-1}(Firefox makes scroll containers Tab stops). CI (deploy + publish workflows) installs and runs Chromium, Firefox and WebKit- Demo pages: a
<DataGrid>withoutheightnow stretches to its parent; give demo grids an explicitheight(or a sized wrapper) unless filling is intended
Correctness release; full list in CHANGELOG.md, consumer guide in docs/migration/v2-to-v3.md. Facts future work needs:
- Rows are never modified. No
idor underscore fields are written; the row store maps rows to ids viagetRowIdandidByRow(useGridRowIdOf). Hierarchy info lives only inrowMetaMap - Generics use
GridValidRowModel(object), notGridRowModel, so consumer interfaces work;DataGridand exports have typed and untyped-columns overloads - Read cell values through
getCellValue(lib/utils/values.ts): it appliesvalueGetterand contains throws (valueundefined, dev warning once per column). Don't callvalueGetterdirectly - Consumer callbacks must be contained (throws fall back to defaults); synthetic rows (group, subtotal, auto-parent, pivot Grand Total) are never selectable and never start or join row spans
DataGrid.tsxhook layout is documented indocs/architecture/datagrid-orchestration.mdnpm run typecheck(lib, tests, demo) is a gate alongside lint and build
- Row-grouping aggregation now shares
lib/utils/aggregation(nulls ignored,unique,availableAggregationFunctions). Expansion in bothuseRowGroupinganduseTreeDatais derived state (depth default plus user overrides keyed by config); no effect resets it on row changes. Never call callbacks such asonRowExpansionChangeinside asetStateupdater - Hierarchy renderers injected by
useGridColumnsmust renderparams.formattedValue, notparams.value, orvalueFormatteris lost;CellpassesformattedValueintorenderCellparams slots.footer/noRowsOverlay/loadingOverlayare rendered (they were typed-only).footerreplaces the pagination area and receives grid state- New:
onRowDoubleClick, exportedColumnVisibilityPanel,ExcelAdvancedExportOptions.groupedRows - Row indices:
renderContextandlayout.cumulativeHeightscover center (unpinned) rows only;allRenderableRowsindices include top-pinned rows. Convert withpinnedTopRows.length. Vertical scroll-into-view goes throughlib/utils/scroll(scrollRowIntoView), which accounts for the sticky header and pinned rows - Virtualization needs a bounded container height. In an unbounded container (e.g. flex child without
min-height: 0) the viewport grows to content and every row renders; pagination masks it and grouping disables pagination.useGridDevWarningswarns about both
The following props existed in v1.x but had no runtime implementation. They have been removed from the public TypeScript types. (GridColDef.description was not removed — it renders as the header title tooltip.)
| Removed | Type | Reason |
|---|---|---|
onPinnedRowsChange |
DataGridProps |
No pin/unpin-row UI exists; callback was never called |
onRowGroupingModelChange |
DataGridProps |
No drag-to-group UI; callback was never called |
density?: 'compact' | 'standard' | 'comfortable' now controls effectiveRowHeight (compact=32, standard=rowHeight, comfortable=72) via CSS --ogx-row-height. Previously the prop was accepted but silently discarded.
Both props are now wired in handleRowClick. disableRowSelectionOnClick suppresses click-to-select; disableMultipleRowSelection caps selection to a single row.
When rowGroupingModel is active, passing groupingColDef now creates a dedicated synthetic __group__ column at position 0, auto-pinned left. Previously the prop was accepted but had no runtime effect.
Columns with groupable: false are now skipped when building the grouping tree in useRowGrouping. Previously this flag was silently ignored.
groupingValueFormatter?: (params: { field: string; value: unknown }) => stringCustomizes the label for group-header rows at that level. Falls back to "${field}: ${value}" when omitted. The formatted string flows through GridRowMeta.groupLabel so useGridColumns picks it up for rendering.
Per-column availableAggregationFunctions?: string[] now gates which aggregation functions are computed in useAggregation. If the current aggregationModel entry for a field uses a function not in that list, the aggregation is skipped for that field.
Shift-clicking a column header now appends to (or removes from) sortModel rather than replacing it. The sort priority badge (1, 2, 3…) appears next to the sort arrow when more than one sort key is active. Plain click still replaces with a single-key sort.
GridApi.getGroupedExportRows()— new method returning a flatGridGroupedExportRow[]ordered list by traversing the active row-grouping tree (group-header → children → subtotal → grand-total). Returnsnullwhen row grouping is not active.GridGroupedExportRowtype — public interface for each entry:type(group-header | leaf | group-subtotal | grand-total),depth, optionalgroupField,groupValue,aggregatedValues,row.- Grouped export across all formats —
exportToCsv,exportToExcel,exportToJson,printGrid,exportToPdfall acceptgroupedRows?: GridGroupedExportRow[]; flat behavior unchanged when absent.
hideable: falsecolumns excluded from Columns panel —ColumnVisibilityPanelnow filters out columns withhideable: falseby default, removing the permanently-disabled rows that added visual noise. Columns still render in the grid body and exports unchanged.GridToolbarProps.showNonHideableColumns— new optional boolean prop. Passtrueto restore the old behavior (show non-hideable columns as disabled rows in the panel). Default:false.
CellErrorBoundaryCellRenderTarget pattern —renderFnis now called inside a module-scopeCellRenderTargetchild component so the throw happens in a descendant; boundaries cannot catch errors in their ownrender()resetKey={row}— error boundary resets when the row object reference changes (not just the cell value), so "Restore" correctly clears error state after fresh row objects are providedgetAllFilteredRows()API — newGridApimethod returning all filtered+sorted rows regardless of pagination; use for full-dataset exports- PDF aggregation footer —
PdfExportDemonow computes the aggregation directly from exported rows (bypassesuseEffect-basedgetAggregationResult); footer now reliably appears with correct values - PDF footer text color — aggregation footer row text is now explicitly black (
[0,0,0]) instead of inheriting the default (which could render invisible against the light gray fill) exportToPdfstandalone API — usesautoTable(doc, opts)standalone function instead ofdoc.autoTable(opts)to avoid unreliable ESM prototype patching
exportToPdf— native PDF export via optional peer depsjspdf+jspdf-autotable; lazy-loaded, zero bundle impact for non-PDF consumersPdfExportOptions— 12-field interface: fileName, title, logoUrl, orientation, selectedRows, aggregationResult, aggregationModel, filterModel, alternateRowColor, headerBackgroundColor, headerTextColor, fontSizeCellErrorBoundaryfix — changed torenderFnprop so the boundary callsrenderCellinside its ownrender(); previously the throw occurred in the parent before the boundary could catch it- GitHub Pages SPA routing — deploy workflow now copies
404.htmlso direct URL access to demo routes works
GridRowMeta— hierarchy metadata moved offGridRowModelintoMap<GridRowId, GridRowMeta>; exposed viaparams.rowMetainrenderCellCellErrorBoundary—renderCellerrors now caught per-cell; grid continues renderingGridLocaleText/localeTextprop — all pagination strings are now overrideable for i18n- Duplicate DOM ID fix —
Rowcheckbox andFilterPanelinputs now useuseId()(wasogx-select-row-${row.id}, caused duplicates with two grids on one page)