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.

BeforeAfter
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, setDefaultViewapi.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, otherpresentation, 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 eventswatch((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 }, GridPersonalLayoutsThe person's own draft of the view, in their record (storage.layout)
api.views.open(null): the grid stays as it isThe 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)

BeforeAfter
{ 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 renderSectionrender(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
GridRemoteDataOptionsOmit<GridDataOptions, 'offline'>: a server-side or infinite grid can save
PartContent on sections and tabsWorkspacePartContent; card and group parts keep PartContent
api.getRowOrder() read as the sorted orderIt is the source order; the shown order is api.records('matching').ids

useGrid() and <GridScope>

BeforeAfter
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

BeforeAfter
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, getColumnFieldinternal
api.canUndo(), api.canRedo()getStatus().history.canUndo, .canRedo
GRID_CREATE_OPTION_KEYS listed themit lists getRowId, initialState, storage, fields

Rules, issues and saving: data

BeforeAfter
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 eventssave()'s result; refused fields keep the server's reason
GridSyncAdapter.pull requiredoptional (without it nothing comes in)

Integrity issues and command targets

BeforeAfter
a failing row rule made a row issue plus one issue per named fieldone issue per rule and record, with fields, marking each field it names
GridCommandTarget.complete was true when a selected record was missingfalse, and the missing ids are in unavailable
a command on the selection acted on selected records the view hidesonly those in the view; the rest are in outside (pass rowIds for them)
focus cleared when its record left the rowsmoves to the nearest record still shown (cleared only when none is left)
a server-side grid pruned the selection to the records loadedkept; 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 GridTransactionResulta GridDraftApplied (the same, plus saved with { save: true })

One filter: filter

BeforeAfter
setState({ filterModel }), getState().filterModelsetState({ 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, queryModelparams.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:

BeforeAfter
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

BeforeAfter
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.

BeforeAfter
getStateSnapshot().sortModel / .filter / .grouping / …getState().sortModel / …
getStateSnapshot().columnsgetColumns()
getStateSnapshot().displays, .enableColumnReordergetOptions().displays, getOptions().columnReorder
getStateSnapshot().loading, .activeEditgetStatus().loading, getStatus().edit
getStateSnapshot().selection.range / .focusgetState().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().

BeforeAfter
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.serverSideg.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:

BeforeAfter
@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_THEMEgetBuiltInTheme('light') …
applyFilterToModel(field, f, model)setState({ filter: { columns: { [field]: f } } })
createEmptyQueryModel(), isQueryModelActive, countQueryNodesa query is a literal: { version: 1, root: { kind: 'group', … } }
offline types from both the root and /offlinethe ones the grid's api names from the root; stores and sync from /offline
createWorkspaceControllerthe 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:

BeforeAfter
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:

BeforeAfter
type: 'currency', <Grid columnTypes={…}>, resolveColumnTypes, BUILTIN_COLUMN_TYPEStype: currencyType() in the column; a type of your own is a function returning settings
valueFormatter, ValueFormatterParamsformat, 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 columnthe required(field) rule in dataIntegrity.validation.cellRules; missingRequiredRule is gone
your own column with checkboxSelection: truerowSelection: '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.

BeforeAfter
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.

BeforeAfter
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

BeforeAfter
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

BeforeAfter
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 rendererrecord(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.

EventFollow instead
sortChangedwatch((g) => g.sortModel, …)
filterChanged, quickFilterChanged, queryModelChangedwatch((g) => g.filterModel / g.quickFilter / g.queryModel, …)
groupingChanged, groupByChanged, groupColumnAdded / Removed / Movedwatch((g) => g.grouping, …); which column: diffIds over by
pivotChanged, treeDataChanged, aggregationChanged, detailChangedwatch((g) => g.pivot / g.treeData / g.aggregation / g.detail, …)
hierarchyColumnChanged, columnReorderToggled, commandsChangedwatch((g) => g.options.hierarchyColumn / g.options.columnReorder / g.options.compose, …)
expansionChangedwatch((g) => g.expansion, …)
selectionChanged, focusChangedwatch((g) => g.selection, …) (.focus for the focus)
rowSelectionChangedwatch((g) => g.selectedRowIds, (next, previous) => diffIds(previous, next))
paginationChangedwatch((g) => g.page, …); the page count watch((g) => g.status.pagination, …)
columnsChanged, columnOrderChanged, columnResizedwatch((g) => g.columnState, …) (and g.columns for the definitions)
rowResizedwatch((g) => g.rowHeights, …)
fieldsChangedwatch((g) => g.fields, …)
workspaceStateChanged, syncStateChanged, serverSideStateChangedwatch((g) => g.status.views / g.status.sync / g.status.serverSide, …)
cellInvalidated, renderInvalidated, layoutTransitionCaptureRequestedinternal (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.

BeforeAfter
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.

BeforeAfter
showStatusBar, showFilterChipBar, showMinimap, showFloatingFiltersstatusBar, filterChips, minimap, floatingFilters
enableColumnReorder, enableContextMenu, enableChart, enableNavigationcolumnReorder, contextMenu, chart, navigation
navigationOptions={{ editTrigger, arrowKeyNavigationEdit }}editing={{ trigger, arrowKeysKeepEditing }}
rowOverscanPx, colBufferperformance={{ 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:

BeforeAfter
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.savedQuickFiltersstorage: { 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: falsecapabilities.canExport: false, or compose the bar without export
workspace.quickFilters: falsecompose 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).

BeforeAfter
initialState.sidebarOpenPanel, quickFilterModelinitialState.sidebarPanel, quickFilter
initialState.aggregation: { defs }initialState.aggregation: defs (the list)
initialState.columns, columnWidths, pinnedColumnsinitialState.columnState (or width / pinned on the columns)
initialState.treeData, detail, hierarchyColumnthe treeData, detail, hierarchyColumn options (live)
initialState.defaultRowHeight, defaultColWidth, loadingSkeletonCountoptions 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 } (or api.setOptions({ theme })). Background, text and lines follow bgColor, textColor and borderColor.
  • 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's filter entry draws it. HeaderMenuRendererProps is 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 what select reads).
  • api.subscribe(fn) → api.watch((grid) => ({ ...grid }), fn) to hear any state change, or better a selector of what you use.
  • subscribeToSyncState, subscribeToPersistenceStatus, subscribeToWorkspaceState, subscribeToIntegrity and onThemeChange → api.watch((grid) => grid.status.sync) (or status.persistence, status.views, status.integrity, status.theme).
  • subscribeToCell, subscribeToDomainVersions and subscribeDomain left the public api: they serve adapters' renderers. To follow a cell's value, api.on('changed', …) and read it again.
  • React: useGridSelector(select, equals) and useGridKeySelector(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'. mode is gone.
  • capabilities.canPerformAction → the rule for that action (canPaste, canFill, …).
  • normalizeCapabilityResult, CAPABILITY_ALLOWED and GridCapabilityCallback are gone: ask api.can, and type a rule as GridCapability.

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]} (or api.setActions) → compose={{ commands: (defaults) => [...defaults, approve] }}, with records: 'selection' on a command that acts on the selected records (the old default). GridAction is GridCommand; its run, available and message get 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. The actionsChanged event → commandsChanged.
  • The workspace's compose.palette → compose.commands: the palette lists the commands that have a group. WorkspaceCommandItem → GridCommand.
  • shortcuts: { 'view:gantt': 'shift+g' } → compose that command's keys: commands: (all) => all.map((c) => (c.id === 'view:gantt' ? { ...c, keys: ['shift+g'] } : c)). A key turned off with null → drop the command, or give it keys: []. edit:undo, edit:redo and select:all are the grid's undo, redo and selectAll.
  • The cell menu's actions are named by their own id (approve), not action:approve, in menus and in the bulk bar's data-control.
  • The cell menu's words (messages.contextMenu.copy…) and the column menu's entry words (sort, pin, group, fields) moved to messages.commands.

Why: the grid, the workspace and the sidebar draw every menu the same way.

Fix:

  • Rows are .og-menu-item (a <button>, disabled with the disabled attribute), 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 through messages are 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's action(params) becomes run(), with the cell (rowId, colField, pointer, selection, api) from the context. shortcut becomes hint, variant: 'danger' becomes danger: true, hidden means 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 call registerGridContextMenu. 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's run(context, anchor) becomes run(anchor) and pressed(context) becomes pressed(), with the composer's ctx.
  • 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 longer extension:<id>:<tab>.
  • bulkActions: [action] → a command with records: 'selection' in commands, and bulkBar: (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 / exportExcel without onlySelected or rowIds now 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 than rows().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 without dataIntegrity.
  • resolveConflict(id, { strategy: 'local' }) → resolveConflict(id, 'local'); { strategy: 'custom', value } → { value }. The result no longer has a blocked status.
  • Remove dataIntegrity.conflicts (and its validateBeforeResolve / checkCapabilitiesBeforeResolve): conflicts are always on, and a resolution is checked like any edit (capabilities, and validation per your validateOnSubmit setting).
  • GridCellConflict has a required shows: 'local' | 'remote' (the side the cell shows).
  • api.integrity.clearIssues() no longer clears conflicts; use api.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 (or setCellValue) writing through a value setter or blocking rule that returns a promise is now rejected with needsAsync: true. Write it with transaction(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-integrity filteredRows scope follows.
  • Views of your own: WorkspaceViewContext.rows() returns the matching records, position(id) is a record's place among them, and completeness() 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: '…' } }}> (or createGrid({ messages: { sync: … } })).
  • Custom filter editors receive params.messages (the grid's words); nothing to change unless you build DomFilterEditorParams yourself, 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:

  • GridApi has seven new members (getSyncState, subscribeToSyncState, syncNow, getPendingChanges, discardPendingChanges, clearOfflineData). A hand-written mock or wrapper typed as GridApi needs them; without offline, getSyncState() returns null and the others do nothing.
  • GridClientProps (React) is now a type alias, a union of "with offline" and "without": interface X extends GridClientProps becomes type X = GridClientProps & { … }.
  • New union members: GridTransaction['origin'] adds 'server'; GridConflictSource adds 'sync'; GridInsightLayerId adds 'sync'; RuntimeFaultSource adds 'offline'; GridEventName adds syncStateChanged, changesSynced, changesRejected. Exhaustive switch statements 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.
  • distinctCount skips blanks.
  • A pivot's median skips blanks; it used to read them as 0.
  • A pivot's first, last and 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.

Edit on GitHub

Last updated on

On this page

next (unreleased)Named views (plan 190)Field types, collections and composed parts (plan 189)useGrid() and <GridScope>React cell components take the value as unknownColumn filters patch per fieldLive language, workspace and rulesRules, issues and saving: dataIntegrity issues and command targetsOne filter: filterOptions are set with setOptionsStyle rules' selectionSingle-value gettersNo state snapshotWhat the grid reports: getStatus()getState() is frozen, and watch reads itA visual row's record is its id and dataEach export in one entry, for one audienceWhat a column means is on the columnA column has one property per jobA row node is a readRow shaping is state; its gestures are commandsOne write: transactionTwo row reads: records and recordOne cell read: cellState is followed with watch; events are things that happenState is set with setState<Grid> props are the grid's optionsOne storage option; the workspace's options say only what the workspace isinitialState is state; the rest are options or create optionsOne theme, one way to change a column menuCard displays are the grid's displaysTwo ways to listen: on and watchOne rule per action: canSort, canGroup, canSelectActions, the palette composer and shortcuts are commandsMenus share one look and one set of class namesThe workspace's slots are composed card and header partsThe cell context menu is changed through the grid's compose onlyThe workspace's own summary is the bar's summary sectionQuick filter chips are composedWorkspace extensions are gone: packages of composers are combinedExport and charts count every matching recordThe summary strip's defaults follow the record rolesConflicts: one list on the gridCell writes: one preparation for every surfaceViews: records are the query's, not the table's displayTranslating: offline's words move to the gridOffline and sync: new API members and union membersReact: live props apply when their content changesData changes: one changed event replaces cellValueChanged and rowsUpdatedAggregates follow one rule everywhereEvery cell is final in every frame: renderer scroll options removedRenderer scroll options: text or live (superseded by the change above)One data-write API: transactionText-only cells no longer contain .og-cell-content.og-cell-portal-host no longer has a boxCutting a release