-
Notifications
You must be signed in to change notification settings - Fork 0
Migrating v2 to v3
📝 Generated from
docs/migration/v2-to-v3.md. Edit it there; changes made in the wiki are overwritten.
This guide covers every change in @opencorestack/opengridx 3.0.0 that can require action when upgrading from any 2.x release. 3.0.0 is mostly a correctness release: nearly every item below is a bug fix. They are listed here because they change what your users see, what files contain, or what your callbacks receive, and code written around the old behaviour can break.
Use the table to find the sections that apply to you. Sections are ordered by how many apps they are likely to affect. The full list of fixes is in CHANGELOG.md.
| If your app… | Go to |
|---|---|
has columns with type: 'date', 'boolean', 'singleSelect' or 'image' and no valueFormatter / renderCell
|
§1 |
passes getRowId, or reads row.id on rows that have their own key field |
§2 |
reads params.row._hasChildren, _treeDepth, _isGroupRow or another underscore field |
§3 |
mutates params.row inside renderCell / valueGetter / valueFormatter under grouping or tree data, mutates columns in place, or has a valueGetter that is impure or can throw |
§4 |
builds filter items in code, relies on empty filter values, or uses the toolbar search / renderQuickFilter
|
§5 |
| sorts numeric strings, dates stored as strings, accented text, or uses the column menu with multi-sort | §6 |
relies on Tab moving between cells, on Enter in a non-editable cell, or on colIndex / rowIndex / aria-* values |
§7 |
uses pagination, rowCount, pageSizeOptions or onPaginationModelChange
|
§8 |
uses checkbox selection, select-all, disableMultipleRowSelection or a controlled rowSelectionModel
|
§9 |
calls apiRef.current.* methods (setters, getAllFilteredRows, getVisibleRows, getVisibleColumns) |
§10 |
edits cells: processRowUpdate, isCellEditable, renderEditCell, singleSelect / date / boolean editors |
§11 |
| exports CSV, Excel, JSON, print or PDF files, or parses them downstream | §12 |
shows aggregation totals (footer, group rows, getAggregationResult()) |
§13 |
uses row grouping or tree data, parses group row ids, or uses getAggregationPosition
|
§14 |
passes a dataSource, uses infinite scroll or server-side pagination / sorting / filtering |
§15 |
pins columns or rows, sets column widths, uses detail panels, onRowsScrollEnd, or has CSS for pinned rows |
§16 |
uses onRowOrderChange, onColumnOrderChange, a controlled columnOrder, or tests column resizing |
§17 |
has a <form> around the grid, uses the exported Button / Checkbox / GridTooltip, or a custom toolbar |
§18 |
copies rows with Ctrl+C or apiRef.current.copySelectedRows()
|
§19 |
uses DataGridThemeProvider, a preset theme, or theme heights |
§20 |
uses pivotMode
|
§21 |
uses colSpan, rowSpan or columnGroupingModel
|
§22 |
uses listView
|
§23 |
has custom CSS targeting ogx__* / ogx-* classes or grid DOM structure |
§24 |
| relies on TypeScript types of rows, columns or props | §25 |
persists grid state: onStateChange, initialState, useGridStateStorage (including with SSR) |
§26 |
After upgrading, restart your dev server and clear the bundler cache (for Vite: rm -rf node_modules/.vite, then start with --force). If you skip this, Vite can keep serving the old version even though node_modules contains the new one, and the old behaviour looks like a regression.
The stylesheet is not loaded by the JavaScript entry. It never was: some v2 docs said it was imported automatically, which was wrong. Import it once, in your app's root file:
import '@opencorestack/opengridx/styles';Without it the grid is unstyled and its viewport has no bounded height, so every row renders (virtualization is effectively off). If your v2 app looked fine, you already have this import. In development, 3.0.0 logs a warning when the stylesheet is not loaded.
3.0.1 note — height. Without a
heightprop the grid now fills its container (root classogx--fill:height: 100%, and as a flex itemflex: 1 1 auto; min-height: 0). In 3.0.0 the root was auto-height, so inside a bounded flex layout it grew to fit every row. The container still needs a bounded height (a definite height, ormin-height: 0on every flex / grid ancestor up to the sized one); in an auto-height container the grid still grows to fit. An explicitheight,style.heightorautoHeightis never stretched. The unbounded-container dev warning is only logged when the viewport really is as tall as its rows.
A single scrolling grid tops out at about 645,000 rows at the default 52px row height in Chromium (the browser's maximum element height). Above that, use pagination or a dataSource. The grid logs a dev warning when content is taller than browsers can scroll.
What changed: columns with a type and no valueFormatter are now formatted for display. This applies to cells, params.formattedValue, list view, and the CSV, basic Excel, print and PDF exports.
type |
v2 showed | v3 shows |
|---|---|---|
'date' |
String(value), e.g. Mon Mar 04 2024 00:00:00 GMT+0100
|
value.toLocaleDateString(), e.g. 3/4/2024
|
'boolean' |
true / false
|
Yes / No
|
'singleSelect' |
the raw value, e.g. 2
|
the matching valueOptions label |
'image' (no renderCell) |
the URL as text | <img class="ogx__cell-image"> |
Who is affected: apps whose tests, snapshots or downstream parsers expect the old text, and apps that post-process params.formattedValue.
How to find it: grep -rnE "type: *['\"](date|boolean|singleSelect|image)['\"]" src/
Fix it: if you want a different format, add a valueFormatter; it always wins over the default.
import type { GridColDef } from '@opencorestack/opengridx';
const columns: GridColDef[] = [
{
field: 'createdAt',
type: 'date',
valueFormatter: ({ value }) => (value instanceof Date ? value.toISOString().slice(0, 10) : ''),
},
{ field: 'active', type: 'boolean', valueFormatter: ({ value }) => (value ? 'true' : 'false') },
];What changed: in v2, getRowId rows were copied and id was overwritten with getRowId(row). In v3 rows are stored untouched and keyed by getRowId internally. Everywhere you receive a row (params.row, onRowClick, processRowUpdate, apiRef, dataSource rows, tree children), it is your own object.
| v2 | v3 | |
|---|---|---|
row.id with getRowId={(r) => r.sku}
|
r.sku (overwritten) |
your row's own id (often undefined) |
row.id === getRowId(row) |
always true | not guaranteed |
Exporting selectedRows
|
matched on row.id
|
matched on row.id unless you pass getRowId
|
| Duplicate ids | later row won and rendered twice | first row kept, dev warning |
Inline getRowId={(r) => r.sku}
|
reset the row store on every render | only a new rows array or changed ids reset it |
Who is affected: apps that pass getRowId and then read row.id, and apps that export the selection with getRowId.
How to find it: grep -rn "getRowId" src/, then check for .id reads on rows in the same components.
Fix it: use your own key, and pass getRowId to exports.
import { exportToCsv } from '@opencorestack/opengridx';
import type { GridApi, GridColDef, GridRowModel } from '@opencorestack/opengridx';
interface Product extends GridRowModel { sku: string; name: string }
const getRowId = (row: GridRowModel): string => String(row.sku);
function exportSelection(api: GridApi, rows: Product[], columns: GridColDef<Product>[]): void {
exportToCsv(rows, columns, { selectedRows: api.getSelectedRows(), getRowId });
}What changed: until v3, useTreeData and useRowGrouping copied each row and added these fields to the copy before it reached renderCell:
_hasChildren, _treeDepth, _isExpanded, _groupingField, _groupingValue, _descendantCount, _isGroupRow
They were deprecated in v1.1 and removed from the TypeScript types then. In v3 they are no longer added at runtime either. The same information has been available as params.rowMeta since v1.1.
Who is affected: code that reads any of these fields. TypeScript does not warn about it, because GridRowModel has an index signature. Reading params.row._hasChildren type-checks as unknown and is simply undefined in v3.
How to find it:
grep -rnE "_(hasChildren|treeDepth|isExpanded|groupingField|groupingValue|descendantCount|isGroupRow)\b" src/Fix it: read params.rowMeta instead. It is undefined for flat rows, so always use optional chaining.
| v2 (row field) | v3 (params.rowMeta) |
|---|---|
params.row._hasChildren |
params.rowMeta?.hasChildren |
params.row._treeDepth |
params.rowMeta?.treeDepth |
params.row._isExpanded |
params.rowMeta?.isExpanded |
params.row._groupingField |
params.rowMeta?.groupingField |
params.row._groupingValue |
params.rowMeta?.groupingValue |
params.row._descendantCount |
params.rowMeta?.descendantCount |
params.row._isGroupRow |
params.rowMeta?.isGroupRow |
rowMeta also has groupLabel, the formatted label the grid shows for a group row, and (new) isGroupFooter for subtotal rows.
import type { GridColDef } from '@opencorestack/opengridx';
// v2: undefined in v3
const nameV2: GridColDef = {
field: 'name',
renderCell: (params) => {
const row = params.row as Record<string, unknown>;
return row._hasChildren ? <strong>{params.formattedValue}</strong> : params.formattedValue;
},
};
// v3
const name: GridColDef = {
field: 'name',
renderCell: (params) =>
params.rowMeta?.hasChildren ? <strong>{params.formattedValue}</strong> : params.formattedValue,
};rowMeta (and formattedValue) are passed to renderCell, renderEditCell and the function form of cellClassName.
Tree data: the parent rows the grid creates for missing path segments used to carry _isGroupRow: true. Use params.rowMeta?.isGroupRow to recognise them (see also §14).
What changed: because v2 copied every row to attach the underscore fields, params.row under row grouping or tree data was a copy. In v3 it is the same object you passed in rows, exactly as it is without grouping. This also stops rows being re-created on every render, so cells re-render less.
Who is affected: only code that mutates params.row (or the row passed to valueGetter / valueFormatter) while grouping or tree data is on. Those mutations used to land on a throwaway copy; now they change your data. Mutating row objects inside render callbacks was never supported, so this should be rare.
How to find it: search renderCell, valueGetter and valueFormatter bodies for params.row.x = or row.x =.
Fix it: don't mutate rows during render. Derive values instead, or update rows through state and processRowUpdate.
If you compare row objects by identity (===), grouped and flat rows now behave the same: the grid passes your objects through unchanged.
| Behaviour | v2 | v3 |
|---|---|---|
Inline columns (a new array each render) |
every render re-ran filtering and sorting | reused while the definitions are shallow-equal, so mutating a column object in place is not detected |
| Quick filter text | re-read every cell per keystroke | cached per row object, so a valueGetter must be pure (same row object, same result) |
A valueGetter / valueFormatter that throws |
crashed the grid once the column was sorted, filtered, aggregated, grouped, pivoted or shown in list view | the value reads as undefined, with a dev warning once per column |
Fix it: pass a new column object (or a new array with new objects) when a definition changes; keep valueGetter free of side effects and outside state, and replace a row object when its data changes.
What changed: a filter item whose value is empty (undefined, null, '', whitespace, []) is now inactive, whatever the operator. Operators that take no value (isEmpty, isNotEmpty) still apply.
| Filter item | v2 | v3 |
|---|---|---|
{ operator: 'equals', value: null } |
kept only null cells | no filter |
{ operator: '=', value: '' } |
hid every row | no filter |
{ operator: '>', value: '' } |
meant > 0
|
no filter |
{ operator: 'contains', value: '' } |
hid null cells | no filter |
{ operator: 'isAnyOf', value: [] } |
hid every row | no filter |
{ operator: 'isAnyOf', value: 'a' } (not an array) |
matched nothing | treated as ['a']
|
Fix it: to match empty cells, use isEmpty, not an empty value.
| Case | v2 | v3 |
|---|---|---|
blank / whitespace cell with >, <, = … |
treated as 0
|
empty: never matches |
| boolean cell with a numeric operator |
1 / 0
|
never matches |
!= and null / blank cells |
!= 0 excluded null |
always included |
after / onOrAfter / before / onOrBefore
|
ignored (every row passed, warning per row) | filter by date |
is / not on type: 'date'
|
raw string comparison | local calendar day, for Date, ISO strings and epoch numbers |
GridFilterOperator gained '=', 'after', 'onOrAfter', 'before' and 'onOrBefore'. Exhaustive switch statements over GridFilterOperator need the new cases.
| v2 | v3 | |
|---|---|---|
| What is searched | every value on the row: id, hidden columns, fields that are not columns | visible, filterable columns only |
| Value searched | raw value (objects as [object Object], dates as Date.toString()) |
valueGetter result formatted by valueFormatter (dates as YYYY-MM-DD; objects skipped) |
Toolbar search "north 2024"
|
one phrase | two words, both must match (in any columns) |
renderQuickFilter value
|
quickFilterValues[0] |
all terms joined by spaces |
Toolbar search and Filters button without a filterModel prop |
did nothing unless you wired onFilterModelChange
|
filter the grid themselves |
initialState.filter |
ignored | applied |
Who is affected: apps that relied on searching hidden columns or ids. Fix it: to make a column searchable, it must be visible and filterable (the default). To search a value that is not shown, add a column for it.
- Opening the panel no longer emits
onFilterModelChangeor rewrites values (anisAnyOfarray used to become"a,b"). - Editing a condition replaces it in place (it moved to the end) and no longer drops filter groups or other conditions on the same column.
-
singleSelectcolumns get a select (multi-select forisAnyOf, which emits an array); typed option values are preserved. Boolean "Any" removes the item. Date columns get<input type="date">. Clearing a text value keeps the item with value''. - The panel shows a note when the model has conditions it cannot display (nested groups).
- The row-grouping
__group__column is alwaysfilterable: false;groupingColDefcannot override it.
What changed: the comparator is type-aware and locale-aware.
| Case | v2 | v3 |
|---|---|---|
numeric strings in type: 'number'
|
compared as text ("10" < "9") |
compared as numbers |
date strings in type: 'date'
|
compared as text | parsed and compared as dates |
| text | code-point order |
Intl.Collator with numeric collation: accents next to their base letter, item9 < item10
|
NaN / Invalid Date |
broke the sort | sorted with nulls (last ascending, first descending) |
| mixed numbers and strings | undefined order | numbers first |
valueGetter columns |
not sortable | sort by the computed value |
Multi-sort:
| Action | v2 | v3 |
|---|---|---|
Shift-click (or multiSort) on the primary sort column |
moved it to the end | keeps its priority |
| Column menu Unsort | cleared every sort key | removes only that column |
Column menu Asc / Desc on a sorted column, or with multiSort
|
replaced the model | keeps the other keys |
Enter / Space on a header with multiSort or Shift |
always replaced | appends, like a click |
| Header click that clears the sort of one column while several are sorted | cleared every sort key | removes only that column |
apiRef.current.sortColumn() |
did not update the grid | replaces the model like a header click (appends under multiSort) |
Who is affected: apps with snapshot tests of sorted output, or server code that expects the old client order.
Fix it: update the snapshots. Since v3.1.0 a column can take a custom sortComparator(v1, v2, params1, params2) (see docs/features/sorting-pagination.md), which is the simplest way to get a custom order. On 3.0.x there is no per-column comparator; to sort by a different key, return that key from a valueGetter and format it for display with valueFormatter (or sort on the server with sortingMode="server"):
import type { GridColDef, GridRowModel } from '@opencorestack/opengridx';
interface Ticket extends GridRowModel { id: number; priority: 'low' | 'medium' | 'high' }
const RANK: Record<Ticket['priority'], number> = { low: 0, medium: 1, high: 2 };
const LABEL = ['low', 'medium', 'high'] as const;
const priority: GridColDef<Ticket> = {
field: 'priority',
type: 'number',
valueGetter: ({ row }) => RANK[row.priority],
valueFormatter: ({ value }) => LABEL[Number(value)] ?? '',
};What changed:
| Behaviour | v2 | v3 |
|---|---|---|
| Tab outside edit mode | moved between editable and system cells | leaves the grid in one press (Shift+Tab leaves backwards); focus returns to the last cell when you tab back in |
| Tab while editing | next editable cell | unchanged |
| Enter on a non-editable cell | nothing | on a row with children (group rows, tree-data parents): toggles expansion only, no onRowClick and no selection; on any other row: behaves like a click (onRowClick, click-to-select) |
| Enter on an editable cell | starts editing | unchanged |
Header focusedCell (exported Header prop) |
{ id: 'HEADER', field } |
{ id: null, field } |
| Ctrl/Cmd+C | copied even when focus was outside the grid | only from a focused grid, never over a text selection |
| Focus ring on blur | stayed | hidden, but the focused cell is remembered |
Cell / Row / Header components |
moved DOM focus themselves | the grid owns focus (standalone users must focus cells themselves) |
New shortcuts: Shift+Space selects the focused row, Ctrl/Cmd+A selects all, Alt+ArrowRight / Alt+ArrowLeft expand and collapse, Alt+ArrowDown or Ctrl/Cmd+Enter opens a header's column menu, Alt+ArrowLeft / Alt+ArrowRight on a focused header resizes it.
Indices:
| Value | v2 | v3 |
|---|---|---|
colIndex in GridCellParams, renderCell, cellClassName, renderHeader, data-colindex
|
relative to the rendered window (changed while scrolling horizontally) | absolute among visible data columns in render order, left-pinned first |
rowIndex of bottom-pinned rows (onRowClick, getDetailPanelContent, data-rowindex, stripe class) |
0..n, colliding with page rows |
topPinned + pageRows + i |
aria-rowindex |
page-local; bottom-pinned rows restarted at 1 | global, 1 = header row, group rows counted |
aria-colindex |
window-relative | absolute, system columns counted |
aria-colcount |
all columns | visible columns, including __group__ and pivot columns |
aria-sort |
every sorted column | the primary sort column only; others get an aria-description with their priority |
Also: the detail panel is role="row" > role="gridcell"; ExpandIcon has tabIndex={-1}; the CellErrorBoundary glyph is role="img" (was status); hierarchy rows get aria-level / aria-expanded; the grid gets aria-multiselectable.
Who is affected: apps (and end-user docs) that describe Tab navigation, apps with onRowClick handlers that must not fire from the keyboard, code that stores colIndex or rowIndex, and accessibility tests.
How to find it: grep -rnE "colIndex|rowIndex|data-(col|row)index|aria-(row|col)index|['\"]HEADER['\"]" src/
Fix it: key your logic on field and row id, not on indices. If you stored colIndex to look up a column, use params.field. Enter on an ordinary row now fires onRowClick like a click, so make that handler safe to run from the keyboard. On a tree-data parent, Enter only expands or collapses; it does not fire onRowClick or select the row (a mouse click on it does both).
| Behaviour | v2 | v3 |
|---|---|---|
| Client-side total | all stored rows, including pinned and filtered-out | filtered, unpinned rows |
| Current page after data shrinks | stale, empty page | last page, and onPaginationModelChange({ ...model, page: last }) fires once |
rowCount prop |
read at mount only | read live; a dataSource response's rowCount wins |
Default pageSize when 100 is not in pageSizeOptions
|
100 (not in the list) | the first option |
pageSize missing from pageSizeOptions
|
select showed a blank value | shown as an extra option |
| Rows-per-page select accessible name | hard-coded "Rows per page" | localeText.paginationRowsPerPage |
paginationMode="server" (and server sort / filter) without a dataSource
|
the grid re-sliced / re-sorted / re-filtered your server page | rows are shown as given |
slots.footer rowCount
|
page length | server total |
slots.footer rowCount in a flat grid |
included pinned rows | excludes pinned rows (under tree data or grouping it counts data rows) |
pagination with paginationMode="infinite"
|
sliced rows | pager and slicing ignored |
Who is affected: controlled paginationModel users (you now receive a page correction and must adopt it), and server-paginated grids that relied on the grid's extra slicing.
Fix it: in controlled mode, always store the model passed to onPaginationModelChange:
import { useState } from 'react';
import { DataGrid } from '@opencorestack/opengridx';
import type { GridColDef, GridPaginationModel, GridRowModel } from '@opencorestack/opengridx';
export function Orders({ rows, columns }: { rows: GridRowModel[]; columns: GridColDef[] }) {
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({ page: 0, pageSize: 25 });
return (
<DataGrid
rows={rows}
columns={columns}
pagination
pageSizeOptions={[25, 50, 100]}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
/>
);
}| Behaviour | v2 | v3 |
|---|---|---|
| Select-all checkbox | replaced the selection with every stored row, including filtered-out rows | adds the filtered rows; deselect removes only those |
| Header checkbox state | counted stale ids and group ids | reflects the filtered rows only |
disableMultipleRowSelection |
honoured by row click only | also caps checkboxes, Space and apiRef; removes the select-all checkbox |
Rows removed from rows
|
their ids stayed selected | pruned; onRowSelectionModelChange fires once with the pruned model |
An action that leaves the selection as it was (for example Ctrl/Cmd+A when every row is already selected, or apiRef.current.selectRow(id, true) on a selected row) |
UI handlers fired onRowSelectionModelChange with an identical model |
does not fire |
apiRef.current.selectRow(s) with a synthetic id (group, subtotal, auto-parent, pivot Grand Total) or with no effect |
selected it / fired | ignored; fires nothing |
| Clicking an already-selected row | deselected it and fired | unchanged: deselects it and fires |
apiRef.current.getSelectedRows() inside onRowSelectionModelChange
|
previous selection | the new selection |
| Group rows | had a checkbox | no checkbox, never selected |
Exported Header
|
always rendered select-all | renders it only when onSelectAll is passed |
Pruning applies when the grid owns the rows: no dataSource, client pagination and filtering, not pivot mode.
Who is affected: controlled rowSelectionModel users, and apps that expected select-all to include hidden rows.
Fix it: with a controlled model, adopt whatever onRowSelectionModelChange passes you (including the pruned model). If you need "select every row including filtered-out ones", set the model yourself from your data.
What changed: in v2 several apiRef methods wrote to an internal store the grid did not render from. In v3 they drive the grid and fire the matching callbacks.
| Method | v2 | v3 |
|---|---|---|
sortColumn, setFilterModel, setPage, setPageSize, selectRow, selectRows
|
no visible effect, no callbacks | change the grid and fire onSortModelChange / onFilterModelChange / onPaginationModelChange / onRowSelectionModelChange
|
getSortModel, getFilterModel, getSelectedRows
|
stale | live |
getVisibleRows, getAllFilteredRows
|
excluded pinned rows | include pinned rows |
getAllFilteredRows under grouping / tree data |
the visible hierarchy, including group rows | every filtered data row, in fully expanded order |
getAllFilteredRows under tree data with a filter |
— | only the rows that match (not their unmatched ancestors); follows screen order when sorting by the hierarchy column |
getVisibleColumns |
included hidden columns, definition order | visible columns in render order: left-pinned, unpinned, right-pinned |
getGroupedExportRows |
dropped collapsed groups' rows, ignored sort and filter | includes collapsed groups, follows sort and filter, subtotals over exported rows |
getAggregationModel |
the internal object | a content-equal copy |
copySelectedRows |
resolved and logged on failure | rejects (see §19) |
useGridApiRef().current before mount |
null |
a no-op API; the live API is installed in a layout effect |
Who is affected: apps that call a setter and then also update their own state "because the grid ignored it". With a controlled prop, the setter now fires your change callback, so both paths run.
How to find it: grep -rnE "apiRef\.current[?!]?\.(sortColumn|setFilterModel|setPage|setPageSize|selectRows?|getAllFilteredRows|getVisibleRows|getVisibleColumns)" src/
Fix it: call the setter and let your change callback update controlled state; remove duplicate setState calls.
3.0.1 note — typed row getters.
useGridApiRef<MyRow>()returns aGridApi<MyRow>whosegetRow,getAllRows,getVisibleRowsandgetAllFilteredRowsreturnMyRow, so the casts v2 needed can go.useGridApiRef()without a type argument andDataGridProps.apiRefwith an untyped ref still compile.
| Behaviour | v2 | v3 |
|---|---|---|
isCellEditable |
only decided Tab stops | also governs double-click, Enter and aria-readonly; returning true for a column without editable no longer makes it a Tab stop |
| Row-grouping group rows, auto-created tree parents | Enter opened an editor whose value was discarded | not editable |
| Tree-data parent rows | Enter only | Enter and double-click |
processRowUpdate per commit |
sometimes twice (Tab; Enter then blur) | exactly once |
| Edit when the cell unmounts (scroll, filter, page) or another edit starts | dropped | committed: processRowUpdate is called |
Synchronous processRowUpdate result |
applied after a microtask | applied in the same event |
processRowUpdate returns a non-object |
TypeError about .id
|
onProcessRowUpdateError receives an Error whose message starts with [OpenGridX] processRowUpdate must return the updated row object (or a Promise of it); the editor stays open |
singleSelect editor commit |
option value as a string ("2") |
the original option value (2, or the object) |
date editor on Date cells |
opened blank, committed 'YYYY-MM-DD'
|
shows the date, commits a Date; ISO datetimes keep their time; clearing commits null (was '') |
boolean editor |
committed on setTimeout(0), stayed open on blur |
commits synchronously and on blur |
| Enter during IME composition | committed | ignored |
onRowDoubleClick on editable cells |
suppressed | fires for the double-click that opens the editor (not inside an open editor) |
onCellClick / onRowClick for clicks inside an editor |
fired | do not fire |
| Focus after commit | always refocused the grid viewport | refocuses the grid only if focus was lost to <body>
|
valueGetter + editable without valueSetter
|
wrote row[field] silently |
still writes row[field], with a dev warning once per column |
Who is affected: anyone with processRowUpdate that has side effects (API calls): it now also runs for edits that used to be lost, so make sure it is safe to call when the user scrolls away. Apps with string-parsing workarounds for singleSelect or date values.
How to find it: grep -rnE "processRowUpdate|isCellEditable|renderEditCell|Number\(newRow|parseInt\(newRow" src/
Fix it: remove value-type workarounds, and add a valueSetter for editable computed columns:
import type { GridColDef, GridRowModel } from '@opencorestack/opengridx';
interface Person extends GridRowModel { id: number; first: string; last: string }
const fullName: GridColDef<Person> = {
field: 'fullName',
editable: true,
valueGetter: ({ row }) => `${row.first} ${row.last}`,
valueSetter: ({ value, row }) => {
const [first = '', ...rest] = String(value ?? '').split(' ');
return { ...row, first, last: rest.join(' ') };
},
};Custom editors now get callbacks, so they no longer need to reach into grid internals:
import type { GridColDef } from '@opencorestack/opengridx';
const notes: GridColDef = {
field: 'notes',
editable: true,
renderEditCell: ({ value, onValueChange, onCommit, onCancel }) => (
<textarea
aria-label="Notes"
value={String(value ?? '')}
onChange={(e) => onValueChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) onCommit();
if (e.key === 'Escape') onCancel();
}}
/>
),
};The type of renderEditCell's parameter is now GridRenderEditCellParams, a superset of GridRenderCellParams, so existing editors still type-check.
For the exported Cell / Row components: Cell.onEditStop gains an optional second argument field; Cell no longer stops propagation of the double-click that starts an edit (so it reaches the row and onRowDoubleClick); Row.onEditStop params gain optional id / field; new optional Row.isCellEditable; pinned rows report aria-readonly. See §25 for the type-level details.
In v2, group-header labels differed between export formats: exportToExcelAdvanced used the column's groupingValueFormatter, falling back to "Header: value" (the column's headerName), while CSV, basic Excel, JSON, print and PDF ignored groupingValueFormatter and always wrote "field: value".
In v3 every format writes the label the grid shows. They use, in order:
- the entry's
groupLabel, whichapiRef.current.getGroupedExportRows()now sets from the grid's own label; - otherwise the grouping column's
groupingValueFormatter; - otherwise
"field: value", the grid's documented default.
Who is affected: exportToExcelAdvanced users grouping by a column without a groupingValueFormatter (headers change from Department: Engineering to dept: Engineering), and CSV / basic Excel / JSON / print / PDF users grouping by a column with one (headers now use the formatter).
Fix it: set groupingValueFormatter on the grouping column. It then applies in the grid and in every export format:
import type { GridColDef } from '@opencorestack/opengridx';
const dept: GridColDef = {
field: 'dept',
headerName: 'Department',
groupingValueFormatter: ({ value }) => `Department: ${String(value)}`,
};| Change | v2 | v3 | Opt out / fix |
|---|---|---|---|
| CSV byte-order mark | none | starts with EF BB BF
|
bom: false |
Text starting with = + - @ tab or CR (CSV, basic Excel) |
written raw | prefixed with ' (-abc → '-abc; negative numbers unchanged) |
escapeFormulas: false |
Non-empty selectedRows together with groupedRows
|
exported every grouped row | exports only the selection, flat | pass selectedRows: [] to export grouped |
selectedRows with aggregationResult / aggregationModel
|
totals over all rows | totals recomputed over the selection | pre-filter rows yourself to keep your own totals |
valueFormatter for null / undefined
|
skipped (empty cell) | called; its placeholder is exported | return '' for empty values |
count / unique totals |
ran the column formatter ($2.00) |
plain numbers (2) |
— |
Aggregate formatter row argument |
{} |
the record of aggregated values | — |
Date min / max into the formatter |
epoch ms | Date |
— |
| Throwing formatter on a total | export threw | falls back to the default format | — |
| First column with an aggregate | label replaced the value |
Subtotal: 300, Grand Total: …, PDF TOTAL: 30
|
— |
isSpacer columns |
exported | excluded everywhere | — |
Grid system columns passed in columns (__checkbox_col__, __expand_col__, __reorder_col__, __group__) |
exported unless exportable: false
|
always excluded (grouped exports write labels from groupedRows) |
— |
| Type-based default formatting | none | see §1 | add a valueFormatter
|
| Function | Change |
|---|---|
exportToExcel |
A .xlsx file name downloads as .xls with a warning (the file is HTML; Excel refuses it as .xlsx). Sheet names are sanitised (\ / ? * : [ ] → -, 31 characters). Text cells are marked as text, so 00501 keeps its leading zeros. |
exportToJson |
Flat aggregation.values are raw numbers (were locale strings like "8,320,000"). Grouped subtotals and grand total contain only exported columns. |
printGrid |
Image URLs other than http(s), data:image, blob: and relative are printed as text; missing alt text falls back to the field name (was "undefined"). |
exportToExcelAdvanced |
Dates carry the local wall-clock date (east of UTC they showed the previous day). ISO strings and epoch numbers in date columns become date cells; numeric strings in number columns become numbers; 'true' / 'false' in boolean columns become booleans; NaN / Infinity / Invalid Date become empty cells; objects become JSON text (formula / hyperlink objects are no longer live). |
exportToExcelAdvanced |
Default numFmt is #,##0 for integers and #,##0.## otherwise; number columns get no column-level numFmt; count / unique use #,##0; includeSummary and the summary sheet are numeric. |
exportToExcelAdvanced |
rows: 'selected' with no selection writes a header-only sheet (was all rows), and its includeSummary is recomputed over the selection. Invalid or duplicate sheet names are sanitised (X-Y, Data (2)) instead of throwing. embedImage reads the URL after valueGetter; SVG / WebP / AVIF write the URL as text (were broken .png). |
exportToPdf |
Characters outside WinAnsi print as ? with a console warning; pass font (a Unicode .ttf, base64) to print them. U+202F / U+2212 become a space / -. The filter line joins with AND / OR (was •), shows groups and the search, skips value-less rules. An invalid headerTextColor falls back to white (was indigo). Title and filter line wrap. A custom JsPDF must provide splitTextToSize, addFileToVFS and addFont. |
Who is affected: anything that parses exported files: importers, diff-based tests, scripts that read CSV without handling a BOM, and workflows that open basic Excel exports under a .xlsx name.
How to find it: grep -rnE "exportTo(Csv|Excel|ExcelAdvanced|Json|Pdf)|printGrid" src/
Fix it: strip the BOM in CSV parsers (or pass bom: false), pass getRowId with selectedRows (§2), rename .xlsx to .xls for exportToExcel (or use exportToExcelAdvanced for real .xlsx):
import { exportToCsv, exportToExcel } from '@opencorestack/opengridx';
import type { GridColDef, GridRowModel } from '@opencorestack/opengridx';
function exportAll(rows: GridRowModel[], columns: GridColDef[]): void {
exportToCsv(rows, columns, { fileName: 'orders.csv', bom: false, escapeFormulas: true });
exportToExcel(rows, columns, { fileName: 'orders.xls' });
}| Case | v2 | v3 |
|---|---|---|
Blank strings, booleans, arrays in sum / avg / min / max
|
counted as 0 (avg([10, '', 20]) = 10; sum over a boolean column = count of true) |
ignored (avg = 15; boolean sum = 0) |
count / unique
|
counted '' and whitespace-only strings |
skip them |
min / max over dates |
epoch ms |
Date, in getAggregationResult(), group-row aggregatedValues, getGroupedExportRows(), pivot cells and usePivot rows |
valueGetter columns |
aggregated row[field] (usually 0) |
aggregate the computed value |
aggregable: false with a model entry |
summed anyway | ignored (footer, group rows, pivot, exports) |
Footer sum / avg / min / max text |
formatAggregationValue |
the column's valueFormatter (with row = the aggregation result); falls back if it throws |
Group rows: count / unique
|
ran the column formatter ($2.00) |
plain (2) |
| Group rows: aggregated column without formatter | 3000 |
locale number 3,000
|
| Under grouping: footer totals | aggregated the visible rows (double-counted expanded groups) | the filtered data rows, independent of expansion |
Server-driven footer (infinite mode, filterMode="server" only) |
summed the loaded rows | server totals, or —
|
Server totals while a new getAggregations is pending or after it failed |
previous result |
{} (shown as —); isLoading / error follow the current request |
Who is affected: apps that display or test totals, and code that treats min / max results as numbers.
How to find it: grep -rnE "getAggregationResult|aggregationModel|aggregatedValues" src/
Fix it: handle Date results:
function toTimestamp(value: unknown): number | null {
if (value instanceof Date) return value.getTime();
return typeof value === 'number' ? value : null;
}If you want blanks counted as zero, store 0 in the data or compute the value in a valueGetter.
| Behaviour | v2 | v3 |
|---|---|---|
renderCell for synthetic rows (group rows, subtotal rows, auto-created tree parents) |
not called | called; return undefined to keep the default rendering |
| Click on a real tree-data parent row | toggled expansion | selects the row and fires onRowClick (the chevron, Enter and Alt+Arrow keys expand) |
| Click on a row-grouping group row | toggled | unchanged; does not fire onRowClick
|
| Auto-created tree parent row object | carried the segment name |
{ id } only; use rowMeta.groupLabel for the label |
Subtotals, "(n)" count, descendantCount
|
counted filtered-out rows | only rows that pass the filter; groups left empty are hidden; tree descendantCount is recursive |
getAggregationPosition |
ignored | honoured; called for each group and once with null for the grand total (return null for it to hide the footer) |
| Group row ids for non-string values | auto-group-<field>-<String(value)>-<parent> |
the value part is a typed key (\u001fnumber:1, \u001fnull, \u001fdate:<ms>), so 1 and '1' are different groups; string values unchanged |
| Tree auto-parent ids | auto-group-a/b |
auto-group-["a","b"] (JSON path, so segments may contain /) |
| Group rows | had a checkbox, could get a detail panel | no checkbox, no detail panel, not editable |
treeData without getTreeDataPath
|
undefined behaviour | rows shown flat |
pinnedRows under tree data or grouping |
vanished, leaving a gap | stay in the hierarchy, with a dev warning |
Server sortingMode / filterMode
|
the hierarchy still filtered and sorted client-side | not re-filtered or re-sorted |
groupingColDef under treeData
|
ignored | adds the pinned __group__ column, which takes the hierarchy toggle |
Row grouping with a paginationMode="server" dataSource
|
grouped only the first page | one request for [0, Number.MAX_SAFE_INTEGER), with a dev warning; make sure your server can return every row |
Who is affected: apps with custom renderCell that assume it only runs for data rows, apps that parse group ids, and apps whose tree parents were expected to expand on click.
How to find it: grep -rnE "auto-group-|getAggregationPosition|isGroupRow" src/
Fix it: guard renderCell, and never parse ids; use rowMeta:
import type { GridColDef } from '@opencorestack/opengridx';
const amount: GridColDef = {
field: 'amount',
renderCell: (params) => {
if (params.rowMeta?.isGroupRow) return undefined; // keep the grid's group rendering
return <span className="amount">{params.formattedValue}</span>;
},
};
const groupKey = (meta: { groupingField?: string; groupingValue?: unknown } | undefined) =>
meta ? `${meta.groupingField ?? ''}=${String(meta.groupingValue)}` : null;Make getAggregationPosition handle null:
import type { GridTreeNode } from '@opencorestack/opengridx';
const getAggregationPosition = (node: GridTreeNode | null): 'inline' | 'footer' | null =>
node === null ? 'footer' : node.isExpanded ? 'footer' : 'inline';Keep getTreeDataPath stable (module scope or useCallback); a new function rebuilds the tree.
| Behaviour | v2 | v3 |
|---|---|---|
dataSource with every mode 'client'
|
getRows never called |
called once with { startRow: 0, endRow: Number.MAX_SAFE_INTEGER }
|
| Server sort / filter with client pagination | refetched per page, showed one page | all rows requested once; page changes do not refetch |
| Infinite scroll request range |
[page * pageSize, page * pageSize + pageSize) (could skip rows) |
[rows loaded so far, (page + 1) * pageSize) |
Infinite scroll after sort / filter / pageSize / dataSource change |
continued from the current page | restarts at row 0 and fires onPaginationModelChange({ ...model, page: 0 })
|
| Tree children request | endRow: -1 |
endRow: Number.MAX_SAFE_INTEGER, plus aggregationModel
|
Server tree with defaultGroupingExpansionDepth
|
lazy nodes stayed collapsed | nodes to that depth auto-expand and load, one request per node (-1 expands everything, which can mean many requests) |
| Tree children request fails | error overlay covered the grid | node collapses, error logged |
| Retry button | window.location.reload() |
re-runs getRows; hidden without a dataSource
|
| When a refetch happens | any new object identity (filterModel, sortModel, aggregationModel, dataSource) |
a new getRows function or changed content |
loading / aria-busy
|
after the 300 ms debounce | from the moment a request is scheduled |
| Live-region error fallback | Error: Unknown error |
Error: An unexpected error occurred while loading the data. |
Inline rows={[]} next to a dataSource
|
wiped fetched rows | ignored |
Who is affected: every getRows implementation that validates startRow / endRow, treats endRow: -1 as "all", or assumes page-aligned requests.
How to find it: grep -rnE "getRows|endRow|startRow|paginationMode[=:{ ]*['\"]infinite" src/
Fix it: slice by the requested range, whatever it is:
import type { GridGetRowsParams, GridGetRowsResponse, GridRowModel } from '@opencorestack/opengridx';
async function getRows(params: GridGetRowsParams): Promise<GridGetRowsResponse> {
const all: GridRowModel[] = await fetchSortedFiltered(params); // your server call
const end = Math.min(params.endRow, all.length);
return { rows: all.slice(params.startRow, end), rowCount: all.length };
}
declare function fetchSortedFiltered(params: GridGetRowsParams): Promise<GridRowModel[]>;Keep getRows stable (useCallback or module scope) if you do not want a refetch.
| Behaviour | v2 | v3 |
|---|---|---|
| Pinned column order | cells in column order, offsets in pinned order (overlaps) |
pinnedColumns.left / .right order; the column menu appends, so the most recently pinned column sits next to the scrolling area |
Pinned '30%' / 'auto' / flex / no width |
100px | sized like unpinned columns |
Fixed or resized widths outside minWidth / maxWidth
|
layout used the raw width, CSS clamped only the cell | layout clamps |
Several % columns |
cascaded | share the width left after fixed-width columns |
getDetailPanelHeight returns 0
|
200px | 0px |
getDetailPanelHeight returns 'auto'
|
200px | measured height |
Detail height without getDetailPanelHeight
|
200px (docs said 'auto') |
200px (docs corrected) |
| Detail callbacks | called for every rendered row, including group rows | only for expanded data rows; group-row ids in detailPanelExpandedRowIds are ignored |
onRowsScrollEnd |
fired on every scroll event near the bottom (including horizontal), never for a short list | once per arrival; re-armed when you leave the zone or rowCount changes; also fires after mount or a rows change when the end is already in view |
| Sticky header and pinned rows |
.ogx__pinned-rows--top / --bottom were sticky |
wrapped in div.ogx__sticky-top / div.ogx__sticky-bottom (see §24) |
| z-index of sticky body system cells | 4 / 5 / 11 | 12; header drag / expand cells have no inline z-index |
| Returning from list view | scroll reset | scroll position restored |
| Right-pinned columns when columns are narrower than the grid | followed the last unpinned column | at the grid's right edge (3.0.1; free space sits before the right-pinned section) |
No height prop |
auto height (3.0.0) | fills the container, ogx--fill (3.0.1; see Before you start) |
Who is affected: apps with onRowsScrollEnd loaders that relied on repeated firing, apps with custom z-index or sticky CSS, and apps whose pinned columns were pinned out of column order.
How to find it: grep -rnE "pinnedColumns|onRowsScrollEnd|getDetailPanelHeight|ogx__pinned-rows" src/
Fix it: list pinned fields in the order you want them on screen; guard onRowsScrollEnd loaders against concurrent calls rather than relying on repeat events.
import { DataGrid } from '@opencorestack/opengridx';
import type { GridColDef, GridRowModel } from '@opencorestack/opengridx';
export function Pinned({ rows, columns }: { rows: GridRowModel[]; columns: GridColDef[] }) {
// Left to right on screen: id, then name.
return <DataGrid rows={rows} columns={columns} pinnedColumns={{ left: ['id', 'name'], right: ['actions'] }} />;
}| Behaviour | v2 | v3 |
|---|---|---|
onRowOrderChange oldIndex / targetIndex
|
page-local, in sorted / filtered order, pinned rows excluded | positions in your rows prop |
| Group rows, auto-created tree parents | draggable | not draggable or droppable |
Pinned rows with rowReordering
|
no handle cell (misaligned) | empty .ogx__cell--drag-handle (draggable=false) |
rowReordering in pivot mode |
handles shown | no effect |
onColumnOrderChange indices |
varied by path | positions in the full current column order (hidden columns and __group__ included) |
Keeping a controlled columnOrder in sync |
onColumnOrderChange only |
use the new onColumnOrderModelChange(columnOrder)
|
| Columns panel Reset | fired onColumnOrderChange (or nothing) |
fires onColumnOrderModelChange
|
| Pinned headers | draggable, drop targets | neither |
pinnable: false |
also blocked drag-reorder | only blocks pinning |
disableColumnReorder + columnOrder
|
order ignored | order applied |
| Resize limits | 50–1000px |
minWidth ?? 50 (never above the current width), no maximum unless maxWidth
|
| Click on the resize handle without moving | resized / sorted | nothing |
| Resize events | mouse events on document
|
pointer events with capture on the handle (touch and pen work) |
| Right-pinned column resize | right edge | left edge (ogx-column-resize-handle--start); drag left to widen |
| Resize handle position | straddled the cell border (right: -4px); half was clipped, 3–4px grabbable |
inside its own header cell against the resizing edge (right: 0 / left: 0), all 8px grabbable (3.0.1) |
dragstart data types |
text/plain |
application/x-ogx-row / application/x-ogx-column plus text/plain
|
| Toolbar, pivot and standalone panels | portal mounted in render | mounted one layout effect later (invisible to users; matters for tests) |
Who is affected: apps that added page offsets to onRowOrderChange indices, apps with a controlled columnOrder, and tests that simulate resizing with mouse events.
How to find it: grep -rnE "onRowOrderChange|onColumnOrderChange|columnOrder=|mousedown|fireEvent\.mouse(Down|Move|Up)" src/
Fix it: remove page-offset workarounds, and switch controlled column order to the model callback:
import { useState } from 'react';
import { DataGrid } from '@opencorestack/opengridx';
import type { GridColDef, GridColumnOrder, GridRowModel, GridRowOrderChangeParams } from '@opencorestack/opengridx';
export function Reorderable({ initialRows, columns }: { initialRows: GridRowModel[]; columns: GridColDef[] }) {
const [rows, setRows] = useState(initialRows);
const [columnOrder, setColumnOrder] = useState<GridColumnOrder>(columns.map((c) => c.field));
const onRowOrderChange = ({ oldIndex, targetIndex }: GridRowOrderChangeParams) => {
setRows((prev) => {
const next = [...prev];
const [moved] = next.splice(oldIndex, 1);
next.splice(targetIndex, 0, moved);
return next;
});
};
return (
<DataGrid
rows={rows}
columns={columns}
rowReordering
onRowOrderChange={onRowOrderChange}
columnOrder={columnOrder}
onColumnOrderModelChange={setColumnOrder}
/>
);
}In tests, resize with fireEvent.pointerDown / pointerMove / pointerUp on the handle instead of mouse events.
| Behaviour | v2 | v3 |
|---|---|---|
Grid buttons and the exported Button
|
default type="submit": clicking a toolbar or pager button inside a <form> submitted it |
type="button"; pass type="submit" explicitly |
Exported Checkbox
|
added aria-label "Select" / "Deselect" / "Select some" |
no default label; pass label or aria-label
|
GridTooltip |
rendered into document.body, position: absolute
|
renders into .ogx-theme-provider, position: fixed, opens on focus, child gets aria-describedby
|
| Column menu | offered Hide on hideable: false, pinning on pinnable: false
|
omits them |
Manage columns with a custom slots.toolbar that does not render GridToolbar
|
did nothing | opens a standalone panel |
onColumnsPanelClose |
fired only on click outside | fires on every close (Escape, button) |
Standalone GridToolbar without onAggregationModelChange
|
showed Summaries | hides it; pills include unique and respect availableAggregationFunctions
|
| Toolbar triggers | no popup ARIA |
aria-haspopup="dialog", aria-expanded
|
loading with rows present |
nothing shown | progress bar (or slots.loadingOverlay) over the rows |
Who is affected: apps with a form around the grid that relied on a grid button submitting it (unlikely; the usual symptom was accidental submits), apps using the exported Checkbox without a label, and CSS or tests that look for tooltips under body.
How to find it: grep -rnE "<(Button|Checkbox|GridTooltip)\b" src/
Fix it:
import { Button, Checkbox } from '@opencorestack/opengridx';
export function Actions({ checked, onToggle }: { checked: boolean; onToggle: () => void }) {
return (
<>
<Checkbox checked={checked} onChange={onToggle} aria-label="Select order" />
<Button type="submit">Save</Button>
</>
);
}| Behaviour | v2 | v3 |
|---|---|---|
apiRef.current.copySelectedRows() on failure |
resolved, logged | rejects; Ctrl+C still only logs |
| Copied columns | every column definition, in definition order, hidden included | visible columns in screen order (left-pinned first); exportable: false dropped |
| Copied rows | selected rows on the current page, in expanded groups | every selected row that passes the filter (other pages, pinned rows, collapsed groups) |
Values with tab, newline, CR or "
|
raw | wrapped in quotes, inner quotes doubled |
Fields starting with __
|
dropped | copied, unless system or exportable: false
|
valueGetter columns |
copied row[field]
|
copied the getter value; valueFormatter is called for null / undefined (a throw gives an empty cell) |
execCommand fallback |
ran on every copy | only when navigator.clipboard is missing or rejects |
Ctrl+C already preventDefaulted by your handler |
copied anyway | left alone |
| Formulas | not neutralised | still not neutralised (copying is not a file export) |
New: disableClipboardCopy turns off the Ctrl/Cmd+C handler (the apiRef method still works).
Fix it:
import type { GridApi } from '@opencorestack/opengridx';
async function copy(api: GridApi): Promise<boolean> {
try {
await api.copySelectedRows();
return true;
} catch {
return false; // clipboard permission denied or unavailable
}
}| Behaviour | v2 | v3 |
|---|---|---|
DataGridThemeProvider palette |
partly followed the OS colour scheme (darkTheme in a light browser was unreadable) |
pins a complete palette chosen by GridTheme.mode ('light' default), whatever the OS |
Brand presets / colors.primary
|
did not recolour toolbar, filter panel, column menu, selection, focus | recolour them |
grid.rowHeightStandard / rowHeightCompact / rowHeightComfortable / headerHeight
|
no effect | size rows and header (props rowHeight / headerHeight still win); compactTheme gives 36px rows, 40px header, 12px text |
toolbar.*, scrollbar.*, overlays.itemDanger*, grid.cellFocusBorder
|
no effect | applied |
| Provider toolbar colours | fixed | follow grid.headerBackground / headerText / borderColor unless toolbar.* is set |
darkTheme toolbar |
transparent |
#1e293b, hover #334155
|
| Expanded, unfocused quick search | no border | input border (primary only while focused) |
| Accent colours | fixed colours | CSS color-mix(): Chrome 111+, Safari 16.2+, Firefox 113+ |
skeleton.darkBaseColor / darkHighlightColor
|
typed, no effect | removed |
Who is affected: apps that wrap the grid in DataGridThemeProvider and expected it to switch with the OS, apps using compactTheme (rows get shorter), and apps that must support browsers older than the versions above.
How to find it: grep -rnE "DataGridThemeProvider|compactTheme|darkBaseColor|darkHighlightColor" src/
Fix it: pick the theme yourself from the colour scheme, and delete the removed skeleton keys:
import { useSyncExternalStore, type ReactNode } from 'react';
import { DataGridThemeProvider, darkTheme } from '@opencorestack/opengridx';
import type { GridTheme } from '@opencorestack/opengridx';
const query = '(prefers-color-scheme: dark)';
const subscribe = (cb: () => void) => {
const mql = window.matchMedia(query);
mql.addEventListener('change', cb);
return () => mql.removeEventListener('change', cb);
};
const lightTheme: GridTheme = { mode: 'light' };
export function ThemedGrid({ children }: { children: ReactNode }) {
const dark = useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);
return <DataGridThemeProvider theme={dark ? darkTheme : lightTheme}>{children}</DataGridThemeProvider>;
}| Behaviour | v2 | v3 |
|---|---|---|
| Pivot row ids |
0, 1, 2 … (collided with source row ids) |
__pivot_row__:["North"] (JSON of the row-field values) |
| Filters and quick filter | applied to pivot rows | source-column filters and the quick filter apply to source rows (quick filter searches all filterable source columns); filters on generated value columns apply to pivot rows; OR across both kinds acts like AND |
| Grand Total row | could move when sorting, was selected by select-all | always last, never selectable (click, select-all, apiRef); absent when there are no rows |
getRowId |
applied to pivot rows (collapsed them into one) | ignored for pivot rows |
treeData / rowGroupingModel
|
crashed or regrouped | turned off while pivoting, with a dev warning |
dataSource |
broken output | pivot ignored, with a dev warning |
getAggregationResult() / slots.footer
|
totals over pivot rows | null |
| Empty result | columns disappeared | value columns stay; no-rows overlay shows |
| Row-label columns | lost formatter / renderer / type / alignment; hidden with their source | keep them; hideable: false; ignore columnVisibilityModel
|
| Column keys | text order | natural order (numbers numerically); header labels formatted; a blank group label is null
|
| Column order | controlled columnOrder could put value columns first |
pivot columns have their own order; a controlled columnOrder is ignored for them; leaving pivot restores your order |
groupable: false / aggregable: false fields |
used | skipped as row / column fields and as values; PivotPanel does not offer them |
Default avg format |
raw | locale-grouped (56,666.67) |
usePivot: UsePivotReturn is now a type alias with the same shape; pivotRows has no Grand Total when there are no rows.
Who is affected: apps that store pivot row ids or rely on selection in pivot mode. Fix it: do not persist pivot row ids across pivot model changes; read the row-field values from the row instead.
| Behaviour | v2 | v3 |
|---|---|---|
Hidden columns inside a colSpan
|
counted (width, aria-colspan, next visible column shifted) |
skipped: the span covers the next visible columns |
colSpan next to a pinned column |
could cover a column in another section | clamped to its own pinned section |
rowSpan across pinned and scrolling rows |
crossed | clamped to its row section and to the first row with an expanded detail panel |
rowSpan at group boundaries |
could start on or cross group, subtotal, auto-parent and pivot Grand Total rows | stops at them |
colSpan params.value / params.colIndex
|
raw row[field]; index counted system columns in unpinned order |
valueGetter result; rendered data-column index, left-pinned first (same as renderCell) |
Span values 1.5 / Infinity / NaN
|
fractional ARIA / hang |
1 / to the end / no span |
| Throwing span callback | unmounted the grid | span 1 for that cell, dev warning |
colSpan + rowSpan
|
only the origin column hidden in following rows | the whole rectangle is covered |
| Merged cell width | capped by the origin column's min/maxWidth
|
not capped |
| Render window | spans crossing the window edge were cut | extra rows / columns rendered so spans stay intact |
| Column group header rows | did not follow widths, hiding, reordering, pinning | follow the rendered columns; a split group shows one labelled cell per run |
Header drag-reorder with columnGroupingModel
|
disabled for every column | allowed within the same innermost group; toolbar and Columns panel get the same restriction |
| Keyboard | focus lost on covered cells | ArrowRight / ArrowDown from a span origin skip covered cells; moving into a covered cell focuses the origin |
GridColumnGroup.headerClassName |
ignored (documented as a background colour) | CSS class added to the group's header cells |
CSS: .ogx-col-group-row no longer has overflow: hidden; new ogx-col-group-cell--pinned, --pinned-left, --pinned-right; filler cells lost role="columnheader"; aria-rowcount includes the group depth. The unexported ColumnGroupHeader component was deleted.
Who is affected: apps whose span callbacks count on hidden columns or read params.colIndex. Fix it: compute spans from params.field and params.value.
| Behaviour | v2 | v3 |
|---|---|---|
renderCell params |
value undefined; no formattedValue / rowMeta; colDef was { field }
|
real values, formattedValue, rowMeta and the column definition |
slots.footer |
not rendered | rendered, replaces the list pagination |
loading, slots.loadingOverlay, slots.noRowsOverlay
|
ignored; "No Data" while loading | honoured |
listView without listViewColumn
|
empty | falls back to the grid, with a dev warning |
| Row checkboxes | Tab stops | not Tab stops; rows are focusable, arrow keys move between rows, Shift+Space selects |
| Empty state | role="status" |
no role="status"
|
aria-rowindex / aria-rowcount
|
restarted at 2 every page / page size | absolute / total |
| Tree data and grouping | no expand control | expand chevron and depth indent (.ogx-list-view__expand) |
Summary and paging with a dataSource
|
loaded rows | server total |
onRowsScrollEnd |
never fired | fires once per arrival at the bottom, like the grid view |
| Large lists | not virtualized | still not virtualized; 3.0.1 warns in development above 2,000 rendered items — use pagination |
| Tab in Firefox | — | lands on the focused row, not the rows container (3.0.1, tabIndex={-1} on .ogx-list-view__rows) |
ogx__* / ogx-* class names and the grid's DOM structure are public API: consumers style and test against them. These changed in 3.0.0.
| Change | v2 | v3 | Action |
|---|---|---|---|
| Sticky header and top-pinned rows |
.ogx__pinned-rows--top was sticky |
header + top-pinned rows inside div.ogx__sticky-top
|
move position / top / z-index rules to .ogx__sticky-top
|
| Bottom-pinned rows and aggregation footer |
.ogx__pinned-rows--bottom was sticky |
inside div.ogx__sticky-bottom
|
move rules to .ogx__sticky-bottom
|
| Empty-state overlay | before top-pinned rows | after them | adjust sibling selectors |
| Aggregation footer | cells for hidden columns; spacers not sticky | rendered from visible columns; spacers .ogx__aggregation-spacer (sticky: --pinned) |
update selectors that count footer cells |
| Tooltip | child of body
|
child of .ogx-theme-provider, position: fixed
|
update tooltip selectors |
| Detail panel |
role mismatch |
role="row" > role="gridcell"
|
update a11y test queries |
| Column group filler cells | role="columnheader" |
no role, hidden from AT | update a11y test queries |
| Sticky body system cells z-index | 4 / 5 / 11 | 12 | re-check custom overlays above the grid body |
| Columns panel item |
<label class="ogx-column-visibility-panel__item-label"> wrapping the checkbox and a <span class="ogx-column-visibility-panel__label">
|
<div class="ogx-column-visibility-panel__item-label"> with the checkbox and a <label for> class="ogx-column-visibility-panel__label"
|
update selectors and test queries that expect a label / span
|
Double-click on an editable cell (exported Cell) |
propagation stopped when it started an edit | bubbles to the row | remove workarounds for the missing onRowDoubleClick / row dblclick
|
| Class | On |
|---|---|
ogx__sticky-top, ogx__sticky-bottom
|
sticky wrappers |
ogx__header-cell--pinned-left-last, ogx__header-cell--pinned-right-first
|
pinned-section edge header cells |
ogx__cell--pinned-left-last, ogx__cell--pinned-right-first
|
pinned-section edge body cells |
ogx__aggregation-spacer, ogx__aggregation-spacer--pinned
|
footer spacers |
ogx__aggregation-footer--loading |
footer while server totals load |
ogx__loading-bar, ogx__loading-overlay--over-rows, ogx__loading-overlay--custom
|
loading over existing rows |
ogx__row--group-footer |
subtotal rows in 'footer' position |
ogx__cell-image |
default <img> for type: 'image'
|
ogx-column-resize-handle--start |
resize handle on the left edge (right-pinned columns) |
ogx--fill (3.0.1) |
grid root when no height, style.height or autoHeight is set |
ogx-col-group-cell--pinned, ogx-col-group-cell--pinned-left, ogx-col-group-cell--pinned-right
|
pinned column-group cells |
ogx-filter__hidden-note, ogx-filter__value-multiselect
|
filter panel |
ogx-list-view__expand, ogx-list-view__loading
|
list view |
ogx-tooltip--left, ogx-tooltip--right
|
tooltip placements |
ogx-column-visibility-panel__item-label |
Columns panel item row; the class existed in v2 but had no stylesheet rule |
| Class | Change |
|---|---|
.ogx__pinned-rows--top, .ogx__pinned-rows--bottom
|
no longer position: sticky
|
.ogx-col-group-row |
no longer overflow: hidden
|
.ogx__cell--drag-handle |
now also rendered (empty, draggable=false) on pinned rows |
.ogx-expand-icon, .ogx__detail-panel
|
their prefers-color-scheme: dark rules were removed; the theme palette colours them |
.ogx-global-search--expanded |
shows the input border while expanded and unfocused; the primary border and focus shadow moved to :focus-within
|
.ogx__cell--focused, .ogx__header-cell--focused, .ogx__header-cell--focus-visible
|
outline colour reads --ogx-grid-cell-focus-border (falls back to --ogx-color-primary) |
.ogx__header-cell--drag-over |
background --ogx-color-primary-light (was the undefined --ogx-color-blue-50) |
.ogx-column-resize-handle |
touch-action: none; a :focus-visible line colour; 3.0.1: right: 0 (was -4px), justify-content: flex-end; --start is left: 0, flex-start
|
.ogx__header-cell--pinned-right-first, .ogx__cell--pinned-right-first, .ogx__aggregation-cell--pinned-right-first, first right-pinned .ogx-col-group-cell (3.0.1) |
margin-left: auto; .ogx__content width is max(100%, <total>px) when right-pinned columns exist |
.ogx-list-view__row |
:focus-visible outline (rows are focusable) |
.ogx-toolbar and its buttons / chips, .ogx-global-search, scrollbars, filter-panel delete button |
read the --ogx-toolbar-*, --ogx-scrollbar-* and --ogx-overlay-item-danger-* variables the theme provider sets (the filter-panel delete button used --ogx-toolbar-btn-danger-*); fallbacks match the v2 colours except the danger-button hover |
How to find it: grep -rnE "ogx(__|-)[a-z-]+" src/ --include='*.css' --include='*.scss' --include='*.ts' --include='*.tsx'
Most of these make code compile that did not compile before. A few make code that used to compile (unchecked) into a type error, because the types now describe what the grid actually passes.
What changed: every public generic is constrained by GridValidRowModel (object) instead of GridRowModel. Interfaces and type aliases can be used as the row type without extends GridRowModel or an index signature, and an untyped GridColDef[] can be passed next to typed rows. The default row type is still GridRowModel, so untyped code is unchanged.
| v2 | v3 | |
|---|---|---|
interface Employee { id: number; name: string } as the row type |
error: does not satisfy GridRowModel
|
compiles |
const columns: GridColDef[] = … with rows: Employee[]
|
TS2322 (the README example failed under strict) |
compiles |
renderCell / valueGetter / valueSetter params with GridColDef<Employee>
|
only via index-signature workarounds | typed as Employee
|
getRowId option of the export functions |
(row: GridRowModel) |
(row: R) |
Fix it: remove workarounds such as extends GridRowModel, [key: string]: unknown added only for the grid, or as unknown as GridColDef[] casts.
import { DataGrid } from '@opencorestack/opengridx';
import type { GridColDef } from '@opencorestack/opengridx';
interface Employee { id: number; name: string; salary: number }
const columns: GridColDef<Employee>[] = [
{ field: 'name', headerName: 'Name' },
{ field: 'salary', valueFormatter: ({ row }) => `$${row.salary.toLocaleString()}` },
];
export function Staff({ rows }: { rows: Employee[] }) {
return <DataGrid rows={rows} columns={columns} />;
}DataGrid is declared with a typed overload (DataGridProps<R>) and an untyped-columns overload (DataGridUntypedColumnsProps<R>). As a result, React.ComponentProps<typeof DataGrid> now resolves to the untyped overload. Use DataGridProps<R> when you need the props type (for a wrapper component, for example).
import { DataGrid } from '@opencorestack/opengridx';
import type { DataGridProps, GridValidRowModel } from '@opencorestack/opengridx';
export function MyGrid<R extends GridValidRowModel>(props: DataGridProps<R>) {
return <DataGrid {...props} density="compact" />;
}Each slot has an exported props type: GridToolbarSlotProps, GridPaginationSlotProps, GridOverlaySlotProps and GridFooterSlotProps. Slot components are checked against them.
| v2 | v3 | |
|---|---|---|
slots.footer: ({ rowCount }: { rowCount: number }) => … |
error (slots were ComponentType<Record<string, unknown>>) |
compiles, rowCount is checked |
Inline slots.toolbar: (props) => …
|
props untyped |
props is GridToolbarSlotProps
|
A class / memo / forwardRef slot typed Record<string, unknown>
|
compiled | type it with the slot's props type |
Known keys in slotProps.toolbar / pagination / footer
|
unchecked | checked (extra keys still allowed) |
-
groupingColDefisPartial<GridColDef<R>>:fieldis no longer required (a dummyfieldstill type-checks and is ignored). -
GridAggregationPositionis'inline' | 'footer' | null(it was an unexported, unused'footer' | 'inline' | 'both'). -
useGridApiRef()returnsMutableRefObject<GridApi>, which type-checks against theapiRefprop under@types/react18. -
GridInitialStateis an interface that accepts partialcolumns. -
GridFilterOperatorhas five new members (see §5); exhaustiveswitchstatements need the new cases. -
renderEditCelltakesGridRenderEditCellParams(a superset of the old params). Existing editors still type-check, but callingcol.renderEditCell(params)yourself with aGridRenderCellParamsis now a type error: pass aGridRenderEditCellParams(addonValueChange,onCommit,onCancel). -
GridColDef<Row>is assignable toGridColDefwhenRowis a type alias or extendsGridRowModel. It is not whenRowis an interface without an index signature: type the array asGridColDef<Row>[]instead ofGridColDef[], or keep the columns untyped and use the untyped-columns overload. -
usePivotis declared to returnPivotResult;UsePivotReturnis now a type alias of it (same fields). An alias cannot be augmented with declaration merging. - Exported
Header:focusedCell.idisGridRowId | null(a header cell hasid: null, was the string'HEADER'). Code that assigns it tostring | numberneeds anullcheck. - Exported
Cell:onEditStopis(cancel?: boolean, field?: string) => void. ExportedRow:onEditStopparams are{ cancel?, id?, field? }, androwSpanningCaches.hiddenCellOriginMapisRecord<GridRowId, Record<string, GridRowId>>(wasRecord<number, Record<string, number>>). - New optional props on the exported components:
Cell.isPinnedEdge,Cell.valueError,Row.onDetailPanelHeightChange,Row.isCellEditable,Row.rowId, andariaRowIndex/ariaColIndex/columnIndexMaponRow,CellandHeader. - The export option types are generic (
CsvExportOptions<R>,PdfExportOptions<R>, …) andUseAggregationParams<R>.columnsisGridColDef<R>[](an overload still acceptsGridColDef[]). -
GridApiis not generic: its methods still returnGridRowModel.params.valueis stillunknown; read typed values fromparams.row. - Removed unused, never-exported types from
lib/types:GridEditCellProps,GridRowModes,GridRowModesModel,GridDetailPanelContent,GridDetailPanelState,GridVirtualizationState,GridRenderContext,GridAggregationFunction.
What changed:
| Behaviour | v2 | v3 |
|---|---|---|
When onStateChange fires |
on every new prop identity, so inline models (sortModel={[…]}) or storing the state in parent state could loop |
once on mount, then only when the state's value changes |
onStateChange payload |
no density
|
includes density: { density }
|
columns.pinnedColumns / columns.columnOrder in the payload |
could contain the synthetic __group__ column |
only your own columns |
initialState.density |
ignored | applied (the density prop still wins) |
useGridStateStorage when storage is blocked (cookie blocking, sandboxed iframes) |
threw | no persistence, no error |
useGridStateStorage clearState()
|
undone by the next debounced write or the flush on unmount | final: removes the saved state and cancels any pending write |
useGridStateStorage writes |
on every re-render | only after a state change (debounced), plus a pending write on unmount |
Who is affected: apps that save grid state, and in particular:
-
The storage key can change (per user, per view) while the grid stays mounted. The grid reads
initialStateonly when it mounts, so remount it with the key:<DataGrid key={storageKey} … />. Without the remount the grid keeps its current state and its next change is saved under the new key. -
You call
clearState()and expect the grid to reset. It does not reset the mounted grid, and the grid's next state change is saved again. Remount the grid to start from defaults. -
Server-side rendering.
useGridStateStoragereads storage during the first render. On the server there is no storage, so the server renders the default state while the client's first render uses the saved state: a hydration mismatch. When users can have saved state, render the grid on the client only.
How to find it: grep -rnE "onStateChange|useGridStateStorage|initialState" src/
Fix it: remount with the key, and render a persisted grid on the client only in an SSR app:
import { useSyncExternalStore } from 'react';
import { DataGrid, useGridStateStorage } from '@opencorestack/opengridx';
import type { GridColDef, GridRowModel } from '@opencorestack/opengridx';
interface OrdersGridProps { userId: string; rows: GridRowModel[]; columns: GridColDef[] }
const subscribe = () => () => {};
// false on the server and during hydration, true after it.
const useIsClient = () => useSyncExternalStore(subscribe, () => true, () => false);
function SavedOrdersGrid({ userId, rows, columns }: OrdersGridProps) {
const storageKey = `orders-grid-${userId}`;
const { initialState, onStateChange } = useGridStateStorage(storageKey);
return (
<DataGrid
key={storageKey}
rows={rows}
columns={columns}
initialState={initialState}
onStateChange={onStateChange}
/>
);
}
export function OrdersGrid(props: OrdersGridProps) {
return useIsClient() ? <SavedOrdersGrid {...props} /> : null;
}In Next.js you can instead load the component with dynamic(() => import('./OrdersGrid'), { ssr: false }).
| Removed / renamed | Replacement |
|---|---|
Runtime row._hasChildren, _treeDepth, _isExpanded, _groupingField, _groupingValue, _descendantCount, _isGroupRow
|
params.rowMeta.* (§3) |
row.id written by getRowId
|
your own key; getRowId option on exports (§2) |
GridThemeSkeleton.darkBaseColor, darkHighlightColor
|
pick a dark theme via GridTheme.mode / darkTheme
|
Header focusedCell.id === 'HEADER'
|
focusedCell.id === null |
Default aria-label on Checkbox
|
pass label / aria-label
|
Default type="submit" on Button
|
pass type="submit"
|
OS-following palette inside DataGridThemeProvider
|
choose the theme yourself (§20) |
exportToExcel writing .xlsx names |
.xls, or exportToExcelAdvanced for real .xlsx
|
endRow: -1 in tree-children requests |
endRow: Number.MAX_SAFE_INTEGER |
| API | What it does |
|---|---|
GridColDef.valueSetter |
write edits of computed columns back onto the row |
renderEditCell onValueChange / onCommit / onCancel
|
build custom editors without internals |
onColumnOrderModelChange |
receive the whole column order after every change |
disableClipboardCopy |
let your own Ctrl/Cmd+C handler own the shortcut |
CsvExportOptions.bom, .escapeFormulas; ExcelExportOptions.escapeFormulas
|
control BOM and formula escaping |
getRowId on every export's options |
match selectedRows when rows are keyed by getRowId
|
PdfExportOptions.font |
print non-Latin-1 text in PDFs |
GridFilterOperator '=', 'after', 'onOrAfter', 'before', 'onOrBefore'
|
numeric equality and date ranges |
GridTheme.mode, colors.white / black / gray, grid.cellFontSize / headerFontSize
|
complete palettes and font sizes |
GridRowMeta.isGroupFooter |
recognise subtotal rows in 'footer' position |
GridGroupedExportRow.groupLabel |
the grid's group label on export rows |
| Keyboard: Shift+Space, Ctrl/Cmd+A, Alt+Arrow expand / collapse, Alt+ArrowDown column menu, Alt+Arrow header resize | keyboard parity with the mouse |
Pagination component and props types (CellProps, RowProps, HeaderProps, SkeletonProps, FilterPanelProps, PaginationProps, GridTooltipProps, ButtonProps, InputProps, CheckboxProps) |
typed custom slots and wrappers |
Type exports GridRenderEditCellParams, GridValueSetterParams, GridGroupedExportRow, GridRowScrollEndParams, GridColumnOrder, GridDataSourceState, GridDetailPanelHeight, GridAggregationPosition, GridSlots, GridSlotProps, GridThemeToolbar, GridThemeOverlays, GridThemeScrollbar, GridThemeSkeleton, GridThemeGrayScale
|
import instead of redeclaring |
- Restart the dev server and clear the bundler cache (
rm -rf node_modules/.vite). - Confirm
import '@opencorestack/opengridx/styles'is in your app root. - Check columns with
type: 'date' | 'boolean' | 'singleSelect' | 'image'; add avalueFormatterwhere you want the old text (§1). - With
getRowId: stop readingrow.id; passgetRowIdto exports ofselectedRows(§2). - Replace underscore hierarchy fields with
params.rowMeta(§3) and remove writes toparams.rowin render callbacks; replace column objects instead of mutating them and keepvalueGetterpure (§4). - Review programmatic filter items with empty values, exhaustive
GridFilterOperatorswitches, and search expectations (§5). - Update sorted-output snapshots; where you need a custom order, use a
valueGetterthat returns the sort key (§6). - Update keyboard docs/tests for Tab and Enter; stop storing
colIndex/rowIndex(§7). - In controlled pagination and selection, adopt the models the callbacks pass you (§8, §9).
- Remove duplicate state updates around
apiRefsetters (§10). - Make
processRowUpdatesafe to run for edits committed on scroll-away; drop value-type workarounds; addvalueSetterfor computed editable columns (§11). - Re-check exported files: BOM,
'prefixes, selection vs grouped,.xlsname, xlsx cell types, PDF fonts (§12). - Handle
Dateresults frommin/max; re-check totals with blank cells (§13). - Guard
renderCellfor synthetic rows; stop parsing group ids; handlenullingetAggregationPosition(§14). - Make
getRowshonour anystartRow/endRow, includingNumber.MAX_SAFE_INTEGER(§15). - Order
pinnedColumnsas you want them on screen; move sticky CSS to.ogx__sticky-top/.ogx__sticky-bottom(§16, §24). - Remove page offsets from
onRowOrderChange; useonColumnOrderModelChange; switch resize tests to pointer events (§17). - Label exported
Checkboxes; passtype="submit"where aButtonshould submit (§18). - Wrap
copySelectedRows()intry/catch(§19). - Choose light or dark theme yourself; remove
darkBaseColor/darkHighlightColor; checkcompactThemeheights (§20). - Run
tsc --noEmitand your test suite (§25). - With persisted state: remount the grid when the storage key changes, and render it client-only under SSR (§26).
OpenGridX 3.2.2 · MIT · This wiki is generated from docs/ on every push to main. To fix a page, open a PR against the source file.
Start here
Components
- DataGrid
- Header
- Row
- Cell
- Toolbar
- Pagination
- Filter Panel
- Tooltip
- Column Visibility
- Column Grouping
- Column Resizing
- Empty State
- Error Overlay
- Aggregation Footer
Features
- Virtualization
- Filtering & Search
- Sorting & Pagination
- Custom Pagination
- Editing & Reordering
- Row Selection
- Clipboard
- Pinning
- State Persistence
- Aggregation & Pivot
- Tree Data & Grouping
- Cell Spanning
- Master-Detail
- Keyboard & Accessibility
- List View
- Infinite Scroll
- Data Source
- Loading States
- Toolbar Customization
- Export (CSV, Excel, JSON, Print)
- PDF Export
Customization
Upgrading
Contributing
- Contributing
- Testing
- Roadmap
- DataGrid orchestration
- GridRowMeta
- useGridControlledState
- useGridRowPipeline
- useGridColumns
- useGridVirtualization
- useGridVisibleRows
- useGridScrollSync
- useGridStateSnapshot