Migration Notes
Track breaking changes and release-specific upgrade notes.
Not on strict semver yet
Public APIs (GridApi, ColumnDef, event payloads) can still change between releases without a formal deprecation cycle. Pin an exact version
rather than a caret range, and check this page before upgrading.
Migration notes are updated whenever a release changes public behavior, exported APIs, event payloads, or
documented defaults. Each fixed version directory (e.g. 1.4) carries its own copy
of this page describing the delta from the version before it — that copy is frozen at release time and never
edited afterward. For a plain list of what shipped in each release (not just breaking changes), see the
Changelog.
next (unreleased)
Tracks work that will ship in the release after 1.4.
Named views (plan 190)
Why: a saved view was a blob of grid state applied over whatever was on screen, workspace tabs were a second concept beside it, and a save was done when the store's call returned. A view is now one destination with an owner that acknowledges each save.
| Before | After |
|---|---|
workspace: { views: [{ id, name, presentation }] } | views: { defined: [{ id, name, presentation, query?, revision? }] } (a grid option) |
api.saveView(name, options) | api.views.saveAs(name, { scope, description, makeDefault }) |
api.applyView(id), applyView(null) | api.views.open(id), open(null) |
api.updateView(id) | api.views.save() (the open view): { status: 'saved' | 'refused' | 'stale', … } |
revertView, renameView, duplicateView, deleteView, setDefaultView | api.views.discard, rename, duplicate, remove, setDefault |
listViews(), hasWorkspace() | api.views.list(); views always work (without a store they last the session) |
getStatus().savedViews (views, activeViewId, defaultViewId, dirty, lastError) | getStatus().views (list, activeId, defaultId, modified, error, persistent) |
changes: columnOrder, columnWidths, columnVisibility, columnPinning, pivot, view, other | presentation, filters, sort, grouping, columns |
A saved view's state: { v, state } | presentation (the table's columns in tableView({ columns })) and query: { filter, sort, group } |
scope: 'system' | scope: 'app' (the app's own views, from views.defined) |
A view's settings { locked: true } | The view's locked, set with api.views.lock(id, true) |
storage: { views } holding { views, defaultViewId } | A GridViewStore (views.store: load, save(view, basedOn) answering { saved } / { refused } / { stale }, remove, setDefault), or storage: { views } holding { views, defaultId } |
A quick-filter chip writing into filter.columns (replacing the view's condition on its field) | A lens in filter.lenses, ANDed with the view's filter; read chips' state from filter.lenses |
viewSaved, viewApplied, viewDeleted, viewRenamed events | watch((g) => g.status.views, …); a save's answer is its result |
storage.layout holding { v: 6, state } (every state key) | { v: 7, state, views }: what belongs to no view in state; the open view and every draft in views |
storage: { personalLayouts }, GridPersonalLayouts | The person's own draft of the view, in their record (storage.layout) |
api.views.open(null): the grid stays as it is | The grid's own view (the table as set up), as drafted |
Fix: move workspace tabs to views.defined, and raise a definition's revision when you change it (the same
revision with other content is reported, and the one held is kept). Views kept by an earlier version are not read.
Field types, collections and composed parts (plan 189)
| Before | After |
|---|---|
{ field, header, ...selectColumnType(options) } | { field, header, type: selectType(options) } — every *ColumnType is now *Type, named by type |
ColumnTypeDefinition (a function returning column settings) | defineFieldType({ id, create }); read a type's settings as type.column |
createFieldControl(api, field, { value, onChange, rowId }) | createFieldControl(api, field, { bind: { value, onChange, context } }), or bind: { record } / { draft } |
<FieldControl value onChange rowId> | <FieldControl value onChange context>, or record / draft |
inspector: { fields: [...] } | inspector: { layout: [...] } (fields, and sections) |
A section's render + update, or renderSection | render(context): a DOM node (the same one keeps it), text or a React element, each refresh |
A tab's renderTab() | render(container): a React element, a node or text, or draw into container and return the cleanup |
GridRemoteDataOptions | Omit<GridDataOptions, 'offline'>: a server-side or infinite grid can save |
PartContent on sections and tabs | WorkspacePartContent; card and group parts keep PartContent |
api.getRowOrder() read as the sorted order | It is the source order; the shown order is api.records('matching').ids |
useGrid() and <GridScope>
| Before | After |
|---|---|
const api = useGridApi() (inside the grid) | const { api } = useGrid() |
a sibling given the api from onGridReady | <GridScope> around the grid and the sibling; useGrid() in the sibling (or keep onGridReady) |
React cell components take the value as unknown
Why: a React renderer's component is now typed (ComponentType<CellRendererProps<TRow>>), so props are checked.
Fix: a component declared with a narrower value (({ value }: { value: string })) takes unknown and narrows it
(String(value)), or is typed CellRendererProps<TRow>. Drop any as any on a renderer spec.
Column filters patch per field
Why: changing one column's filter meant copying, editing and writing back the whole map.
Fix: write only the field that changes: setState({ filter: { columns: { amount: null } } }) removes one. Code
that wrote a whole map to remove a field (copy, delete, write back) now keeps the removed field; write null for it
instead. To replace every column filter at once, clear first (filter: { columns: null }).
Live language, workspace and rules
| Before | After |
|---|---|
messages, workspace, data were create options (a change needed a new grid) | options: setOptions({ messages }), a changed prop, apply live |
api.isAutoSaveEnabled() | getStatus().persistence.autoSave |
api.getRowLoadState(index) | api.isRowLoading(rowId) |
api.extendSelection(end) | setState({ selection }) |
flushCellUpdatesSync, getInsightDiagnostics, getColumnIndex, getColumnField | internal |
api.canUndo(), api.canRedo() | getStatus().history.canUndo, .canRedo |
GRID_CREATE_OPTION_KEYS listed them | it lists getRowId, initialState, storage, fields |
Rules, issues and saving: data
| Before | After |
|---|---|
dataIntegrity: { validation: { cellRules, rowRules } } | data: { rules: { cell, row } } |
dataIntegrity: { quality, diff, liveStream } | data: { quality, diff }; a stream needs no option (api.data.stream()) |
validateOnEdit, validateOnSubmit, validateOnTransaction, … | gone: rules always run; a blocking rule refuses the write |
offline: offline({ name, sync, pollIntervalMs }) | data: { offline: offline({ name }), save: { ...sync, pollIntervalMs } } |
api.syncNow() | await api.data.save() (it returns what happened) |
api.integrity.validateGrid(), run() | await api.data.check() |
api.integrity.canSubmit(), getBlockingIssues() | getStatus().data.blocking (or save()'s blocked result) |
api.integrity.getIssues(), getCellIssues(), getRowIssues() | getStatus().data.issues (filter by rowId and issueOn) |
api.integrity.publishIssues(), publishServerReport(), clearIssues() | api.data.setIssues(source, issues) ([] clears) |
api.integrity.setDiffModel(), clearDiff(), getDiffResult() | api.data.compareTo(model | null), getStatus().data.diff |
api.integrity.acceptCellDiff(), createStream() | api.data.acceptChange(), api.data.stream() |
api.getConflicts(), resolveConflict(), clearConflict() | getStatus().data.conflicts, api.data.resolve(id, choice) |
getStatus().sync, getPendingChanges() | getStatus().data (state, changes, lastSavedAt, device) |
discardPendingChanges(), clearOfflineData() | api.data.discard(), api.data.clearDevice() |
changesSynced / changesRejected events | save()'s result; refused fields keep the server's reason |
GridSyncAdapter.pull required | optional (without it nothing comes in) |
Integrity issues and command targets
| Before | After |
|---|---|
| a failing row rule made a row issue plus one issue per named field | one issue per rule and record, with fields, marking each field it names |
GridCommandTarget.complete was true when a selected record was missing | false, and the missing ids are in unavailable |
| a command on the selection acted on selected records the view hides | only those in the view; the rest are in outside (pass rowIds for them) |
| focus cleared when its record left the rows | moves to the nearest record still shown (cleared only when none is left) |
| a server-side grid pruned the selection to the records loaded | kept; setServerSideDatasource / setInfiniteDatasource clear it |
a view type's write answered { ok, rejected } | { status, rejected, settled } (pending is a status of its own) |
draft.apply() answered a GridTransactionResult | a GridDraftApplied (the same, plus saved with { save: true }) |
One filter: filter
| Before | After |
|---|---|
setState({ filterModel }), getState().filterModel | setState({ filter: { columns } }), getState().filter.columns |
setState({ quickFilter: { text, columnIds } }), getQuickFilter() | setState({ filter: { search: { text, fields } } }), getState().filter.search |
setState({ queryModel }) | setState({ filter: { query } }) |
watch((g) => g.filterModel / g.quickFilter / g.queryModel) | watch((g) => g.filter) (or one part) |
datasource params.filterModel, quickFilterModel, queryModel | params.filter.columns, .search, .query |
evaluateQueryForRow(id) | the rows shown: records('matching') |
Saved layouts and views are schema version 5: blobs saved before are not applied.
Options are set with setOptions
Why: each setting had its own setter, some had none, and some were creation-only. One shape, all live.
Fix:
| Before | After |
|---|---|
setCompose(c) | setOptions({ compose: c }) (undefined for the built-in parts) |
setDisplays(d) / setStyleRules(r) | setOptions({ displays: d }) / setOptions({ styleRules: r }) |
setShowMinimap(on), setShowFloatingFilters(on), setShowFilterChipBar(on), setShowGroupPanel(on) | setOptions({ minimap, floatingFilters, filterChips, groupPanel }) |
setColumnReorderEnabled(on) | setOptions({ columnReorder: on }) |
setRowAnimation(a), setDefaultRowHeight(h) | setOptions({ rowAnimation: a }), setOptions({ defaultRowHeight: h }) |
mergeTheme(tokens), setTheme(theme) | setOptions({ theme: tokens }); switching built-in themes is setState({ themeName }) |
capabilities (creation only) | also live: setOptions({ capabilities }) |
Style rules' selection
params.selection in a style rule is a GridSelectionTarget: focus, anchor and bounds. Rules that read
range read anchor and focus instead.
Single-value getters
| Before | After |
|---|---|
getOpenPanel() | getState().sidebarPanel |
isChartOpen() | getState().chartOpen |
getThemeName() | getState().themeName |
getView() | getState().view |
No state snapshot
Why: getStateSnapshot() repeated getState() in other shapes beside options and status.
| Before | After |
|---|---|
getStateSnapshot().sortModel / .filter / .grouping / … | getState().sortModel / … |
getStateSnapshot().columns | getColumns() |
getStateSnapshot().displays, .enableColumnReorder | getOptions().displays, getOptions().columnReorder |
getStateSnapshot().loading, .activeEdit | getStatus().loading, getStatus().edit |
getStateSnapshot().selection.range / .focus | getState().selection ({ focus, anchor? }, or null), records('range') |
getStateSnapshot().pinnedColumns, watch((g) => g.pinnedColumns) | getState().columnState (each column's pinned) |
watch((g) => g.treeData / g.displays / g.detail) | watch((g) => g.options.treeData / …) |
Fix: read each value from its read. Don't mutate what a read returns: it is frozen.
What the grid reports: getStatus()
Why: status was spread over five getters and nine watch keys, and the page sat in both the state and
getPagination().
| Before | After |
|---|---|
getPagination() | getStatus().pagination (no page: getState().page) |
getSyncState(), getPersistenceStatus(), getWorkspaceState() | getStatus().sync, .persistence, .views |
getTheme() | getStatus().theme |
watch((g) => g.sync / g.persistence / g.integrity / g.theme) | watch((g) => g.status.sync / …) |
watch((g) => g.workspace), g.activeEdit, g.loading, g.serverSide | g.status.views, g.status.edit, g.status.loading, g.status.serverSide |
watch((g) => g.pagination) | g.page (state), g.status.pagination (the page count) |
getState() is frozen, and watch reads it
Why: getState() built new objects on every call, so it could not be compared, and watch read a second,
differently shaped snapshot instead.
Fix: don't mutate what getState() returns (copy it first). In a watch selector, g.selection is the public
selection, possibly null (g.selection?.focus ?? null), and g.aggregation is a list of aggregations.
A visual row's record is its id and data
Why: a data row's node was typed as the grid's internal row node, so the public types named an internal class.
Fix: row.node is a VisualRowRecord ({ id, data }). Read row.node.data and row.node.id as before; for
anything else about the record (selection, expansion, its place), use api.record(id).node.
Each export in one entry, for one audience
Why: the root exported 628 names, many of them the grid's internals, and two entries overlapped; an app could not tell what it was meant to use.
Fix:
| Before | After |
|---|---|
@witbybit/wit-grid-core/internal | @witbybit/wit-grid-core/adapter (framework bindings only) |
flight recorder and trace replay from @witbybit/wit-grid-core/experimental | @witbybit/wit-grid-core/testing |
ThemeManager.detectSystemPreference() | systemColorScheme() ('light' or 'dark') |
LIGHT_THEME, DARK_THEME, GLASS_LIGHT_THEME, GLASS_DARK_THEME | getBuiltInTheme('light') … |
applyFilterToModel(field, f, model) | setState({ filter: { columns: { [field]: f } } }) |
createEmptyQueryModel(), isQueryModelActive, countQueryNodes | a query is a literal: { version: 1, root: { kind: 'group', … } } |
offline types from both the root and /offline | the ones the grid's api names from the root; stores and sync from /offline |
createWorkspaceController | the grid's views: api.views.open, saveAs and the rest |
What a column means is on the column
Why: the workspace's records said what each field meant away from the field, beside a column schema that
already said what it holds; done and blocked statuses sat there too, and saying "no column is the progress" took an
empty string.
Fix:
| Before | After |
|---|---|
workspace: { records: { status: 'stage', owner: 'rep' } } | { field: 'stage', role: 'status', … }, { field: 'rep', role: 'owner', … } |
records: { doneValues: ['won', 'lost'], blockedValues: [] } | type: selectType(STAGES, { done: ['won', 'lost'], blocked: [] }) |
records: { progress: '' } (so a progress column is not taken) | role: false on that column |
A view's own records ({ kind: 'calendar', records: { schedule: 'due' } }) is unchanged.
A column has one property per job
Why: a column could get a type two ways (a spread or a name in a registry only React read), its text had a
formatter, a display's text and onCopy, text came back through onPaste only (paste into a currency column wrote
the text), required sat beside the integrity rules, and checkboxSelection marked a column only the grid makes.
Fix:
| Before | After |
|---|---|
type: 'currency', <Grid columnTypes={…}>, resolveColumnTypes, BUILTIN_COLUMN_TYPES | type: currencyType() in the column; a type of your own is a function returning settings |
valueFormatter, ValueFormatterParams | format, CellFormatParams |
a display's text(value, context) | its format(value, context) (the default format of columns naming it) |
onCopy({ value }) | format (copy is the column's text), or the copy command composed |
onPaste({ pastedText }) | parse({ text }) — also read for the text editor and the inspector; return undefined to refuse |
required: true on a column | the required(field) rule in dataIntegrity.validation.cellRules; missingRequiredRule is gone |
your own column with checkboxSelection: true | rowSelection: 'multiple', or { mode: 'single', checkboxes: true } |
Built-in column types now read pasted and typed text: numbers with grouping, currency signs and %, dates people
write, yes / no, options by label.
A row node is a read
Why: record(id).node carried its own writes beside the grid's one way for each.
Fix: read the node's fields; change rows the grid's way.
| Before | After |
|---|---|
node.setData(d), node.updateData(p), node.setDataValue(f, v) | transaction({ cells: [{ rowId, colField, value }] }) |
node.setSelected(on) | applyRowSelectionGesture({ kind: 'select' | 'deselect', rowIds }) |
node.ensureVisible() | revealRecord(rowId) |
node.retryLoad() | runCommand('retryLoad', { rowId: visualRow.id }) on a failed row |
node.validate(), getValidationState(), getIntegrityIssues() | api.integrity.validateRow(rowId), api.integrity.getRowIssues(rowId) |
node.getData(), getValue(f), getDisplayValue(f) | node.data, api.cell(rowId, f).value, .text |
node.refresh() | nothing: rows refresh when what they read changes |
Row shaping is state; its gestures are commands
Why: grouping, expansion, detail rows and pivot each had their own getters, setters and add / remove / move / toggle methods beside the state, and a row node could open itself.
Fix: read state with getState() (or watch), options with getOptions(); run the gestures as commands.
| Before | After |
|---|---|
getGrouping(), getGroupBy() | getState().grouping (null: not grouped), its by |
getAggregation(), getPivot() | getState().aggregation, getState().pivot (null outside pivot mode) |
getTreeData(), getHierarchyColumn(), getDetail() | getOptions().treeData, .hierarchyColumn, .detail |
addGroupBy(col), removeGroupBy(col) | runCommand('group', { colField: col }), runCommand('ungroup', …) |
addGroupBy(col, index), moveGroupBy(col, index) | setState({ grouping: { ...grouping, by } }) with the levels in their new order |
setExpanded(id, open), toggleExpanded(id) | runCommand('expand' | 'collapse', { rowId: id }) |
expandAll(), collapseAll() | runCommand('expandAll'), runCommand('collapseAll') |
expandAll({ maxLevel }) | setState({ expansion: { base: maxLevel + 1, rows: {}, details } }) |
setDetailOpen(id, open), toggleDetailOpen(id) | runCommand('openDetail' | 'closeDetail', { rowId: id }) |
getPivotResult(), getPivotCellRows(rowId, colId) | pivotResult(), pivotResult()?.cellRows(rowId, colId) |
record(id).node.setExpanded(open) | the expand / collapse commands |
Which rows and details are open is the expansion state (setState({ expansion }), initialState.expansion); saved
layouts do not keep it. isExpanded(id) and isDetailOpen(id) stay as reads.
One write: transaction
| Before | After |
|---|---|
setCellValue(rowId, field, value), setFormula(rowId, field, '=…') | transaction({ cells: [{ rowId, colField: field, value }] }) |
clearFormula(rowId, field) | write the plain value |
setRowOrder(ids) | transaction({ order: ids }) |
transaction({ columns, sortModel, filterModel, … }) | setColumns(columns), setState({ sortModel, filterModel }) |
flushTransactions() | a synchronous write commits the queue first; else await the async transaction |
refreshRows() | not needed: writes redraw what they change |
Two row reads: records and record
| Before | After |
|---|---|
getRawRowById(id), rows().getById(id) | record(id)?.data |
getRowNode(id), rows().getNodeById(id) | record(id)?.node |
rows().getAll(), rows().forEach(f), rows().getCount(), forEachDisplayedNode(f), getDisplayedRowAtIndex(i) | records('displayed') (.rows, .ids) |
forEachNode(f) | records('all') |
rows().getChecked() | records('selected').rows |
getSelectedRowIds(), rows().getCheckedIds(), getSelectedRowCount() | getState().selectedRowIds (.length) |
rows().getSelected(), rows().inRange(selection).getIds() | records('range') |
rows().getVisualRowById(id).hierarchy in a renderer | record(id)?.node (level, expandable, expanded) |
One cell read: cell
Fix: api.getCellValue(rowId, field) → api.cell(rowId, field).value. The stored value is .raw; the text
export and copy show is .text.
State is followed with watch; events are things that happen
Why: about 26 events only said that a piece of state changed, some fired several times for one change, and a
restore fired them for six keys only. watch hears every state change once per commit, with its cause.
Fix: the events below leave GridEventName and GridEventPayloadMap; follow the state instead. A watch
listener gets (next, previous, cause); cause.source tells a person (user) from your code (api), a restore,
undo or redo.
| Event | Follow instead |
|---|---|
sortChanged | watch((g) => g.sortModel, …) |
filterChanged, quickFilterChanged, queryModelChanged | watch((g) => g.filterModel / g.quickFilter / g.queryModel, …) |
groupingChanged, groupByChanged, groupColumnAdded / Removed / Moved | watch((g) => g.grouping, …); which column: diffIds over by |
pivotChanged, treeDataChanged, aggregationChanged, detailChanged | watch((g) => g.pivot / g.treeData / g.aggregation / g.detail, …) |
hierarchyColumnChanged, columnReorderToggled, commandsChanged | watch((g) => g.options.hierarchyColumn / g.options.columnReorder / g.options.compose, …) |
expansionChanged | watch((g) => g.expansion, …) |
selectionChanged, focusChanged | watch((g) => g.selection, …) (.focus for the focus) |
rowSelectionChanged | watch((g) => g.selectedRowIds, (next, previous) => diffIds(previous, next)) |
paginationChanged | watch((g) => g.page, …); the page count watch((g) => g.status.pagination, …) |
columnsChanged, columnOrderChanged, columnResized | watch((g) => g.columnState, …) (and g.columns for the definitions) |
rowResized | watch((g) => g.rowHeights, …) |
fieldsChanged | watch((g) => g.fields, …) |
workspaceStateChanged, syncStateChanged, serverSideStateChanged | watch((g) => g.status.views / g.status.sync / g.status.serverSide, …) |
cellInvalidated, renderInvalidated, layoutTransitionCaptureRequested | internal (render plumbing); the flight recorder's subscribeFlightRecorder for devtools |
AG Grid's onSortChanged, onFilterChanged, onSelectionChanged, onColumnResized, … | the watch on the same state above |
State is set with setState
Why: each piece of state had its own setter (some two), and reading it back took as many getters.
Fix: api.setState(partial) with the keys you change; api.getState() reads every key.
| Before | After |
|---|---|
setSortModel(m), setFilterModel(m), setQueryModel(m) | setState({ sortModel: m }), { filterModel: m }, { queryModel: m } |
setQuickFilter(text, columnIds) | setState({ quickFilter: { text, columnIds } }) |
setGrouping(g), setGroupBy(by), updateGrouping(patch) | setState({ grouping: { ...api.getGrouping(), by } }) |
setPivot(p), setAggregation(defs), setView(v), setPage(n) | setState({ pivot: p }), { aggregation: defs }, { view: v }, { page: n } |
setColumnWidth(f, w), setColumnVisible(f, on), setColumnsVisible(fs, on) | setState({ columnState: [{ field: f, width: w }] }), [{ field: f, hide: !on }] |
setPinnedColumns({ left, right }) | setState({ columnState: [{ field, pinned: 'left' }] }) per column |
setColumnOrder(fields), applyColumnState(s, { applyOrder }) | setState({ columnState }) naming every column |
getColumnState() | getState().columnState (no sort / sortIndex: sortModel has it) |
setRowHeight(id, h), setRowHeights(r) | setState({ rowHeights: { ...api.getState().rowHeights, [id]: h } }) |
switchTheme(name) | setState({ themeName: name }) |
openPanel(id), closePanel(), togglePanel(id) | setState({ sidebarPanel: id }) / null |
openChart(), closeChart(), toggleChart() | setState({ chartOpen: true }) / false |
selectCell(p), selectRange(a, b) | setState({ selection: { focus: p } }), { selection: { anchor: a, focus: b } } |
selectRows(ids, { mode: 'replace' }), clearRowSelection() | setState({ selectedRowIds: ids }), { selectedRowIds: [] } |
selectRows(ids), deselectRows(ids), toggleRowSelection(id), selectAllRows(o) | applyRowSelectionGesture({ kind: 'select' | 'deselect' | 'toggle' | 'selectAll', rowIds, scope }) |
getGridState(), applyGridState(blob) | getState(), setState(state); stored layouts are the persistence adapter's |
Saved layouts and views are schema version 4: state is GridState as JSON (columnState in place of
columnOrder, columnVisibility, columnWidths and pinnedColumns). Blobs saved before are not applied (the grid
reports it and keeps its state); save again after upgrading.
<Grid> props are the grid's options
Why: some settings were React props only (a grid without React could not have them), and switches had three naming styles.
Fix: the props have the option names, and every one of them is live.
| Before | After |
|---|---|
showStatusBar, showFilterChipBar, showMinimap, showFloatingFilters | statusBar, filterChips, minimap, floatingFilters |
enableColumnReorder, enableContextMenu, enableChart, enableNavigation | columnReorder, contextMenu, chart, navigation |
navigationOptions={{ editTrigger, arrowKeyNavigationEdit }} | editing={{ trigger, arrowKeysKeepEditing }} |
rowOverscanPx, colBuffer | performance={{ rowOverscanPx, colBuffer }} |
pinLeftColumns={n}, pinRightColumns={n} | pinned: 'left' / 'right' on the columns, or setState({ columnState }) |
pinTopRows={n}, pinBottomRows={n} | pinnedRows={{ top: n, bottom: n }} |
sidebar={{ …, defaultOpen: id }} | sidebar={{ … }} and initialState={{ sidebarPanel: id }} |
registerGridContextMenu(api), bindGridInteractionSurface(…) | mountGrid(api, element): the cell menu and keys come with the options |
<GridView navigation sidebar chart pins … /> | api.setOptions({ … }); GridView takes the api and the drawing callbacks |
One storage option; the workspace's options say only what the workspace is
Why: where things were kept was spread over five options with four adapter shapes, workspace meant either a
saved-views adapter or the workspace's options, and three workspace switches repeated what a capability or composing
already says.
Fix:
| Before | After |
|---|---|
persistence: 'key' | storage: 'key' (keeps the layout, the views, own layouts and saved chips) |
persistence: adapter (load / save / clear / debounceMs) | storage: { layout: adapter } (load / save / remove; saves stay debounced) |
workspace: adapter, workspace.savedViews (listViews, saveView, …) | api.views, with views.store (or storage: { views }) |
workspace.personalLayouts (load(viewId) / save(viewId, layout)) | storage: { personalLayouts }: one object of layouts by view id |
workspace.savedQuickFilters | storage: { quickFilters } |
createLocalStorageAdapter(key), createLocalStorageWorkspaceAdapter(...) | localStorageAdapter(key) (one place), localGridStorage(key) (all four) |
workspace.canManageView(view) | capabilities.canManageView (params.view), asked with api.can('manageView') |
workspace.export: false | capabilities.canExport: false, or compose the bar without export |
workspace.quickFilters: false | compose the bar without quickFilters; the commands without filter:quick:save |
mountGridHost(api, el, { workspace }), <GridView workspace> | createClientGrid({ workspace }) / <Grid workspace>: the grid is made with it |
GridPersistenceAdapter, GridWorkspaceAdapter, isWorkspaceAdapter (types) | GridStorageAdapter<T>, GridStorage, GridViewStore |
People's own layouts and saved chips no longer default to this browser's localStorage: without a place they last for
the session. Views kept by createLocalStorageWorkspaceAdapter are not read back.
initialState is state; the rest are options or create options
Why: initialState mixed what a person changes with what the app configures, under internal names, and several
<Grid> props were initial-only although nothing stopped them changing.
Fix: initialState is a GridState (the keys getState() reads), applied as setState applies them. What the
app configures is an option (live); what a grid is made with is a create option (GridCreateOptions: getRowId,
initialState, persistence, workspace, dataIntegrity, fields, messages, offline).
| Before | After |
|---|---|
initialState.sidebarOpenPanel, quickFilterModel | initialState.sidebarPanel, quickFilter |
initialState.aggregation: { defs } | initialState.aggregation: defs (the list) |
initialState.columns, columnWidths, pinnedColumns | initialState.columnState (or width / pinned on the columns) |
initialState.treeData, detail, hierarchyColumn | the treeData, detail, hierarchyColumn options (live) |
initialState.defaultRowHeight, defaultColWidth, loadingSkeletonCount | options of those names |
initialState.themeOverrides, showGroupPanel, showStatusBar, … | the theme, groupPanel, statusBar, … options |
initialState.colBuffer, asyncTransactionWaitMs, runtimeLimits, … | performance: { colBuffer, asyncTransactionWaitMs, maxRenderedRows, … } |
<Grid overscanAdaptive runtimeLimits={{ maxRenderedRows }}> | performance={{ overscanAdaptive: true, maxRenderedRows }} |
rendererOptions={{ rowAnimation }} | rowAnimation={…} (GridRendererOptions is gone) |
pagination={{ pageSize, initialPage }} (initial-only) | pagination={{ pageSize }} (live) and initialState={{ page }} |
rowDragMode, rowSelection (initial-only) | the rowDrag and rowSelection options (live) |
<Grid view={…}> (controlled) | initialState={{ view }}, api.setState({ view }); onViewChange stays |
api.setTreeData(t), setDetail(d), setHierarchyColumn(h) | api.setOptions({ treeData: t }), { detail: d }, { hierarchyColumn: h } |
transaction({ pins }) | pinned on the columns / columnState; the pinnedRows option |
GridInitialState, GridPaginationConfig (types) | GridState, GridPaginationOptions, GridCreateOptions |
Expanded rows and open details are not state yet: open them with setExpanded / setDetailOpen.
One theme, one way to change a column menu
Why: the workspace had a second set of design tokens, and a column's menu had three ways to change it.
Fix:
workspace.theme: { accent, radius, … }→ the grid's theme:initialState.themeOverrides: { focusRing: accent, radius: '10px', shadow }(orapi.setOptions({ theme })). Background, text and lines followbgColor,textColorandborderColor.suppressHeaderMenu: true→ compose that column's menu empty:compose.menu: (entries, c) => (c.menu === 'column' && c.colField === 'id' ? [] : entries). The header then shows no menu button.headerMenuRenderer/headerMenuComponent→ compose the column's menu, and give the column a custom filter editor (filterDef) for filter UI of your own: the menu'sfilterentry draws it.HeaderMenuRendererPropsis gone.
Card displays are the grid's displays
Why: a field's look was defined once for the table and again for cards. One display now serves the cells, the cards, the inspector, and export and copy.
Fix: move workspace.cardDisplays to the grid's displays (<Grid displays={…}>, or displays in
createClientGrid). render(value, row, reader) becomes render(value, { row, rowId, column, surface, api }). A
column whose cells drew the same thing with its own renderer can name the display instead (display: 'health').
WorkspaceCardDisplay is GridDisplay.
Two ways to listen: on and watch
Why: the api had eleven ways to listen. Two remain: on for events, watch for state.
Fix:
api.addEventListener(event, fn)→api.on(event, fn).api.subscribeToKey('sortModel', fn)→api.watch((grid) => grid.sortModel, fn).api.subscribeToSnapshotSelector(keys, select, fn, equals)→api.watch(select, fn, { equals })(no key list: the watch follows whatselectreads).api.subscribe(fn)→api.watch((grid) => ({ ...grid }), fn)to hear any state change, or better a selector of what you use.subscribeToSyncState,subscribeToPersistenceStatus,subscribeToWorkspaceState,subscribeToIntegrityandonThemeChange→api.watch((grid) => grid.status.sync)(orstatus.persistence,status.views,status.integrity,status.theme).subscribeToCell,subscribeToDomainVersionsandsubscribeDomainleft the public api: they serve adapters' renderers. To follow a cell's value,api.on('changed', …)and read it again.- React:
useGridSelector(select, equals)anduseGridKeySelector(key, select, equals)→useGridWatch(select, { equals });useGridSyncState()→useGridWatch((grid) => grid.status.sync).
One rule per action: canSort, canGroup, canSelect
Why: a column said the same thing several ways, and some surfaces read one way and not the others.
Fix:
sortable: false→canSort: false;sortable: true→ leave it out (on by default).enableRowGroup: false→canGroup: false;enableRowGroup: true→ leave it out.disableCellRangeSelection: true→canSelect: false.- A rule that returned
{ allowed: false, reason }returns the reason (a string);{ allowed: true }→true. A rule can also be a plain value:canEdit: false,canExport: 'Kept in the app'.modeis gone. capabilities.canPerformAction→ the rule for that action (canPaste,canFill, …).normalizeCapabilityResult,CAPABILITY_ALLOWEDandGridCapabilityCallbackare gone: askapi.can, and type a rule asGridCapability.
Actions, the palette composer and shortcuts are commands
Why: a behaviour had several homes: Ctrl+C, the menu's Copy and the palette each ran their own code, and only the workspace could bind keys. Every behaviour is now one command, wherever it is offered.
Fix:
actions={[approve]}(orapi.setActions) →compose={{ commands: (defaults) => [...defaults, approve] }}, withrecords: 'selection'on a command that acts on the selected records (the old default).GridActionisGridCommand; itsrun,availableandmessageget the command's target (ids,rows,api,complete,cell,colField, …).api.getActions({ rowIds })→api.getCommands({ rowIds }), and.find(…).run()→api.runCommand(id, { rowIds }).useGridActions→useGridCommands. TheactionsChangedevent →commandsChanged.- The workspace's
compose.palette→compose.commands: the palette lists the commands that have agroup.WorkspaceCommandItem→GridCommand. shortcuts: { 'view:gantt': 'shift+g' }→ compose that command'skeys:commands: (all) => all.map((c) => (c.id === 'view:gantt' ? { ...c, keys: ['shift+g'] } : c)). A key turned off withnull→ drop the command, or give itkeys: [].edit:undo,edit:redoandselect:allare the grid'sundo,redoandselectAll.- The cell menu's actions are named by their own id (
approve), notaction:approve, in menus and in the bulk bar'sdata-control. - The cell menu's words (
messages.contextMenu.copy…) and the column menu's entry words (sort, pin, group, fields) moved tomessages.commands.
Menus share one look and one set of class names
Why: the grid, the workspace and the sidebar draw every menu the same way.
Fix:
- Rows are
.og-menu-item(a<button>, disabled with thedisabledattribute), inside.og-menu, on a popover with.og-menu-surface. Labels are.og-menu-label, icons.og-menu-icon, key hints.og-menu-kbd, headings.og-menu-heading, separators.og-menu-sep, and the row under the keyboard has.og-menu-active. - These classes are gone:
.og-context-menu-item(and-icon,-label,-shortcut),.og-context-menu-divider,.og-popover-item,.og-popover-sort-section,.og-popover-divider,.og-ws-menu*and.og-sb-menu*. Point CSS and tests that used them at the new classes. - The cell and column menus' built-in labels are sentence case: "Copy Selected Range" is now "Copy", "Sort
Ascending" is "Sort ascending", "Export All as CSV" is "Export as CSV", and so on. Tests that match the old labels
need the new ones, or can match by
data-id(each row carries its entry's id). Translations set throughmessagesare unchanged.
The workspace's slots are composed card and header parts
Why: a card's and a group header's parts are declared and composed like every other workspace part, so your
content can go anywhere among them, and built-in parts can be dropped or moved. slots, WorkspaceSlots,
WorkspaceSlotGroup and SlotContent are removed (the group is now WorkspaceGroup, the content PartContent).
slots was only in pre-release builds of next.
Fix:
slots.cardFooter: { height, render }→compose.card: (parts) => [...parts, { id: 'footer', height, render }].slots.groupHeader: (group, reader) => …→compose.groupHeader: (parts) => [...parts, { id: 'mine', render: (group, reader) => … }].
The cell context menu is changed through the grid's compose only
Why: every menu the grid draws is composed one way, in one place: the grid's compose.menu, the built-in entries
in, the entries to show out. contextMenuOptions (with excludeDefaults, customItems, disableDefaults and
disabled), the GridContextMenuOptions and GridContextMenuItem types, and registerGridContextMenu's options
argument are removed.
Fix: pass compose to the grid (<Grid compose>, createClientGrid({ compose }) or api.setCompose), and
answer the cell menu where context.menu === 'cell':
excludeDefaults: ['copy']→menu: (entries, ctx) => ctx.menu === 'cell' ? entries.filter((e) => e === 'separator' || e.id !== 'copy') : entries.customItems: [item]→menu: (entries, ctx) => ctx.menu === 'cell' ? [...entries, { label, run: () => … }] : entries. An item'saction(params)becomesrun(), with the cell (rowId,colField,pointer,selection,api) from the context.shortcutbecomeshint,variant: 'danger'becomesdanger: true,hiddenmeans leaving the entry out, and an icon is a string (an icon name, an<svg>or text).disableDefaults: true→ return only your entries for the cell menu.disabled: true→enableContextMenu={false}in React, or don't callregisterGridContextMenu. To leave the menu off some cells only, return[]for them: a cell whose menu is composed empty opens none.
The workspace's own summary is the bar's summary section
Why: the workspace-wide summary was a second way to set the numbers the command bar's summary section shows.
It is removed; the section is now in the bar's defaults in every view and the table (empty where there are no
numbers) unless the view or the person hides it. It was only in pre-release builds of next.
Fix: summary: numbers →
compose: { bar: (sections, { view }) => sections.map((s) => (s.id === 'summary' && !view?.summary ? { ...s, metrics: numbers } : s)) }.
A view's own summary is unchanged.
Quick filter chips are composed
Why: a quickFilters list replaced the built-in chips wholesale. compose.quickFilters gets them by id, like every
other part, so you can keep some and add yours. quickFilters is now only false to turn chips off. It was only in
pre-release builds of next.
Fix: quickFilters: [chip, …] → compose: { quickFilters: () => [chip, …] } (or (chips) => [...chips, chip] to
keep the built-in ones).
Workspace extensions are gone: packages of composers are combined
Why: extensions were a second way to change the parts compose changes. Your additions are composers, and a
package of them is a plain compose object; combineCompose(a, b, c) makes several into the one compose.
extensions and WorkspaceExtension are removed. WorkspaceActionContext is merged into WorkspaceComposeContext,
whose record(id) is now recordData(id) (the inspector's and menus' record is the record's id). Extensions were
only in pre-release builds of next.
Fix: extensions: [{ id, views, toolbar, … }] and compose: mine → compose: combineCompose(package, mine), with
the package's parts as composers (a package for some views only checks ctx.view):
toolbar: [button]→bar: (sections, ctx) => [...sections, { id, row: 'toolbar', controls: [button] }]. A button'srun(context, anchor)becomesrun(anchor)andpressed(context)becomespressed(), with the composer'sctx.commands: [command]→commands: (commands, ctx) => [...commands, { ...command, group }].inspectorTabs: [tab]→inspector: (tabs, ctx) => [...tabs, { id, label, render: (el) => …(ctx.record) }]. Its id is yours, no longerextension:<id>:<tab>.bulkActions: [action]→ a command withrecords: 'selection'incommands, andbulkBar: (controls) => [...controls, { id, command: id }]: the same command is in the palette and on its keys.settings: (ctx) => [section]→customize: (sections, ctx) => [...sections, { id, ...section }].
Export and charts count every matching record
Why: a flat export of a paged grid wrote only the current page, and a chart over collapsed groups totalled 0: both read the rows on display.
Upgrade:
exportCsv/exportExcelwithoutonlySelectedorrowIdsnow write every record that passes the filters, on every page. To export only the page on display, pass its ids:rowIds: api.records('displayed').ids.- To total over records yourself, read
api.records('matching')rather thanrows().getAll(), which stays the rows on display.
The summary strip's defaults follow the record roles
Why: each view's default numbers assumed a project tracker.
Upgrade: a board shows Active and Blocked only when the records have progress, dates or dependencies; a
gallery shows Average progress only with a progress field. To keep a card the defaults no longer show, declare it
in summary ({ label: 'Blocked', fn: 'blocked' }), or add it to the default summary section in compose.bar.
Conflicts: one list on the grid
Why: conflicts lived in the data-integrity conflict module (only when dataIntegrity.conflicts was set) and
offline kept its own; there were two resolve APIs with different shapes.
Upgrade:
api.integrity.getConflicts()/resolveConflict()/clearConflict()→api.getConflicts()/api.resolveConflict()/api.clearConflict(). They work withoutdataIntegrity.resolveConflict(id, { strategy: 'local' })→resolveConflict(id, 'local');{ strategy: 'custom', value }→{ value }. The result no longer has ablockedstatus.- Remove
dataIntegrity.conflicts(and itsvalidateBeforeResolve/checkCapabilitiesBeforeResolve): conflicts are always on, and a resolution is checked like any edit (capabilities, and validation per yourvalidateOnSubmitsetting). GridCellConflicthas a requiredshows: 'local' | 'remote'(the side the cell shows).api.integrity.clearIssues()no longer clears conflicts; useapi.clearConflict(id).
Cell writes: one preparation for every surface
Why: a field's setter and rules ran differently depending on where it was written (an edit awaited the setter and checked its result; a transaction checked the raw value, then ran the setter unawaited).
Upgrade:
- A sync
transaction(orsetCellValue) writing through a value setter or blocking rule that returns a promise is now rejected withneedsAsync: true. Write it withtransaction(tx, { async: true }). - Row rules receive the whole proposed record: a rule that only held because it saw the old values of the other fields may now report.
- Workspace gestures are all or nothing: a move whose cells the user cannot all change moves nothing (bulk actions still apply to the records they can).
Views: records are the query's, not the table's display
Why: a board or calendar lost records when the table collapsed a group or turned a page.
Upgrade:
- The client row model's
getFilteredDataNodes()now returns every record passing the filters, in sort order, whatever is expanded or paged (it returned the displayed rows).getCurrentPageDataNodes()returns the displayed records. The data-integrityfilteredRowsscope follows. - Views of your own:
WorkspaceViewContext.rows()returns the matching records,position(id)is a record's place among them, andcompleteness()says'loaded'for server and infinite models.
Translating: offline's words move to the grid
Why: the grid has one messages table now; offline had its own.
Upgrade:
offline({ name, messages: { saved: '…' } })→<Grid messages={{ sync: { saved: '…' } }}>(orcreateGrid({ messages: { sync: … } })).- Custom filter editors receive
params.messages(the grid's words); nothing to change unless you buildDomFilterEditorParamsyourself, where it is optional.
Offline and sync: new API members and union members
Why: offline grids (see Offline and Sync) added to types that apps may implement or switch over exhaustively. Nothing changes for a grid that does not use offline.
Upgrade:
GridApihas seven new members (getSyncState,subscribeToSyncState,syncNow,getPendingChanges,discardPendingChanges,clearOfflineData). A hand-written mock or wrapper typed asGridApineeds them; without offline,getSyncState()returnsnulland the others do nothing.GridClientProps(React) is now a type alias, a union of "withoffline" and "without":interface X extends GridClientPropsbecomestype X = GridClientProps & { … }.- New union members:
GridTransaction['origin']adds'server';GridConflictSourceadds'sync';GridInsightLayerIdadds'sync';RuntimeFaultSourceadds'offline';GridEventNameaddssyncStateChanged,changesSynced,changesRejected. Exhaustiveswitchstatements over them need the case.
React: live props apply when their content changes
Why: <Grid> applied styleRules, view, rowAnimation and the other live props whenever their identity
changed, so props written inline were applied again, and the grid repainted, on every parent render.
What changed: a live prop is applied when its content changes (plain data by value, functions by identity),
during React's commit. A view written inline with the same content is no longer applied again, so a view the
person picked inside the grid stays after the parent re-renders.
Fix: to put the grid back on a view, pass a different value, or control it with view and onViewChange.
Data changes: one changed event replaces cellValueChanged and rowsUpdated
Why: a commit could raise several events, or none (row transactions, server loads), and listeners had to piece together what happened. Each commit now carries one change set.
What changed: GridEventName.cellValueChanged and GridEventName.rowsUpdated are removed. GridEventName.changed
delivers GridChanges: added, removed and updated rows (each update with its fields' oldValue and
newValue), derived cells, changed config inputs, origin (local, remote, undo, redo, server)
and source. The React prop onCellValueChanged is now onChanged. globalVersion is no longer part of the
state snapshot.
Fix: listen to changed. For the old per-cell callback, walk the cells with forEachCellChange(changes, (rowId, field, change) => …). Code that watched globalVersion to learn that rows changed should listen to changed too.
Aggregates follow one rule everywhere
Why: grouped grids, totals and pivots counted some values differently.
What changed:
- A value counts as a number when it is a finite number or text that reads as one. This applies to grid
aggregation (
sum,avg,min,max) as it already did to pivots. distinctCountskips blanks.- A pivot's
medianskips blanks; it used to read them as 0. - A pivot's
first,lastand custom functions read a cell's records in source order at every level. A parent row used to take its last child group's last record. - In tree data, a row whose parent is missing or whose parent chain loops is a root, in source order. Orphans used to follow the roots, and rows on a cycle disappeared.
- A query model on tree data follows the tree's
filterMode.
Fix: none needed unless a test or report depended on the old numbers.
Every cell is final in every frame: renderer scroll options removed
Why: cells used to show stand-in text, a frozen previous row or a '...' placeholder while the grid scrolled,
and were repaired after the scroll ended. The renderer now prepares each cell's content ahead of time and draws
every cell final in the frame that shows it, so there is nothing to choose.
What changed: capabilities on a column's renderer (scroll, scrollText, scrollState) and
rendererOptions.live and rendererOptions.fullWidth are removed, with the CellRendererCapabilities and
CellScrollPresentation types. The renderer phases 'scroll-live', 'scroll-force-live' and 'scroll-idle'
are gone; a renderer sees 'initial', 'scroll', 'edit', 'interaction' or 'destroy'. Render stats lost the
counters of the removed machinery (stand-ins, post-scroll repair, deferred portal work, prewarm, budgets) and
gained cellViewsPrepared and preparedRows.
Fix: delete capabilities from renderer specs and live / fullWidth from rendererOptions. A renderer
that drew differently while scrolling (scrollState) should draw the same content always.
Renderer scroll options: text or live (superseded by the change above)
Why: six scroll presentation modes, each with its own sub-options, were more than anyone needed. Every custom renderer now picks between stand-in text and the real renderer during scroll.
What changed: capabilities.scrollPresentation, live, textImpostor and htmlSnapshot are replaced by
scroll: 'text' | 'live' and scrollText. rendererOptions.domUpdate, liveReact, htmlSnapshot and
textImpostor are replaced by rendererOptions.live. Imperative React updates are declared with
kind: 'imperativeReact' instead of live.update: 'imperative'. HTML snapshots are removed.
Fix: use the replacements listed in the Changelog and Renderer Capabilities.
One data-write API: transaction
Why: rows and cells used to be written through separate methods, each with its own undo entry, result shape
and async variant. api.transaction() covers them all, atomically.
What changed: applyTransaction, applyTransactionAsync, flushAsyncTransactions, batchCellValues,
batchCellValuesAsync, setCellValueAsync and updateRows are removed, as are the rowTransaction and full
rows fields of transaction(). The type BatchCellValueUpdate is now GridCellWrite.
Fix: use the replacements listed in the Changelog and
Transactions. The row nodes of a transaction are now at result.rows, and result
is never null.
Text-only cells no longer contain .og-cell-content
Why: a cell of a column with no custom renderer only ever shows text, so its content wrapper was one more
element to style and lay out per cell. Such cells now hold their text directly and carry a data-text-cell
attribute. Cells of columns with a custom renderer, and the row-selection checkbox column, keep the wrapper.
Before:
.my-grid .og-cell-content {
font-variant-numeric: tabular-nums;
}After: style the cell itself; it applies to both kinds of cell.
.my-grid .og-cell {
font-variant-numeric: tabular-nums;
}.og-cell-portal-host no longer has a box
Why: the host only anchors renderer containers in the DOM; as a flex box of its own it was one more container per
custom-renderer cell for every layout to walk through. It is now display: contents, so size, padding, background
or border set on it no longer apply.
After: style the renderer's container (.og-custom-renderer-container for React renderers,
.og-dom-renderer-container for DOM renderers), or the cell.
Cutting a release
When a release is cut, maintainers run pnpm --filter wit-grid-site docs:release:from-package — it reads the
version from packages/react/package.json, snapshots next into a fixed version directory named after the
major.minor, and updates the version switcher automatically. See
Docs Architecture for the full mechanics. After cutting a release, start a fresh,
empty "unreleased" section here.
Last updated on