Changelog
What changed for users of active-grid, per version.
@workspace/active-grid 11.0.0Status unknowncontent differs from the recorded release 11.0.0baseline ce081dc3
The version line at the top of this page is read from the package and the release register of Foundation. A recorded release counts only for exactly the content it recorded: when the package changed after it, this site does not call that version released. The section Unreleased lists what changed since, including the changes made since Foundation maintains the package.
Notable changes to @workspace/active-grid. Format based on
Keep a Changelog; versioning per
Semantic Versioning.
For architectural rationale of larger changes see
docs/DECISIONS.md.
[Unreleased]#
Maintained in Foundation (since 2026-10-21)#
- Dependencies:
@workspace/active-helpersreplaces@workspace/active-utils. The serialization, export-font and menu helpers the grid uses (./serialization,./export-fonts,./menu-items) now come from@workspace/active-helpers, the one package that owns them, instead of theportable-helpersselection of@workspace/active-utils. Same functions, same behaviour; the grid's public API is unchanged. A host that receives the grid receivesactive-helperswith it (the transfer closure names it). - Fixed: a wide grid no longer widens the page.
.grid-rootis nowposition: relative, the containing block of the grid's absolutely positioned parts (visually hidden texts, the skip-link target, the announcer). Before, those took a containing block outside the grid: a grid wider than its host's scroll container (a phone, a narrow column) made the whole page scroll sideways. Found on the documentation site at 390 px. - Tests run without the source repository. The source contracts
(
candidate-boundary,consumer-precheck,runtime-reach) read through a package-local helper (src/__test-utils__/files-if-present.ts) instead of the host'srepo-tools; one test sorts with a local machine-order comparator instead of@workspace/active-utils/sorting. The scrollbar test checks the grid's own thumb tokens and no longer reads one host's brand mapping of them (that mapping is the host's test). - Documentation: references to documents of the source repository are
text, marked
(source repository: …), instead of links that resolve nowhere here.
Changed — BREAKING for the managed package set (ADR-059)#
- Formatting comes from
@workspace/active-format, not from theformattersentry of a translation package. The manifest declares@workspace/active-formatinstead of@workspace/active-i18n; the package imports nothing from a translation package any more. No prop, export or behaviour of active-grid changed: its presentation type is still theValuePresentationunion, now owned by@workspace/active-format. - A consumer that formats next to active-grid with the same functions imports
them from
@workspace/active-format.
[11.0.0] - 2026-10-18 - The canonical transfer form, and the first recorded release#
Changed — canonical transfer form (2026-10-18, platform ADR-112)#
- The manifest is the transferable manifest (RPS-D7): no workspace
package in
devDependencies(what the package's own development takes from its host repository, such as the lint preset, is host tooling in the reusable package register); the declaration packages of what the source imports sit in the field of their module (@types/reactand@types/react-domas optional peers beside thereactpeers). Nothing changes for an existing consumer. tsconfig.jsonis self-contained: it no longer extends the host repository's shared TypeScript preset, with the same compiler options, because a bundler in a consumer reads it for every source file it compiles.
Changed (2026-10-04, one menu pipeline and behaviour with active-charts, ADR-058)#
- Added (G-1): custom context-menu items nest to any depth; a
childreninsidechildrenis a submenu of the submenu. - Added (G-2): a disabled item's
disabledReasonis exposed asaria-description(it was atitleonly), on submenu triggers too. - Changed (G-3): the resolve pipeline is
resolveMenuItemsfrom@workspace/active-utils/menu-items, shared with Active Charts; same semantics, the permission check runs as itskeepstep. - Changed (keyboard): a menu opened by Shift+F10 or the ContextMenu key focuses its first item; Tab closes the menu (not during IME composition).
- Changed (touch): under a coarse pointer every menu item is at least 44 px high.
Changed (2026-10-04, a declared menu permission fails closed)#
- Behaviour change: a context-menu item that declares
permissionis no longer shown when nohasPermissionchecker answered it. Before, a menu built without a checker (or passed directly as aContextMenuBuilder) ignoredpermissionand showed the item to every viewer. NowcreateContextMenuwithout a checker leaves such an item out, the render boundary keeps an item withpermissiononly when the factory approved it, and it reports a dropped item through the<GridDefaults diagnostics>sink (warn, sourceActiveGrid.ContextMenu,data: {label, permission}). The factory returns approved items as fresh copies; the consumer's objects are never mutated. Migration: a menu that declares rights passes the viewer's checker:createContextMenu({hasPermission, customItems}). The report reaches the sink outside production only, like the other configuration warnings; leaving the item out does not depend on it.
Changed (diagnostics family parity, R-58, ADR-057, 2026-10-02)#
- Every diagnostic reaches the
GridDefaultssink. The thirty configuration warnings that wrote toconsole.warndirectly now go through<GridDefaults diagnostics>(sourceActiveGrid), still outside production only. Without a sink they reach the console as before, as[ActiveGrid] …. - The currency, percent and calendar-day preset warnings are reported once
per column when the columns are defined, no longer once per rendered cell.
The CSV separator and the capability-drift warning of
createResourceServerFetchFnare reported once per ground; the drift carries both capability maps as structureddata. - Messages no longer repeat an
[active-grid]or[ActiveGrid]prefix. - No public API change.
Fixed (re-convergence, 2026-09-30)#
- Server mode: a re-render no longer restarts the adjacent-block prefetch. The prefetch effect depended on a page object the block cache creates on every render. Every host re-render therefore aborted and reissued the request for the next block.
- Server grouping: a load-more that settles after a sort, filter or grouping change is dropped. Before, its rows were appended to the group in the new dataset, and a failure set the grid's error. Only the first page of a group was checked against the dataset.
- Undo: a failed undo keeps what was already undone redoable. The failure path re-recorded the entry, which cleared the whole redo stack.
- A select filter restored from the URL matches again. The URL brings a numeric-looking option code back as a number, and the select predicate compared it with the cell's written form. Numbers are now compared by their written form; other non-strings still never match (the string-key contract of
filterOptions). - Group labels are isolated bidi runs. The client group label, the server group value and the grouping chip render
dir="auto"with.grid-isolate(ADR-052 point 4). - The lookup dialog's hint has its style again. It is small, muted text in the footer.
Changed (portable runtime P9, ADR-056)#
@date-fns/tzis a declared dependency. The inlined formatter closure imports it; an install outside the workspace reads only this manifest, so the package now declares it. No runtime change.- Documentation. README, functional overview and the
localeprop describe the standalone product (no Next, no active-ui, the stylesheet import, the time-zone requirement of date filters and calendars, optional export backends as bundler externals).
Documentation (portable runtime P8D, ADR-055)#
- Export guarantees.
docs/guides/export-guarantees.mdstates the tiers L0 to L3b-print per format and script, the route to take, the error kinds, a host font example and the host L3b recipes. No runtime change.
Changed (portable runtime P8C, ADR-054)#
- BREAKING (behaviour): the PDF refuses what it cannot draw. Without an
export font set, text outside WinAnsi fails with
GridExportErrorEXPORT_FONT_REQUIREDinstead of drawing broken glyphs; complex and right-to-left scripts, residual combining marks, joiners and variation selectors, emoji and supplementary-plane characters fail withEXPORT_TEXT_UNSUPPORTEDwhatever the fonts. WinAnsi-only PDFs without a set are unchanged. xlsx,jspdfandjspdf-autotableare optional dependencies. The package root no longer reaches them; a missing one fails its export withEXPORT_DEPENDENCY_MISSING(missingModule).
Added (portable runtime P8C, ADR-054)#
- Export fonts for the PDF.
GridExportFonts(withGridExportFontFamily,GridExportFontFace,GridExportFontSource): host families in fallback order, faces as bytes or lazy loaders. Accepted byGridPdfExportOptions.fonts, the grid'sexport.fontsandGridDefaults'export.fonts, in that precedence;'inherit'uses no set. Each cell draws in the first family that covers all of it (EXPORT_GLYPH_COVERAGEotherwise); the flat, grouped and server-grouped PDF share one pipeline. Structurally the charts'ChartExportFonts. GridExportError(GridExportErrorCode,GridExportErrorOptions): stable codes, metadata without exported text.- Export direction.
GridExportDirection;GridPdfExportOptions.directionputs the first column on the right;GridExcelExportOptions.directionopens the sheet right to left (Workbook.Views[0].RTL). The row menu passes the grid's resolved direction. Values stay logical and typed.
Fixed (portable runtime P8C, ADR-054)#
- The PDF export works in the browser. It called
doc.autoTable(...), which jspdf-autotable 5 installs only on a globalwindow.jsPDFthat the ES build of jsPDF never sets (measured:TypeError: doc.autoTable is not a function). It now callsautoTable(doc, options).
Changed (portable runtime P6, BW-3, ADR-053)#
- BREAKING: pinning is logical.
ColumnPinningPositionisfalse | 'start' | 'end',ColumnPinningStateis{start, end},column.pin('start' | 'end'); the readers aregetStart*/getEnd*(getStartVisibleLeafColumns,getEndHeaderGroups, …) and header group namesstart_*/end_*. No alias forleft/right. - BREAKING:
columnResizeDirectionis removed. The grid's one resolved direction reaches the options asdirection. - BREAKING: persisted state version 3. A version 2 payload is cleared on
load; a physical
left/rightpinning slice is dropped. - RTL.
dir="rtl"(or an inherited rtl) lays the grid out mirrored: pinning, scrolling, column virtualization (active-virtual reads the rtl offset), header/body sync (TD-14 fixed), resize, reorder, keyboard (ArrowLeft/Right visual, Home/End logical), selection, menus (submenus toward the inline end), filters and the calendar; directional icons mirror.cellAlign: 'right'is the inline end. - Formatted values read in their locale's direction. A value renders
with its presentation locale's script direction where that differs from
the grid's (
GridValue), so an ltr-locale date keeps its order in an rtl grid.
Added (portable runtime P5, ADR-052)#
dirprop. A grid resolves one UI direction: the explicitdir, else the page's direction (the nearestdirattribute, followed when it changes), elseltr. Never the locale. Every popup the grid opens carries it, and Base UI's keyboard semantics follow it. Until P6 a grid that resolvesrtlthrowsGridProjectionFault('unsupported-direction'); an invaliddirthrowsGridConfigurationError(GRID_DIRECTION_INVALID).meta.textDirection(GridTextDirection:auto,ltr,rtl). Cell text, headers, filter options and the cell tooltip render as isolated runs: free textauto, formatted values inheriting, codes as declared. A right-to-left column name in anaria-labelor announcement is isolated with FSI/PDI; Latin labels in anltrgrid are unchanged. Exports and the clipboard carry no bidi controls.
Changed (portable runtime P4, BW-2, ADR-051)#
- BREAKING: an unsupported locale throws. A grid whose presentation
names a tag the formatters cannot honour (malformed, without Intl data, a
non-Gregorian
-u-ca-or an unknown-u-nu-) fails withUnsupportedLocaleErrorfrom@workspace/active-i18n/formattersinstead of rendering English. Any other BCP 47 tag Intl supports formats in its own convention and digits. - The calendar accepts every supported locale. Month and weekday names in
the Gregorian calendar, accessible labels from Intl ("Next month",
"today"), day numbers in the locale's digits, and the week start from
Intl.Localeweek info (Monday without it). AcalendarLocaleobject that carries its ownlabelskeeps them. The previous five-language refusal is gone. - Number input reads any supported locale. The number editor and the numeric filters use the one portable parser: the locale's decimal separator, Indian and other grouping on paste, every Unicode digit set and the U+2212 minus. Parentheses and a trailing minus stay unsupported.
- IME composition is left to the input method. While an input method composes, Enter, Escape, Tab and the arrows neither commit, cancel nor move the active cell.
Changed (portable runtime P3, BW-1, ADR-049, ADR-050)#
- BREAKING: the grid ships its own stylesheet. Import
@workspace/active-grid/styles.cssonce, after declaring the cascade layer order (… base, active-grid, components, utilities). All rules are plain CSS in@layer active-grid; no Tailwind scan and no design-system stylesheet is needed. A neutral light and dark baseline is built in; a host brands the grid by mapping the--grid-*role tokens (--grid-surface,--grid-primary,--grid-border,--grid-ring,--grid-status-<tone>, the radius and type scale, …). Previously unlayered, the grid's rules now lose to a host's utility layer, so a host class on a grid element takes effect. - BREAKING:
StatusTone→GridStatusTone(same five values) formeta.statusTonesand<StatusCell tone>. The grid no longer exposes any type from@workspace/active-ui;FilterPopoverContenttakes the grid's own popover props. - BREAKING:
LinkCellrenders a native<a href>for internal links. A host with a client router passes its link once via<GridDefaults ui={{linkComponent}}>(GridLinkComponent,GridLinkProps:{href, className, children}). External links are unchanged. - BREAKING: default menu classes.
ui.contextMenuandui.headerMenudefault to the package classesgrid-menu-popup--context/grid-menu-item--contextandgrid-menu-popup--header/grid-menu-item--header(same sizes as before). - The grid renders on an internal primitive layer (Base UI and
react-day-picker); its markup names only
grid-*classes. - Dependencies:
@workspace/active-uiandtailwind-mergeremoved,react-day-pickeradded; thenextpeer removed. Peers arereactandreact-domonly.
Changed (portable runtime P1, ADR-047)#
- Diagnostics go to a host sink.
<GridDefaults diagnostics>takes aGridDiagnosticSink({level: 'debug' | 'warn' | 'error', source, message, data?}, a caught error underdata.error); nearest boundary wins. Without one,warnanderrorgo to the console anddebugis dropped. New public typesGridDiagnostic,GridDiagnosticLevel,GridDiagnosticSink.copyToClipboardoptions takediagnosticstoo. - BREAKING (internal consumers only): a misconfiguration throws the new,
public
GridConfigurationError(codeGRID_ROW_ID_MISSINGorGRID_SERVER_OPTIONS_INVALID,field,constraint,value) instead ofValidationErrorfrom@workspace/active-errors. Messages are unchanged. CellErrorBoundaryis a function component over an internal class boundary; its props and rendering are unchanged.- Dependencies:
@workspace/active-loggingand@workspace/active-errorsremoved. The CSV/decimal helpers come from@workspace/active-utils/serializationinstead of/helpers.
Added (FAM-V2-T6, ADR-046)#
- The outcome of a menu export reaches the host.
GridExportOptions.onExportComplete(format, scope)fires after the file was handed to the browser;onExportError(format, error, scope)when no file was produced. Before, a failed menu export was only logged and the host could not tell. WithoutonExportErrorthe failure is still logged, never swallowed. New typeGridExportScope('page' | 'loaded' | 'selection'). Additive; the programmatic export functions keep reporting by throw, rejection and return.
Breaking (FAM-V2-T4, ADR-045)#
GridDefaultsreplacesGridConfigProvider. One public defaults boundary carries the presentation (locale), the translator (t) and thePartialGridConfigsections as top-level props. Migration:<GridConfigProvider config={{storage: {...}}}>becomes<GridDefaults storage={{...}}>. Nested boundaries refine the enclosing one; presentation and translator are replaced whole, sections merge one level deep,filtering.operatorPresetsper name.- No implicit English. A grid without a
tprop and without an inherited<GridDefaults t>throws a configuration error. The English table is the exportedenglishGridTranslator; pass it on purpose for standalone use, fixtures and examples. The operator factories (getTextOperators, …,getOperatorsForType) take a required translator, and the English-bakedTEXT_OPERATORS,NUMBER_OPERATORS,DATE_OPERATORSandBOOLEAN_OPERATORSare removed. - Built-in column types are the package default. Every grid applies them
without a prop;
columnTypesextends or overrides per type name.getDefaultColumnTypesleaves the public API: removecolumnTypes={getDefaultColumnTypes()}and spread nothing into a custom map. - Provider surface narrowed.
GridTranslationProvider,useGridCalendarLocale,useGridConfig,DEFAULT_GRID_CONFIG,GridProvider,FlexLayoutContextandFlexLayoutContextTypeare internal.useGridTranslationanduseGridLocalestay public and return the effective values of the enclosing grid. - JavaScript theme layer removed (FAM-W6): the
themeprop,createGridTheme,GridTheme,gridThemes,GridThemePreset,compactTheme,comfortableThemeandfinancialTheme. The grid CSS stays in active-ui; a scoped override sets a--grid-*value on.grid-rootthroughclassName. Row height has one authority (settings.rowHeight, thenlayout.rowHeight). The per-grid scrollbar thickness setting went with it; the lane is 10px.
Changed#
-
localeis an optional instance override onActiveGridandLookupGrid: the prop, else the nearest<GridDefaults locale>, else a configuration error.tonLookupGridfollows the same rule. -
GridPresentationis an alias of the active-i18nValuePresentation(active-i18n D23); the type is unchanged. -
The filter builder takes a condition's number and date in the grid's own controls instead of native
numberanddateinputs. A number is typed in the notation of the grid's presentation, as in the number filter, and stored as a number; a date is typed in the presentation's date notation or picked from a calendar behind its own button, and stored as an ISO calendar day. Half-typed or unreadable input leaves the condition's value as it was, and emptying the field makes the value absent (it was'', which a number operator read as0). The operator stays a select, as in the column filters. -
The pagination footer counts selected rows without building a row view per selected row. The displayed count is unchanged.
-
Row and column virtualization run on
@workspace/active-virtual2.0.0 (useVirtualAxis). Observable behavior is kept: the same windows, overscan, scroll-to-cell behavior and column resize; the measured row heights are now reset before the browser paints after a change of the row model, and the whole scroll range stays reachable for keyboard navigation to the last row when pinned rows or a footer follow the rows. No public API of the grid changes. -
Diagnostic severity follows the repository meaning of ERROR and WARN. Recovered or expected conditions now log at WARN instead of ERROR: a row that is gone when an edit starts, commits or is buffered in batch mode, an edit cancelled because its row left the data, a refused persistence write (quota, private mode) and a refused clipboard write (permission; the function still returns
false). Commit and batch-commit failures, export, render and fetch failures, and the refusal of a group row stay at ERROR. Messages are unchanged.
Fixed#
-
An incomplete filter-builder condition no longer filters rows.
evaluateFilterExpressionignores a condition until its operator has every value it reads, as its documentation already said: adding a number condition before typing its value used to hide every row.0andfalsestill filter, a range needs both ends, andisEmpty/isNotEmptystay active without a value. An AND or OR group judges only its complete conditions (an incomplete range inside an OR group used to match every row), and a group with none matches every row. -
The filter-builder toolbar button counts only conditions that filter rows, on the same rule as the evaluator. A condition still waiting for its value no longer shows a count or marks the button active;
isEmpty/isNotEmptycount as soon as they are chosen. The panel's own count still covers every condition in it. -
When row virtualization switches on while the reader is scrolled (rows loaded past
virtualizationThreshold, or client data replaced by a larger array), the record at the top of the viewport stays there, at the same distance into it. The two bodies can lay rows out at different heights, so an unchangedscrollTopshowed another record, up to many rows away. A grid at the top stays at the top; horizontal scroll is untouched. -
Undo, redo and the edit history act only while the grid is mounted. An undo or redo whose
onDataCommitfinishes after the grid was unmounted no longer patches the old grid's rows, calls the consumer'sgetRowIdor rewrites its history, also when the call is rejected; an ordinary commit finishing after unmount records no history. The consumer's write itself still completes. -
A client group row's label cell no longer carries
data-wrap="true"from awrapTextcolumn: the label stays on one line, as on a server group row. A label too wide for its column now ends in an ellipsis with its full text astitle(client and server), and the item count no longer wraps below it. -
wrapTextwith the default text renderer now actually wraps. The renderer truncated by default whatever the column said, so awrapTextcolumn, including one withautoHeight, still showed one line with an ellipsis and its row never grew. Withtruncateunset, awrapTextcolumn now renders untruncated text on every default text path (plain,valueFormattertext, number, currency, percent and date semantics, and the empty-value placeholder), so the text breaks over lines andautoHeightrows grow to hold it. An explicittruncate: truestill truncates, an explicittruncate: falsestill does not, columns withoutwrapTextare unchanged, and customcellrenderers are untouched. -
Under row virtualization, a toolbar search now resets the measured row heights, as sorting, column filters, grouping and expansion already did. A search puts other records at the row positions; an auto-height row kept the height measured for the record that was there before.
-
A measured row under row virtualization now keeps its own height when rows are pinned to the top. The row reported its display position (pinned rows first) to the row virtualizer, which counts only the unpinned rows, so every measured height landed on the row that many positions further down: an auto-height row was clipped to the estimate while its neighbour grew, and the last row's measurement was dropped. Server group and load-more rows had the same path. Keyboard navigation, cell ranges and
aria-rowindexkeep the display position. -
Ctrl/Cmd+Shift+Z now redoes, and Ctrl/Cmd+Z and Ctrl/Cmd+Y also work with Caps Lock on. The handler compared the letter in lower case only, while a browser reports it in upper case under Shift or Caps Lock.
-
Documentation matches the source again:
enableUndoRedodefaults totruewhenonDataCommitis set, records only single-column commits and keeps its history per mounted grid; the cell editors are the grid's own number input and a calendar-backed date input, not nativetype="number"/type="date"fields;gridRef.removeRowis a post-commit patch, not an optimistic update; the exclusive-selection recipe states that the consumer translates theSelectionQueryitself; the header checkbox is three-state for assistive technology and two-state on screen. ThecalendarLocaledocblocks no longer show a locale the calendar refuses. Consumer guides and API pages that were partly Dutch are English. -
A "Save all" that completes while the user kept editing no longer erases the newer edits (CR2-02). Its completion cleared the whole dirty buffer: a cell changed again after the save began was marked clean although it was never sent, and when
onBatchCommitreturned rows the returned (older) value replaced the newer one on screen. The completion now settles only the cells that save sent. A cell edited again, a cell first edited meanwhile and a cell edited back to its old value stay dirty with the value on screen, keep "Save all" available, and go out with the next save; a returned row no longer overwrites a cell that is still dirty. A rejected save keeps every change, and a second "Save all" while one is pending sends nothing. With no edit during the save, nothing changes. -
Unmounting the grid ends a pending edit (CR-01). A validator that settles after the grid unmounted no longer leads to an
onDataCommitcall, in cell and row mode, and a pending batch flush no longer fills the buffer. A write the consumer already received finishes; the grid then patches no row and opens no editor. The same holds for "Save all". -
A write that finishes after the user opened another cell no longer replaces that edit (CR-02). Enter, Tab and a
commitOnBlurswitch opened the next cell as soon as the earlier write completed, closing the newer editor and discarding what was typed in it. They now move on only when the committed edit is still the one in progress; the newer editor keeps its exact draft. Normal Enter and Tab progression, and a refused commit that stays put, are unchanged. -
aria-rowcountand the body'saria-rowindexno longer count a filter row that is not rendered (R-54). A grid with one header row and no filter row announced its first body row as row 3 and one row too many in total; it now announces row 2. Grouped header levels, pinned rows, the virtual body and the group and "load more" rows of server grouping follow the same count. With a rendered filter row nothing changes. -
An edit that ends while its validation is pending no longer commits (IR-01). Removing the row, cancelling the edit, starting another edit or "Discard all" in batch mode now ends the pending commit:
onDataCommitis not called and nothing enters the batch buffer when the validator settles later. A validator's late message is no longer written under a cell opened afterwards. A live edit still commits exactly once. WithcommitOnBlur, moving to another cell commits the open edit first and opens the next one once that commit is accepted; a refused value keeps the open edit. -
A grid with multi-row selection declares
aria-multiselectable="true"also without cell selection (IR-02). Assistive technology was told the grid could not select several rows while it could. -
The header checkbox announces a partly selected page as
aria-checked="mixed"instead of checked (IR-03). Its look and what a click on it does are unchanged. -
Turning
enableCellSelectionoff ends every range, reported once as[](IR-04). The range stayed stored out of sight: it came back when the feature was turned on again, and while off it still took the Ctrl+C hint from the selected-rows item in the context menu. -
Server group rows carry
aria-rowindexlike every other body row (IR-05), so group, member and load-more rows form one unbroken sequence, also in a virtualized body. -
createResourceServerFetchFn'sonErroralso fires for a read that fails by a rejecting or throwing action or a throwingtransform(IR-06), once, after the retries are spent. It fired only for{success: false}. Redeemed and superseded attempts still notify nobody. -
The server-grouping "load more" row works without a pointer (R-44). Its cells are cells of the keyboard navigation: the arrow keys reach the row, and Enter or Space on any of its cells loads the group's next block once, the same action a click takes. The row is named by its label, its cells announce the keys (
aria-keyshortcuts), and it isaria-busywhile the block loads; keys pressed in that time ask for nothing more. When the block lands, the active cell keeps its position, which then holds the first new row, and the focus is there. Before, Arrow Down moved the grid's position onto the row while the focus and the mark stayed on the row above, and Enter loaded from a row the reader could not see was active. -
Focus that a replaced cell held returns to the active cell in every browser. The recovery relied on the
focusouta removed element dispatches, which not every engine does; a cell that loses its registration while it holds the focus now takes the same path. -
pagination={{enabled: false}}no longer pages a client grid (R-51). The pager was hidden while the rows were still cut to the page size, so a grid of 500 rows showed ten and the rest were out of reach. Every filtered and sorted row is now presented, a page size is ignored, and row virtualization still limits what is mounted;aria-rowindexcounts from one. Server grids are unchanged. Consumer grids with pagination off now show all their rows. -
A column width set by dragging survives a reload and every later viewport resize (TD-50 b). The flex layout ran before the stored column locks were read and wrote its own width over the stored one; no layout pass now runs before the locks are known.
-
createResourceServerFetchFn'sonErrorfires once per failed read, after the grid's retries are spent (TD-50 f). It fired for every attempt: one failure gave up to four toasts, and a read a retry redeemed still gave one. Retries and the grid's own diagnostics are unchanged. -
A second click on the active cell changes nothing. It used to drop the cell's ring while its corner cue stayed, a half-drawn state.
-
Escape never leaves half a selection on screen: with a range it ends the range and leaves exactly the look of a click on the active cell; without one it changes nothing. It used to leave the ring and remove only the cue.
-
The first click after a reload no longer draws its mark bright and then dim. The row under the pointer faded its hover background in over 100 ms, and a browser applies no hover after a reload until the first pointer event, so that fade started under a mark that had just been drawn. The hover background now changes at once.
-
After a drag, the focus and the grid's active cell are the same cell (the drag's start). The press focused the start cell while the active cell stayed where it was, so the next arrow key moved from a cell the reader had left.
-
With rows selected, a click on a cell (including on a row's checkbox) no longer takes Ctrl/Cmd+C from the rows. The click left a one-cell range, which went before the selected rows and, on the checkbox column, copied nothing.
-
A right-click on a body cell outside a range makes that cell the active cell. It already took focus natively while the grid kept its previous active cell, so after the menu closed the next arrow key continued from a cell the reader had left.
-
rowMenu.enabled: falseis honoured: no row offers the grid's context menu. The setting was declared and documented but not read. -
The row menu's Copy copies a pinned row whose data position is on another page. It looked the row up in the current page and wrote nothing.
-
Keyboard operations on a pinned row act on the row shown there (R-47). They used to act on the row at that index of the data order:
- Enter calls
onCellActivate, or starts the edit, for the pinned row, and for the centre rows next to it. - ArrowLeft/ArrowRight on a server group row expands or collapses the group that has focus. It no longer toggles the group at a pinned member row's data index.
- Arrow navigation past a column span uses the span of the row shown.
- Enter calls
-
Cell-range selection and copy (R-45), with
enableCellSelection:- Shift+click extends the selection from the active cell, which stays the anchor, to the clicked cell. It used to select only the clicked cell.
- A range copy and a single-cell copy (Ctrl/Cmd+C) now go through the same clipboard TSV boundary as the row copy: a value a spreadsheet would run as a formula is neutralized with a leading apostrophe, and a value with a tab, line break or quote is quoted. The copied text is still the presentation form.
- A range ends when a sort, filter, row pin or column move, hide or reorder puts other rows or columns at the positions it covers; one that leaves those positions in place keeps it. A range used to survive such a change and copy the data that had moved in. It no longer ends merely because the row count changed.
- Ctrl/Cmd+C reports its outcome through
meta.onCopySuccessandmeta.onCopyError, once per copy, like the row menu's copy. The count is the number of rows copied: the selected rows, the rows a cell range spans, or 1 for a single cell. - A range or active cell on a pinned row copies the row shown there; it used to copy the row at that index of the data order.
- Ctrl/Cmd+C copies a range made with the mouse alone; it used to do nothing.
-
Clearing a number filter right after an operator change clears it; the still-debounced value no longer re-applies the filter under the default operator (TD-50). An operator change re-applies the typed value as a number, not as the input's string.
-
A column drop lands on the side of the target header where the pointer is released. It used to read the side the last
dragoverhad written to React state, which a drop in the same frame could see before it rendered (TD-50). -
unpinRowrefuses a missing row identity, as the kernel'sunpinRowdoes, instead of filtering the pinned buffer by an absent identity (TD-46). -
An empty grid is valid for assistive technology: the "No results" message is one row with one cell, and a body still loading its first rows carries
aria-busy="true". A row group without a row was a criticalaria-required-childrenviolation (R-27). -
The focus marks follow the configured accent theme (UI-F1, UI-F2). The outer grid element carries the class
grid-root, on which active-ui re-declares every theme-derived grid token, so a focused filter field no longer shows the default blue under another accent. The active body cell is marked at 50% of--ring, the active-ui field focus, instead of the full ring. Focus behaviour is unchanged. -
The grid container's keyboard focus is subdued (UI-F3). Its tab stop marked itself with a full
border-primaryand aring-2halo; it now carries its own border at 50% of--ring, no ring and no outline, the same expression as the active cell and the filter fields. Focus behaviour is unchanged. -
Keyboard focus stays in the grid when the active cell leaves the DOM (R-27). Scrolling a virtualized row or column out of the window, or a render that replaces a control inside a cell, used to drop focus to
<body>, and the grid then ignored the arrow keys on its own tab stop. Focus now parks on the grid, returns to the active cell when it mounts again, and the next navigation key resumes from the active cell (clamped to the current model). -
Toggling a row selection or an expander with the keyboard keeps the focus on that control. The system columns' renderers keep their identity when the consumer passes new column definitions, so their cells no longer remount.
-
Action mode no longer stops on a control's hidden native proxy (the
aria-hiddeninput of a checkbox).
Added#
- The body context menu names every copy action by its scope. In a range context: "Copy selection" and "Copy selection with headers". In a cell context: "Copy cell", "Copy row", "Copy row with headers" and, while rows are selected, "Copy selected rows (N)". In a row context (no cell selection): the selected rows, or the row, each with and without headers. "Copy selection" writes exactly the block Ctrl+C writes; "with headers" adds one header row with the columns' labels, also over several ranges.
- The Ctrl+C hint sits on the one menu item that does what Ctrl/Cmd+C does
at that moment, exposed as
aria-keyshortcuts; where no item matches, none carries it. ContextMenuBuilderreceives an optional second argument,ContextMenuContext(new export):kind(range,cellorrow),columnId,selection(rangeCount,cellCount) for a range, andselectedRowCount. A one-argument builder keeps working unchanged.- With cell selection, a client group row offers the grid's copy-only menu: "Copy cell" on a data column, or the selection items inside a range. The row builder, edit and pin never appear there.
- Cells in a range carry
aria-selected="true", and a grid with cell selection declaresaria-multiselectable. - New translation keys, supplied by the host:
grid.copy.selection,grid.copy.selectionWithHeaders,grid.copy.cell,grid.copy.row,grid.copy.rowWithHeaders,grid.copy.selectedRows,grid.copy.selectedRowsWithHeaders. - Shift+F10 and the ContextMenu key open the row context menu for the active
cell, with the same target as a right-click on it. The grid opens the menu
itself; the browser's own
contextmenufor that keystroke is suppressed. Escape closes the menu and focus returns to the active cell. - With
enableCellSelection, the body context menu resolves its target when it opens: a right-click inside a range of more than one cell keeps every range and the active cell where they are (range context); a right-click outside all ranges clears them and makes the clicked cell active (cell context). Row selection is not changed, and the row's own menu items stay. useKeyboardNavigation's cell props carryonMouseDown: a primary-button press makes the cell active on the press itself.onClickstays and changes nothing after that press.- Styling hooks for the selection look, all set by the grid: on the grid
element
data-cell-selection(cell selection is on),data-cell-range(an explicit range exists) anddata-body-focus(the focus is in the grid's body or in its own body menu); on a celldata-cell-cue(the cell that carries the selection's cue). Two tokens on.grid-root:--grid-range-outlineand--grid-range-fill. - While the focus is parked on the grid (the active cell scrolled out of the mounted window), Shift+arrow keeps extending the range, Ctrl/Cmd+C copies, Escape ends the range and brings the active cell back, and Shift+F10 or the ContextMenu key brings it back and opens its menu.
- A keyboard path for column order and width (R-27, WCAG 2.1.1). With
enableColumnReorderingthe column menu offers "Move left" and "Move right"; on a resizable column it offers "Increase width" and "Decrease width" (10 px per step). The menu stays open so a step can be repeated. A move follows the rules of a drop (never across a pinned boundary or out of its header group, never past a grid-owned column); a width is held between the column's bounds and locks the column as the end of a drag does. Each step is announced through the grid's live region; a pointer drag announces nothing. New translation keys, supplied by the host:grid.move.label,grid.move.left,grid.move.right,grid.layout.increaseWidth,grid.layout.decreaseWidth,grid.announce.moved,grid.announce.resized,grid.announce.atMinWidth,grid.announce.atMaxWidth. - A server group row is a row of the keyboard navigation: the arrow keys reach it and Enter on its first cell reaches its toggle.
Grid.getState()andGrid.subscribe(listener)are part of the published grid: the accepted interaction state, replaced when a change is accepted and handed back unchanged otherwise, and an observer told after every accepted change (ADR-038).- The row and cell shapes are exported from the root:
SourceRow(a row that stands for a record, withoriginal: TData),GeneratedRow(a row grouping generated, withoriginal: undefined,groupingColumnIdandgroupingValue),SourceCell,GeneratedCellandGeneratedCellContext(ADR-037). - The grid options type declares
collationLocale, the language thelocaleTextcomparator orders text in. There is no default. Column.getNextSortingOrder(multi?)declares its optional argument: it asks about a toggle that extends a multi-column sort.
Changed#
-
Persistence writes only the slices it restores (TD-50 d): sorting, filters, visibility, order, sizing, column pinning, pagination, global filter, row pinning and expansion. Row selection, grouping and the resize bookkeeping are no longer written, and remain non-persistent. A stored entry that still carries them loads as before and drops them at the next save.
-
Cell selection has one model (ADR-043). The active cell is the grid's keyboard position and the anchor of every extension; a range is an explicit selection (a drag that reached a second cell, a Shift extension, a Ctrl/Cmd addition):
- A click makes the cell active and stores no range. With cell selection on, the active cell shows the one-cell look.
onCellSelectionChangereports explicit ranges only: a click reports nothing, and a single selected cell is[].aria-selected="true"is on the cells of an explicit range only.- A drag, Shift+click and Shift+arrow keep the active cell on the anchor and move the far end; Shift+arrow brings the far end into view. They used to move the active cell to the far corner.
- Ctrl/Cmd+click adds the cell and keeps the previously active cell selected. Ctrl/Cmd+Shift+click replaces the last range instead of adding an overlapping one (R-52).
- Ctrl/Cmd+C copies an explicit range, else the selected rows, else the active cell.
- Starting an edit ends the range.
-
The selection look is drawn from the grid's own state, never from where the browser put the focus, and nothing it draws moves content. The active cell outside a range: one square one-pixel mark at 50% of
--ring, plus, with cell selection on, the corner cue. A range: an 8%--ringfill, a one-pixel outline on its outer edges and one cue on the bottom-right corner of the last range. The active cell inside a range is the window: unfilled, with no mark of its own. When the focus leaves the grid the active cell's look disappears entirely and a range stays; a menu the grid opened changes nothing. The row of the active cell takes no focus tint of its own while a range is on screen. In forced colours the range is Highlight and the active cell a Highlight mark. The cue is a mark, not a handle: there is still no fill and no paste. Seeguides/styling.md. -
The cell-range copy (Ctrl+C and the menu) leaves out the grid's own control columns (selection, expander, actions), as the row copy and the exports already did; a range over the selection column no longer copies an empty field for it.
-
The row menu's consumer builder runs when that row's menu opens, not on every render of every row.
-
The selection checkbox, the expander and the group toggles are no longer stops in the page's Tab sequence while keyboard navigation is on (R-27): a page of 100 rows used to add 200 stops. Enter or Space on the cell reaches them (action mode), Escape returns to the cell. With
enableKeyboardNavigation={false}they keep their stop. Action mode now also stops on a cell control withtabIndex={-1}; a control a consumer renders in a cell joins this model by declaring that tab index. -
The grouping panel is rendered directly above the grid, as the top of the grid's card, instead of between the column headers and the body. The
role="grid"element owns rows only; the panel's buttons inside it were a criticalaria-required-childrenviolation. -
Controls that exist once per column are named after that column's label (
meta.label, else a stringheader) instead of its id or a generic word: the filter-row value control (Filter value for {column}, with the chosen value when there is one), the funnel and its popover (Filter {column}), the clear button, and the column menu button (Column options for {column}). The popover's condition select and value fields, the filter builder's selects and fields, and cell editors are named. A multi-column sort names its priority as a phrase instead of a bare digit. Expander and group toggles exposearia-expanded, and the group toggle is named byaria-labelinstead of only atitle. A server group row shows the grouped column's label instead of its field id. -
New translation keys, supplied by the host:
grid.filter.valueFor,grid.filter.valueForWithValue,grid.filter.condition,grid.filter.value,grid.filter.rangeFrom,grid.filter.rangeTo,grid.header.optionsFor,grid.sort.priority,grid.filterBuilder.column. -
R14B complete: the temporary approved-delta test register is retired. Every behaviour it listed is asserted by a directly named test, and the consumer-visible ones are now documented in the API reference: the built-in comparator and predicate names (unknown names, mixed values,
inNumberRange,between,arrHas), width bounds,getRowId,postSortRows, row models and selection scopes, pinned positions, group-row ids and faceting. No runtime behaviour changed. -
R14 complete: license and attribution disposition. The package license is unchanged (
UNLICENSED, proprietaryLICENSE,private: true). The current source contains no vendored or copied TanStack Table material and the package has no TanStack Table dependency, so no TanStack Table notice applies; none was ever present inLICENSE. The README License section now states this, keeps the removed fork as history, and notes that the runtime dependency@workspace/active-virtualcarries its own third-party notice. ADR-007 keeps its historical text with a current disposition. A legal question about earlier distributed versions is escalated (ROADMAP TD-42). Completed temporary rewrite reports were removed; the approved-delta test register is retired in the follow-up R14B. -
The development warning for toolbar search on a server grid without a
server.globalcolumn no longer names a removed grid-side resolver. It now says the term is sent to the fetch adapter asglobalFilterand the server decides which fields it searches. Text only; when it fires is unchanged. -
R13 complete: independent technical provenance supported; all 38 canonical documents reviewed, kernel ownership and API guidance corrected, neutral examples enforced by a scoped documentation guard. Historical ADR evidence and approved deltas remain through R14. Five internal hook aliases now say KernelGrid; source comment corrections do not change runtime behavior. Attribution and license decisions remain with R14. Performance follow-up remains due 2026-09-29; unrelated repository validation debt is recorded in the R13 closure report.
-
The grid runs on its own kernel; the replaced engine is no longer used at runtime (ADR-038). The published objects, options and state slices keep their names and shapes.
-
A callback that is handed a row's record —
meta.valueFormatter,meta.editable,meta.cellClassNameas a function,meta.cellClassRules,meta.cellStyle,meta.tooltipValueGetter,meta.tooltipField,meta.colSpan,onRowClick,onRowDoubleClick,onCellClickand the rowcontextMenu— is no longer called for a row grouping generated, which has no record; it was handedundefined. On such a row no edit starts and no row menu opens. Group totals, group footers, server-grouping aggregates and the presentation export of an aggregate are shown through the column's declared semantics (semanticType) or itsaggregatedCelltemplate and no longer pass throughvalueFormatter, which received an empty object in place of a record. Ratified as delta DC-1 (2026-09-22): a column whose aggregate relied onvalueFormatterfor its notation declaressemanticTypeinstead. -
The
medianaggregate skips an absent value (null,undefined,'',NaN,±Infinity) and is the median of the values that are present; without a present value it has no result. It used to return nothing as soon as one value was absent. Ratified as delta DC-2 (2026-09-22). -
Group-row aggregation is declarative. A column shows an aggregate in a group row only when it declares
aggregationFn. A numeric column without a declaration used to be summed through an implicitauto; it now shows an empty cell, because a numeric value (an identifier, a year, a rate) does not imply an additive meaning. A column that should total declares it, for exampleaggregationFn: 'sum';autostays available as an explicit choice. Ratified as delta DC-3 (2026-09-22). -
A mounted client grid no longer builds a row object for every filtered row on each data change. The pagination total and the status announcement read the filtered count without building rows, and the row numbering is built only when a row-number column is shown. At 100 000 rows this removes 200 000 of the 200 010 row objects a data change built.
-
onSelectionChangehands over the records of the selected source rows only. -
Breaking (types).
Row<TData>isSourceRow<TData> | GeneratedRow<TData>, androw.getIsGrouped()narrows it. Code that readsrow.originalwhere a generated row can arrive — a comparator, an eligibility predicate such asenableRowSelection,renderDetailPanel,getRowCanExpand, a row model — narrows first:(row) => !row.getIsGrouped() && row.original.active. Callbacks that only meet rows standing for records keeporiginal: TData: a filter function receives aSourceRow, an aggregation'sleafRowsareSourceRows, the parentgetRowIdreceives is aSourceRow, and a body-cell renderer'sCellContextcarries aSourceRow. AnaggregatedCellrenderer receives aGeneratedCellContext.CellisSourceCell | GeneratedCell. -
A persisted pagination state whose page size is not a whole number greater than zero is discarded on restore, as a zero or negative size already was; a fractional size is no longer restored. One rule decides every page size.
-
createLocaleSortFn(locale)refuses an empty or missing locale with aTypeErrorinstead of falling back to the host locale. The sorting semantics for a given locale are unchanged. Its declared return type is now a structural comparator overrow.getValue(columnId); the parameters are unchanged. -
createColumnHelperandcreateLocaleSortFnare implemented independently of the engine; the objects the helper returns are unchanged. -
The objects a consumer receives (
Grid,Row,Column,Cell,Header,HeaderGroup,CellContext,HeaderContext), the state slices, the callback types andDeepKeysare declared by the package itself instead of aliasing the engine's types (ADR-036). Every supported member keeps its name; the row, cell and callback signatures that change are listed above (ADR-037). Collections the grid hands out (getRowModel().rows,getAllColumns(),row.getVisibleCells(), …) are typedreadonly; the value type of a column now reachesColumn,Cell,Headerand both renderer contexts, and every slice reset declares its optionaldefaultStateflag. -
createLocaleSortFnandcreateColumnHelperare documented as part of the root export; they were described as contributor-only.
Fixed#
- A replaced column definition is what the grid shows. Re-rendering a
mounted grid with the same records and a replaced
accessorFn,accessorKey,cell,header,aggregatedCell,meta(for examplevalueFormatter,semanticType,cellClassName) or default column now updates the visible cells and headers when the render commits. The grid already answered with the new definition; the cells went on drawing the old one until an unrelated interaction. No row is rebuilt for it: the displayed rows keep their identity, and only their cells are rebuilt, also in a virtualized body. - The active cell is marked as soon as a click makes it active, not only
after an arrow key moves it (styling in
@workspace/active-ui). - A sort label clicked with a held Shift (multi-sort) no longer shows the keyboard focus ring. Keyboard focus on a sort label still shows it.
- A malformed page size in the address (
pageSize=2.5,25junk,1e2) is ignored instead of being truncated into a different, valid size; the one page-size rule reads the text (PW-02). - A malformed page number in the address (
page=2junk,1.5,-1,0) is ignored instead of being truncated or clamped into a page; it follows the same textual rule as the page size. - With two or more sorted columns every sorted header shows its priority, the
first one included; the
1was never shown, so a multi-sort read as2without a first. With one sorted column no number shows. The header reads the priority fromColumn.getSortIndex(). - Clearing a text filter whose operator is not the column default (for example "does not equal") clears it; the debounced input used to re-apply the old text under the default operator.
- Server grouping no longer hands its group headers and load-more markers to
getRowId; the grid names them itself. - Server grouping shows the group rows the server returns. Since the kernel cutover the grid grouped those rows a second time by the mirrored grouping and folded every group into one empty group; in server-grouping mode the grid's own grouping is now manual.
- A page restored from the address or from the stored state survives the mount on a grid with a filter row. Every filter control clears its own column once on mount, and that no-op write returned the grid to page 1; a sort, filter or search now returns to the first page only when it changes the slice.
- The grid type declares
setColumnResizing, the member the grid has at runtime, instead of the misspelledsetcolumnResizing.
Removed#
-
The CSS token
--grid-z-cell-focus. The active cell's mark is an overlay inside the cell and needs no stacking of its own; the old rule's z-index also lifted a scrolled active cell over the sticky pinned columns. -
The active cell's inset box-shadow ring on
.grid-cell[role='gridcell']:focus, replaced by the overlay mark above. A consumer rule that restyled that shadow no longer has an effect. -
The translation keys
grid.copy.labelandgrid.copy.withHeaders: the one ambiguous "Copy" is replaced by the items named by scope (see Added). -
The translation key
grid.filter.openFilter: every funnel is named with its column (grid.filter.openFilterFor). -
From the public types: members that belong to the engine's implementation (
_features, the prototype and row-model fields, value caches,autoResetExpanded()), the state-container fieldsbaseStoreandstore(grid and options), thedebug*options andmergeOptions, and five declared grid members that do not exist at runtime (getFacetedRowModel,getFacetedUniqueValues,getFacetedMinMaxValues,getPinnedLeafColumns,getPinnedVisibleLeafColumns).Header.rowSpan, which is always0, is no longer declared. -
The dependency on
@workspace/active-state. It served only the replaced engine, which is removed from the source together with its binding (TD-42, R12); neither was reachable from the published entry, so no import changes.
Documentation#
- The version-to-version migration guides (
migration/from-pre-1.0.md,v1-to-v2.md,v2-to-v3.md,v3-to-v4.md) are removed: they described upgrades to versions and helpers that no longer exist. A current integration starts from the current API; this changelog keeps the history. The AG Grid migration guide (migration/from-aggrid.md) is removed too: the package is private and has no external adopters. guides/i18n-date-filters.mdis merged intoguides/filters.mdandDATA-ACCESS.mdintoARCHITECTURE.md§ Datatoegang en eigendom.- The README quick starts now pass the required
locale, andapi/filters.mdnames the real operator label keys (grid.operators.*).
[10.2.2] - 2026-09-21 - The baseline is frozen on a quiet machine, and the column window is bounded#
R-26 is closed. The entries below were held at 10.2.1 until a baseline could be frozen on a quiet machine: freezing the numbers of a loaded machine, or of a defect, is worse than having no baseline at all. On 2026-09-21 both rounds reported
quiescent: yes-- the browser round with only admin:3002 and auth:3000 listening, the jsdom round with nothing listening at all -- andbenchmarks/baseline.jsonnow exists at this version. The immediate compare-back was green: no counter drift, no scenario-integrity drift.
The benchmark baseline exists (R-26)#
benchmarks/baseline.json is the package's first legitimate frozen baseline:
both runners, 18 scenarios, 3 warm-ups plus N = 15, raw observations retained,
every counter observed fifteen times and equal among itself, and the machine
condition of each round recorded inside the document alongside an explicit
human confirmation.
Added#
benchmarks/baseline.json-- the frozen reference point. It is the one shared artefact underbenchmarks/; everything underbenchmarks/local/stays ignored.
Measured#
- Zero counter drift against the 2026-08-28 trial rounds, in both runners. Those rounds were measured on a machine carrying seven to nine dev servers with coefficients of variation up to 89%, and their counters are still exactly these. That is the tightest evidence so far that the counter class is genuinely machine-independent while the timing class is not.
- Memory is a usable trend for the first time. The jsdom round carries
memoryPolicy: "trend"and all twelve retained-heap series sit below a 12% coefficient of variation, where 10.2.1 published negative deltas of hundreds of megabytes. It remains a trend and does not become a gate. - The three pre-rewrite measurements R1 asked for: one delivered, two
reported as unobtainable. Accessor-call counts and engine-wide publication
counts do not exist in the shipping engine, and adding a scenario counter is
by the scenario manifest a baseline change -- which would have invalidated
the very reference point this work produced. The column-normalization
question is answered as a static census instead: of 240
columns={...}bindings across 235 consumer files, 40 rebuild the array identity on every render and therefore miss the engine's column memo every render. Full reporting inPERFORMANCE.md.
The public column contract is a contract again (TD-31B, ADR-035)#
GridColumnDef<TData> resolved literally to the top type, so
const columns: GridColumnDef<Row>[] = [42, 'not a column'] compiled. There were
two independent causes with that effect, and repairing one of them did nothing
for the other. No runtime behaviour of this package changed.
Changed#
- The feature set is supplied, not hidden.
ReactLayerFeatureswasany; it is nowStockFeatures, in one named package-private type. Measured:CoreFeatures & StockFeaturesproduces 16 errors thatStockFeaturesdoes not and removes none, all of them an alias claiming a wider set than the engine builds. GridColumnDefis a union of three kinds — accessor-key, accessor-function, and group-or-display. A display column is not a fourth kind on purpose: as a separate member it would absorb a group whose children are invalid.accessorKeymust name a field of the row, dotted paths included, computed from the unwidened row type.- A group's
columnsis recursively this same definition, so a nested child carriestype,serverandmetaexactly as its parent does. It was the engine's own shape and carried none of them. createColumnHelperdeclares the public shapes and infers each column's value type from the key or the function. It is the only construction path that infers a value type at all.enableRowSelection's predicate receives the ROW, and the prop now says so. It was declared with the record while the engine passed the row, so a predicate written against record fields readundefinedand refused every row. This is a corrected declaration, not a behaviour change in the package; the one consumer using the function form was corrected with it.meta.statusTonesis declared partial. A totalRecord<string, StatusTone>claims every possible string has a tone, which contradicts the documented fallback, and it refused a shape TypeScript synthesizes when two columns with different key sets share one array literal.- The header selection checkbox states its value honestly. It passed
'indeterminate'through a boolean field, so a partial page has always rendered as fully checked; the rendering is unchanged and the missing third state isROADMAP.mdTD-45.
Added#
hasAccessorKey/hasAccessorFn— the two narrowings for reading a definition back.accessorKeyis not reachable on a union, and a localcolumn as {accessorKey?: string}keeps compiling after the field moves.
Removed#
BivariantGridColumnDef— the bivariance it provided is now the default of the public type itself, so a second name for the same type would be a second truth. Its five consumers nameGridColumnDef<TData>.- Three
(r: any)annotations in component code that existed only because the React layer hidTFeatures.
Source language convergence completed, and the engine's provenance measured (AGRID-STD-04)#
No runtime behaviour changed and no public API changed. Every change is
authored prose: comments, docblocks, test descriptions, it.each labels,
assertion messages and developer diagnostics.
Changed#
- All remaining Dutch authored prose is English (TD-44, closed). Three
independently constructed sweeps found 908 lines across production source,
tests,
e2e/,benchmarks/andscripts/-- more than double the 242 the previous re-measurement reported, because a comment-only scan cannot by construction seedescribe/it/it.eachdescriptions, assertion messages or Dutchthrow new Error(...)prose. Seven English comments that quote a Dutch UI label remain, asCODING_STANDARDS§ 2 permits, along with the Dutch UI expectations and locale fixtures themselves. - Benchmark scenario descriptions are English.
scenario-manifest.tscarried Dutchmeasuresprose that lands in every published benchmark document. The harness assertion that matched on that Dutch text moved with it, so the guard keeps its subject.
Documentation#
- TD-42 is reframed from attribution to provenance. Measured:
src/engine/**(100 files, 14,004 lines) andsrc/react/**(4 files, 392 lines) are a fork with a rename of TanStack Table v9table-core, not an independent implementation -- established on directory layout, per-feature file names, the complete identifier set, and two character-for-character identical helpers inutils.ts. 27% of the package is derived; the remaining 73% is independently authored active-grid code.LICENSEwas not touched, no attribution text was added, and no existing provenance reference was removed. The evidence and the seven-tranche replacement plan are indocs/research/ACTIVE-GRID-TANSTACK-PROVENANCE-ASSESSMENT-2026-09-20.mdand inROADMAP.mdTD-42. This is a technical finding, not legal advice.
Documentation drift corrected (AGRID-DOC-01)#
No runtime behaviour changed and no public API changed. Listed here because three of these corrections were misleading to a consumer, which is the one case the changelog convention records a documentation-only change for.
Documentation#
- The README no longer says the package is MIT.
package.jsondeclaresUNLICENSED,LICENSEcarries the proprietary Active Development text andprivate: trueis set. The README also claimed the TanStack attribution was "preserved inLICENSE"; it is not there, and no file undersrc/engine/carries an upstream copyright header. ADR-007 requires that clause, so the gap is now stated in the README and registered asROADMAP.mdTD-42. nextis no longer described as optional.ARCHITECTURE.mdsaid "optioneel Next.js 16" andFUNCTIONEEL-OVERZICHT.mdsaid "optioneel Next.js 15 of 16", both left over from before AGRID-F04 made it a required^16.0.0peer.docs/api/columns.mdtyped a column group'scolumnsasGridColumnDef[]. It is the engine shape,Array<ColumnDef<…, unknown>>, and has never been otherwise: a group child carries notype,serverormeta, and itsTValueis pinned tounknown. Repairing the type was TD-31B, which landed in this same release and made the field the public definition at every depth; see the TD-31B entry above for what it is now.docs/guides/filters.mddescribedwithEmptyagainst a symbol that does not exist.toPrismaWhereFromContracthas no occurrence anywhere in the repository, and the claimed "silently dropped with a dev-warning" does not happen: in@workspace/active-resourcesa non-nullable text column matches the empty string, and number, date and boolean haveisEmptyrejected by the operator allowlist before any condition is built. The same wrong sentence is corrected in theoperator-presets.tsdocblock.docs/FUNCTIONEEL-OVERZICHT.mdrewritten against the measured package. It still described a different monorepo, counted 7 e2e specs where there are 17, cited the withdrawn ADR-011 guard, named a--max-warnings 545cap that is 100, and listed the Prisma query builder and the schema-aware server contract — both removed in 7.0.0 — as current differentiators. Batch editing, undo/redo, PDF export, the filter builder and cell/range selection were missing and are now listed.- Version labels. ADR-030, ADR-031 and ADR-032 read "geaccepteerd (…,
10.2.2)" as though 10.2.2 existed. Nine places now say what
INDEX.mdalready said: the work sits in[Unreleased]and ships as 10.2.2 with the R-26 freeze.
Removed#
- Eighteen temporary documents, after each was read in full and its durable
content handed to its owner:
docs/plans/,docs/traycer/,docs/comparison/,docs/integrations/, six research files, the resize handle analysis and the 2026-07-21 audit verification — about 12 000 lines. active-grid no longer has a work-archive or history concept; git is the history. Two research files remain, each with a deletion trigger recorded indocs/INDEX.md.
The package conforms to /docs/standards (AGRID-STD-03)#
No runtime behaviour changed, no public API changed, and lint:api-drift
reports the same 271 exports and 401 documented symbols as before. The full
suite runs green on the same 193 files and 2 808 tests.
Changed#
- Braces on every conditional and loop body (CODING_STANDARDS § 5), across
all authored source: 825 braceless bodies in 182 files became zero. Measured
over production, engine, test, benchmark, e2e and script source -- § 1 puts
all of them in scope -- and applied as an AST rewrite with
eslint --fixrunningcurlyalone, followed by Prettier.src/engine/is included: ADR-007 vendors it and says to treat it as internal code, no file under it carries an upstream copyright header, and there is no generator or synchronisation contract that § 2 would exempt. The TanStack portion clause inLICENSEis untouched. - All sixteen remaining hooks declare an explicit return type
(CODING_STANDARDS § 6), and not one name was added to
src/index.ts. Four reuse a type that already existed; the other twelve get a result interface in their own module, fourteen of which are module-private. TD-39 argued that naming a hook result inevitably publishes it -- the package already contradicts that six times (UseAutoSizeReturn,UseEditHistoryReturn,UseServerGroupingReturn,UseServerSelectAllReturn,UseBatchEditingStateReturn,UseGridImperativeResult), none of which appears in the barrel. Exported non-component functions in the non-engine source now carry an explicit return type 205 out of 205 times. curlyis enabled in the package's owneslint.config.mjsso a regression shows up inpnpm lint. It reports as a warning, because the shared base downgrades every severity; blocking enforcement is a repository decision and is registered as TD-40.
Documentation#
- Authored source prose is English (CODING_STANDARDS § 2): 1 417 comment and
docblock units across 185 files, 154
describe/it/testdescriptions, and two developer-facingthrowmessages. Meaning-preserving translation only -- no claim added, no commentary rewritten, and every date, identifier, path and translation key left exactly as it stood. The Dutch UI labels quoted as expectations incolumn-chooser.spec.ts,column-resize.spec.tsandcontext-menu.spec.tsstay: those specs run againstapps/admin, which hands the grid its ownt, and § 2 covers test expectations whose language is the point.
Removed#
bash.exe.stackdump(1 196 bytes, an msys bash crash trace). Already git-ignored, referenced by no gate, script or document; its call stack was read before removal and contains no Datyx frame.
A fallback message speaks the current language, and the manifest tells the truth (AGRID-STD-02)#
Fixed#
- A translated fallback no longer arrives in the language the user just
left (AGRID-F03). Three
useCallbacks reached for the grid's resolved translator without listing it: the paged fetch and the infinite-scroll fetch inuseDataOrchestrator, and the cell flush inuseBatchEditingState. A Datyx host memoizestper language, so the identity rotates exactly on a language switch and the callbacks kept the previous one. Reach:grid.error.fetchFailed(a fetch that rejects with a non-Error) andgrid.edit.validationFailed(a column validator that throws).translateis now in all three dependency arrays; the fetch trigger already calls throughfetchDataRef, so nothing refires.
Changed#
nextis a REQUIRED peer dependency (AGRID-F04).peerDependenciesMetamarked it optional whileLinkCellimportsnext/linkat module scope and the single entry point exportsLinkCell-- so importing the package has always had to resolvenext, and@workspace/active-uirequires it too. The manifest and the README now say what the package actually needs. No export, subpath or component moved; no consumer changes.- Type-only imports use
import typein 30 modules (CODING_STANDARDS § 4). Mechanical; no runtime or type-contract change. - Five production
as anycasts and their suppressions are gone fromexport-utils.ts.jspdf-autotablev5 publishesUserOptions,RowInputandCellHookData; the prototype-installedautoTableis now reached through one named view type instead ofany, and the twodidParseCellhooks are typed. The PDF option objects and both table bodies are checked against the package's own types for the first time. - Thirteen public functions declare an explicit return type (CODING_STANDARDS
§ 6): the twelve
exportTo*variants andcopyToClipboard. The sixteen that remain are all hooks whose result shape would have to be NAMED, which is a public-API decision; registered as TD-39. - Ten
react-hooks/exhaustive-depssuppressions carry an English reason (CODING_STANDARDS § 10). Each was probed by removal first: all ten are still necessary. Two redundantuseMemodependencies that the rule flagged as unnecessary were removed fromgrid.tsxanduse-grid-setup.ts; neither is read in its memo body. active-grid now lints at zero warnings.
Documentation#
- TD-37: two stale source comments corrected.
types/filters.tscalled the module-scope operator lists "NL defaults" -- they carry the package's own standalone English table.i18n/grid-translation-context.tsxandtypes/props.tsboth claimed the built-in calendar mapping "covers en/nl/de/fr/es"; since ADR-034utils/date-locale.tsdefers to active-i18n and active-grid carries no language list at all.
Keyboard navigation and auto-height read the live grid model (AGRID-CORR-01)#
Fixed#
- Keyboard navigation no longer freezes its bounds on the first render.
useKeyboardNavigationderivedrowCount,colCountandpinnedTopCountin auseMemokeyed on the grid instance, which never changes identity (react/useGrid.tsbuilds it in auseStateinitializer). The counts therefore stayed on whatever the first render saw. Consequences, all gone:mode="server": the first render has zero rows by contract, sorowCountwas 0,Math.min(rowIndex + 1, rowCount - 1)targeted row -1, and the first ArrowDown after the fetch threw DOM focus out of the grid. Every later key then landed nowhere. WCAG 2.1.1 is level A.mode="client": a row model that changed length after mount — filtering, searching, a shorter last page, data arriving late — kept the old bound, so arrows,End,Ctrl+EndandPageDowncould not reach the rows that were there.- The column axis: the column chooser, responsive hiding and a restored
visibility state changed the visible column count without changing the
bound, so
ArrowRight/Endstopped short or aimed at a column that had been hidden. pinnedTopCount: pinning a row to the top while the grid was mounted leftscrollToIndexconverting to the wrong center index.
meta.autoHeightnow takes effect when its column becomes visible. Both bodies (sections/body.tsx,sections/body-virtual.tsx) derivedhasAutoHeightfrom the same frozen memo. A column withautoHeightthat was hidden at mount — a persisted visibility state, responsive hiding — and made visible afterwards left every row on a fixed height, clipping content that should have grown.
No public API change, no remount, no new state, no change to
@workspace/active-virtual. The counts are read during the render and the
auto-height flag hangs on the visible-column model; both are engine-memoised
on the state that can change them, so an unchanged model costs a dependency
comparison. ADR-030
carried the rule already and now names grid as the second stable instance.
The date calendars mark the presentation's day, not the runtime's (AUI-FINAL-REM-01)#
Changed (breaking)#
- The calendars of
DateEditorand the date filter passtodayto active-ui'sCalendar, which now requires it (active-ui ADR-025, amendment of 2026-09-19). The day comes from the zone of the grid presentation, through the sametodayInPresentationthe relative date presets use; without a selection the calendar opens on that day's month.new Date()is gone from both. - A grid presentation without a time zone (a bare language code) now throws
a configuration error when a date editor or a date filter calendar renders,
the rule the relative presets already followed. Every product grid is fed by
usePresentation()under a resolved zone and is unaffected.
The host owns words and presentation; no invented English (I18N-HC-02, ADR-034)#
Changed (breaking)#
localeonActiveGridandLookupGridis required.useGridLocale()has no'en'default any more and throws a configuration error without a presentation.- A host translator (
t) is authoritative. A key it does not carry renders as the raw key; the former silent replacement by the English table (result === key) is gone. - The fetch, save, validation and cell-error fallbacks ask the same translator (they called the English table directly before).
Fixed#
- Ctrl+C of selected rows or of the focused cell copied values in English
notation whatever the grid's
locale(the cell-range copy was correct):useKeyboardNavigationread the presentation context from outside the provider the grid renders. The grid now passes itslocaleto the hook.
The scrollbar is the sidebar's, pixel for pixel (UI-SCROLLBAR-02, active-ui ADR-026 amendment)#
Changed#
- The scrollbar lane is built exactly like the house
ScrollAreabar:padding: 1px, a transparent 1px border on the side facing the content (border-leftvertical,border-tophorizontal) and aflex: 1thumb, replacing the symmetric 1.5px inset (--grid-scrollbar-insetis gone). The thumb therefore has the sidebar's width and position (7.36px at DPR 1.5625, 1px from the outer edge) instead of a 7px thumb centred in the lane. Measured side by side in UI Labscroll-area#case-grid-parity: lane, thumb, inset, corner, rest, hover and scrolling state identical, light and dark. - No change to the adapter, the lane width, the colours, virtualization or the
scroll math.
scrollbar-presentation.test.tspins the construction.
The scrollbar reads the platform contract (UI-SCROLLBAR-01, active-ui ADR-026)#
Changed#
--grid-scrollbar-thumb,--grid-scrollbar-thumb-hoverand--grid-scrollbar-trackdefault to the platform tokens--scrollbar-thumb,--scrollbar-thumb-hoverand--scrollbar-trackinstead of their own values. The resting colour is the same (--border).- The thumb now reacts under the pointer and while scrolling, with the
platform hover token. AG-SCROLL-01 kept it at rest only because the house
ScrollAreadid not react either; both now do.theme.scrollbarThumbHoverstill overrides it per grid. - Under
forced-colorsthe thumb isCanvasText; before, the browser replaced its background with the lane colour and it disappeared. - No change to the adapter, the lane, the geometry or virtualization.
scrollbar-presentation.test.tspins the aliases and the hover rule.
Grid fields carry the platform field focus (UI-FOCUS-01, active-ui ADR-022)#
Changed#
- Every grid field focuses like a form field: a border at 50% of
--ring, no ring, in light and dark. The popover filter inputs and selects (text, number, interval, date, operator) droppedfocus:border-primary focus:ring-2 focus:ring-primary/20; the date popover's text inputs dropped the same withring-1; the filter-builder fields droppedfocus-visible:ring-1 focus-visible:ring-ring. All of them take the internalFIELD_FOCUS_CLASS(components/primitives/field-focus.ts), which is bound to active-ui'sInput. - The inline filter bar is dimmed through active-ui's
--grid-filter-trigger-focus-border, nowcolor-mix(in oklab, var(--ring) 50%, transparent)(wasvar(--ring)). The deadfocus:border-primary/50 focus:bg-backgroundon the inline text and number inputs is gone; the grid CSS already won over them. - The toolbar search no longer adds
focus-visible:ring-1toInput, and the page-sizeSelectTriggerno longer carries a no-opfocus:ring-0.
No public API change. Buttons, cells and the grid container keep their focus.
Guarded by field-focus-contract.test.ts.
±Infinity is absent, like NaN (active-i18n F3b-9)#
Changed#
- A non-finite number is absent on every layer.
isAbsentCellValuenow treatsInfinityand-InfinitylikeNaN: the cell draws the placeholder instead of∞,isEmptymatches, the presentation export writes'', andsum/mean/min/maxskip the value (medianreturns no result). The DATA export was already empty. Walt's decision A2: the human formatters of active-i18n return''for a non-finite value, and no product means an infinite number. Supersedes the REVIEW-AG-004 rule that Infinity is data. The internaltoNonFiniteNumberpass-through in the presentation export is removed.
Date filter: a calendar day is not an instant (active-i18n F3b-3)#
Fixed#
- A calendar-day column no longer filters one day early west of UTC. The
client-mode date filter read every cell as an instant in the presentation's
zone, so a Prisma
@db.Dateor…T00:00:00.000Zvalue for 9 March matched 8 March on anAmerica/Curacaosurface. AsemanticType: 'date'column (and afilterType: 'date'column without semantics, asDateCellshows it) now compares calendar days without a zone. ADR-033. - The filter builder receives the presentation and each field's date semantics, which it did not before.
Changed#
- A
dateTimecolumn filter and the relative date presets require a time zone in the grid presentation and throw aTypeErrorwithout one, instead of reading the browser's day.DateFilterPreset.getValuedocuments this. evaluateDateFilter,evaluateFilter,advancedFilterFnandevaluateFilterExpressiontake an optional date-semantics argument.
SelectFilter: a declared selected state and reader-language search (DATYX-FINAL-013)#
Added#
- Every option row in the select filter declares its membership. An option
is a
<button>with a drawn check mark; the check is ansvg, so the state existed only for someone looking at it. Each option now carriesaria-pressed, andSelect allcarries the three-valued form (true/"mixed"/false) that matches the check, the dash and the empty box it already drew.aria-pressedand notrole="checkbox": the DOM is a button, its keyboard behaviour is already correct, and a role override would contradict it.
Changed#
- Option search follows the reader's language. It was
label.toLowerCase().includes(query), which does not findZürichforzuruck-style input and applies a case rule that differs per language. It now runs over anIntl.Collatorwithsensitivity: 'base'andusage: 'search'on the language the host already supplies throughGridPresentation(languageLocaleOf). No coupling toSearchComboboxwas introduced; the collator belongs to this module.
Unchanged, and now guarded#
- Empty is NO FILTER,
Select allwrites the explicit set rather than a wildcard,Clearreturns the state toundefined, and the stored identity remainsoption.valueand never the label. - The declared label map keeps winning over the derived one. A column that
declares
meta.filterOptionsshows those words; a column that declares none shows the value itself. The grid invents no human label -- nostartCase, no underscore replacement, no capitalisation rule. Re-measured forDATYX-FINAL-013: theString(key)label Codex saw occurs only where no label authority is declared, which is the contract rather than a defect.select-filter-option-contract.test.tsxnow fails if the derivation ever starts beating the declaration.
Row menus: destructive grouping, a live visible, and an honest export scope#
Changed#
- BREAKING (menu shape). A destructive action now sits in its own block.
The resolve pipeline adds a separator above the destructive tail when the menu
ends in a
variant: 'destructive'item and something precedes it. Measured on 2026-08-31 across the monorepo: of 43 destructive items, 15 carried that rule and 24 did not, while all 43 used the same shared translation key. A mixed lifecycle group stays whole -- the tail starts at the FIRST destructive item of the last segment, so delete / restore / delete-permanently is one block with one rule above it. - BREAKING.
Export allwas removed from the row menu, in every mode, andExport viewnow means the CURRENT paginated page. It previously readgetFilteredRowModel(): in client mode every filtered row across all pages (Export view (152)beside a paginator reading1 - 10 of 152), in server mode only the current page, becausemanualFilteringfalls that model back onto the page data. One label, two meanings.Export allpromised "all rows, ignoring filters" and, measured on a server grid of fifteen users with a filter active, wrote a file containing the seven FILTERED rows under the labelExport all (7). A full-dataset export is a separate product feature. - Infinite scroll carries its own label,
Export loaded rows (N), over the blocks already fetched. It never fetches missing blocks for an export. - The structure-preserving grouped export is no longer wired to the row
menu. It writes the whole group tree, which is not one page.
exportTo*GroupedandexportTo*ServerGroupedremain public API for programmatic use.
Fixed#
ContextMenuItem.visiblewas declared but never evaluated. Measured: 31 occurrences in 13 consumer files decided nothing, and three permission checks hung on it as their only client-side gate. It is now evaluated by the resolve pipeline, before separator normalization, so a group that filters away leaves no orphaned rule.- The pipeline now also runs at the render boundary, so a menu passed
directly as a
ContextMenuBuildergets the same treatment as one built bycreateContextMenu. 32 of the 139 consumer declarations never met the factory. meta.exportConfigset by a consumer was silently overwritten by the grid's own value;test-server-groupinghad carried a dead filename that way. Theexportprop is the contract.
Added#
ContextMenuItem.separatorBefore-- the explicit group boundary, same word and meaning asGridSelectionAction.separatorBeforeon the bulk bar.GridExportRowScope('page' | 'loaded'), declared by the grid onmeta.exportConfig.rowScope.
Removed#
ServerGroupingContextValue.exportConfig-- its only reader was the row menu's grouped-export branch, which is gone. Producers without readers are silent state.
TD-36 — enableColumnVirtualization bounds the mounted column window#
Fixed#
-
enableColumnVirtualizationdid not bound the mounted column window at all (TD-36). Measured in Chromium at 1600x900 on/test-column-virtualization: with the flag on, all 120 declared columns were mounted (1 200 cells, 4 835 DOM nodes) — exactly as many as with the flag off — and scrolling 6 000px horizontally moved no column in or out of the window. After the fix the same page mounts 16 columns, 160 cells and 686 DOM nodes, and the window follows the scroll.The cause was a single
useMemo.useColumnVirtualizerderived its result under[enabled, centerCount, virtualizer, leftPinnedCount, rightPinnedCount, scrollToColumnIndex], and all six are stable once the grid is mounted — the virtualizer instance most of all, because@workspace/active-virtualkeeps it referentially stable by design and publishes changes through a version counter. On the first render the scroll container does not exist yet (it arrives through a callback ref), so the virtualizer measured a zero viewport, returned an empty window, and the memo cached the safe "render every column" branch. Nothing in the dependency list ever changed again, so that first answer stood forever — including after the measurement landed, and on every scroll after that.The window is now read during render, exactly as the row side has always done in
body-virtual.tsxand exactly asactive-virtualdocuments. Referential stability — which threeReact.memoboundaries depend on — comes from a value signature over the window (indices, both spacers, total width), never from the instance. There are no extra renders: the subscription already re-rendered the grid on every window change; the result was simply discarded. See ADR-030. -
scrollToColumnIndexwas alwaysundefined, so keyboard navigation had no horizontal scroll-to-focus path. It only appeared in the branch the memo never reached. It works now; until this release it was masked by the fact that every column happened to be mounted.
Changed#
-
Autosize keeps measuring real cell content under column virtualization.
measureCellsWidthclones actual cells, and a column outside the window has none — it would have been sized on its header alone. The grid now suspends the column window for the duration of a measurement pass (autoSizeOnMount,autoSizeAllColumns, the single-column menu item) instead of narrowing the measurement. One pass per public entry point, not one per column, and the suspension is lifted in afinallyso no state can be left behind. Grids without column virtualization are untouched: no runner, no extra render. -
enableColumnVirtualizationwithheight="auto"now dev-warns. The prop has always documented a fixed height as a requirement; nothing enforced or said it. This is a warning in the shape of the other configuration warnings — no default changes, nothing is overridden and nothing is clamped. -
The browser benchmark treats ineffective column virtualization as a scenario integrity failure. The round fails when the flag mounts as many columns as the un-virtualized branch, when a 6 000px scroll moves no column in or out, or when header and body do not carry the same columns — checked on the column ids, because two equal counts prove nothing about which columns they are. The cross-check between the two branches was
on === offwhile TD-36 stood; it is nowon < offon columns, cells and DOM nodes. -
censusGridreportsmountedHeaderColumnIdsnext to the body window, so header/body drift is observable at all. Placeholder headers are excluded. -
The Playwright-side census helpers are shared.
waitForStableCensus,nextFrameandsetScrollmoved from the benchmark file toe2e/helpers/grid-census.ts; the benchmark and the newe2e/column-virtualization.spec.tsnow use one readiness contract instead of two copies of a settle window.
Added#
-
e2e/column-virtualization.spec.ts— 14 browser tests covering the product contract that jsdom cannot show: a bounded window with the flag on, every column with the flag off, strictly fewer columns/cells/DOM nodes than the un-virtualized grid, an unchanged horizontal scroll range,aria-colcountstaying the declared total whilearia-colindexstays absolute, columns entering and leaving on scroll, header and body on the same window, pinned columns surviving every scroll position, a valid window after grouping a column away, keyboard navigation reaching a column outside the first window, a cell outside the first window being reachable, and every column mounting for the duration of an autosize pass. -
src/hooks/use-column-virtualizer.test.tsx— 17 tests on the reactive chain itself, including the transition from the initial empty window to a live one, three different windows in a row, referential stability across renders that change nothing, and the shape ofColumnVirtualizationResult(the one maintenance trap of the signature approach).
R-26 — the benchmark harness#
Changed#
-
The benchmark harness was rebuilt (R-26, phases B1-B4). No product code changed; the grid behaves exactly as in 10.2.1. Phase B5, the freeze itself, ran on 2026-09-21 and is recorded above; the measured baseline is in
PERFORMANCE.md§ De bevroren ronde van 2026-09-21.pnpm benchmarkran no scenario at all.vitest.benchmark.tslacked theserver-onlyalias thatvitest.config.tshad, so the import chain ofsrc/components/grid.tsxfailed with "This module cannot be imported from a Client Component module". Both configurations now take their alias from one source (vitest.shared.ts), and a seconds-long smoke test (pnpm benchmark:smoke) loads that chain under the benchmark configuration itself.- A normal run overwrote the frozen baseline. The default output path was
the file the documentation called the baseline. A run now writes to the
ignored
benchmarks/local/, andbenchmarks/baseline.jsonhas exactly one writer:pnpm benchmark:freeze, which requires an explicit confirmation, refuses a round that failed its machine preflight, writes atomically and re-reads and re-validates its own candidate before replacing anything. - Three scenarios never reached their intended code path. Grouping-expand
looked for
button[title="Expand"](the row-detail expander, not the group toggle), found nothing, and a silentifpublishedexpandLatencyMs: 0. Editing looked fordata-column-id, an attribute that does not exist, and a broad fallback selected the first gridcell of row 0. The column-virtualization pair mounted identical DOM in both branches and was still presented as virtualization evidence. All three are fixed, and there is noif (element) { measure } else { report 0 }left anywhere in the suite. - The statistics now carry the conclusion. Three warm-ups instead of one, N=15 instead of 5, raw samples kept, and median/p95/stddev/coefficient of variation/minimum detectable effect reported per metric. Counters are observed 15 times and must be exactly equal.
- Counters are the gate; timings are a trend. The universal
+5%rule is withdrawn: measured coefficients of variation ran to 68% on timings and past 800% on memory, so that threshold fired on noise while being far too loose fordomNodes. A timing difference never produces a non-zero exit code. benchmarks/was never typechecked. It sat outsidetsconfig.json. Adding it surfaced that every client scenario passed anenableVirtualizationprop that does not exist onActiveGridand was silently ignored: row virtualization is an automatic decision abovevirtualizationThreshold, not an opt-in.- Memory is measured for real or not at all.
forceGc()was a no-op because Vitest starts its workers without--expose-gc; the published negative heap deltas of hundreds of megabytes were an artefact. The harness now resolvesgc()throughv8.setFlagsFromStringplusvm.runInNewContext, verifies it by calling it, and recordsmemoryPolicy: "trend"or"omitted"accordingly.
Added#
- A browser benchmark round (
pnpm benchmark:browser) under the existing Playwright harness, in its own configuration withretries: 0and its own**/*.bench.tsdiscovery, so the ordinary e2e suite never picks it up. Six scenarios measure what only a real viewport can show: the mounted column window with the flag on and off, horizontal scroll, the row-virtualization latch, and a grouped grid collapsed versus genuinely expanded. It shares the 10.2.1 layout-readiness contract rather than duplicating it, and adds one new shared browser-side contract (e2e/helpers/grid-census-contract.ts) with its own jsdom unit tests. - A validated result and baseline format with scenario fingerprints,
integrity evidence, full environment metadata, an explicit memory policy and
a stated list of limitations; plus
pnpm benchmark:compare, which reads and never writes.
Fixed#
vitest.benchmark.tsno longer uses thepoolOptionsshape Vitest 4 removed.
Known issue found by this work#
-
enableColumnVirtualizationdoes not bound the mounted column window (TD-36). Measured in Chromium at 1600x900 on/test-column-virtualization: with the flag on, all 120 declared columns are mounted (1 200 cells, 4 835 DOM nodes) — exactly as many as with the flag off — and scrolling 6 000px horizontally moves no column in or out of the window. Row virtualization is unaffected and demonstrably works. This is measured, not fixed: changing product behaviour to make a benchmark pass was an explicit non-goal.Fixed separately in 10.2.2 (see above and ADR-030). The finding stands as written: it is what this round measured, and the round is the reason the defect was found at all.
[10.2.1] - 2026-08-28 - De layoutwacht van het e2e-harnas wacht op een signaal, niet op een venster#
Harnasreparatie. Geen productcode gewijzigd, geen publieke API geraakt; de grid gedraagt zich exact zoals in 10.2.0.
Fixed#
-
waitForGridLayoutSettledaccepteerde een layout die nog niet BEGONNEN was te bewegen. De wacht toetste één ding: de geometriesignature ongewijzigd gedurendestableForMs(250 ms). Dat onderscheidt "beweegt nog niet" niet van "beweegt niet meer", en op/test-showcase-gridliggen die twee milliseconden uit elkaar. Gemeten over zeven mounts, elke keer identiek:fase venster stil toestand pre-meting ~7-265 ms 247-265 ms header 8px breder dan het lichaam, --grid-op de stylesheetstandaard van 8px, inline vars leegedge- clearance tussenstand ~350-430 ms 61-78 ms vars gepubliceerd, totaal nog niet definitief definitief vanaf ~465 ms stabiel header en lichaam komen overeen Het venster van 250 ms lag middenin de spreiding van het plateau dat het moest uitsluiten. Landde een run in fase 1, dan las
grid-content-geometry.spec.tseen header die 8px breder was dan zijn lichaam (gemeten1876tegen1868, twee keer identiek) en druktecolumn-resize.spec.tswaar de handle straks niet meer zou zijn. Dat is de flake die over de 10.1.2-runs werd gemeld; gemeten faalpercentage op de gerapporteerde test: 2 fouten in 23 runs.De wacht stelt nu eerst POSITIEF vast dat het product zijn geometrie heeft gepubliceerd --
--grid-total-widthen--grid-edge-clearanceals inline stijl op zowel de body-scrollcontainer als de headerwrapper -- en meet daarna pas stabiliteit. Alle vier zijn gemeten leeg gedurende fase 1 en gezet vanaf fase 2, dus fase 1 is per constructie onbereikbaar. Het venster van 250 ms blijft ongewijzigd: de reparatie is een signaal, geen langer getal.Na de reparatie: 85 runs van de reproductievorm zonder fout (was 2 fouten in 6 runs), en 10 van 10 directe metingen op de definitieve geometrie (was 1 van 8 in de tussenstand).
-
De timeoutmelding scheidt de twee faalwijzen. Liep de wacht af, dan stond er "waitForFunction timed out" en verder niets -- terwijl "het product komt niet op gang" en "het product blijft herlayouten" verschillende problemen zijn. De melding noemt nu de gereedheid, de vier inline properties, het aantal headercellen, hoe lang de signature al stilstaat en welke van de twee voorwaarden het niet haalde.
Changed#
-
Eén settlecontract in plaats van drie.
resize-handle-scroll-preservation.spec.tshad een eigensettleGrid()-- het enige dat de gereedheidshelft al eiste -- en gebruikt nu de gedeelde helper, die er positie én een venster aan toevoegt.last-column-clearance.spec.tsdroeg dertien vaste waits: elf wachtten op layout na een navigatie, een style-injectie of een pin en zijn de gedeelde helper geworden; de andere twee paceden de wielloop inscrollToEnden zijn een poll op de UITKOMST geworden (de scroller staat aan zijn einde en de header is gevolgd). Er staat geenpage.waitForTimeoutmeer in de suite. -
horizontal-scroll-sync.spec.tswacht op het gedeelde contract inbeforeEach. De synchronisatieasserties zelf zijn relationeel en hielden in elke fase; wat op toeval rustte was de premisse -- de eenmalige "er is horizontale overflow"-controle draaide vóór publicatie en slaagde alleen omdat het pre-meettotaal van 1868px de viewport van 1024px óók overschrijdt. DetoPass-lus om de scrollinteractie is bewust NIET vervangen: een tweede polling-laag zou alleen vertroebelen welke van de twee faalde. -
De tolerantie-asymmetrie in
grid-content-geometry.spec.tsis verklaard en behouden.TD-34vergelijkt een som van vijftien FRACTIONELE celbreedtes met één geheel getal: sub-pixelophoping is daar echt en 1px is de kleinste tolerantie die haar toelaat.TD-35vergelijkt twee GEHELE getallen uit dezelfde layout die volgens het contract dezelfde ruimte beschrijven: daar bestaat geen ophoping, elk verschil is structureel, en exacte gelijkheid is de juiste vorm. Beide testnamen dragen dat nu, met de grond in de code. De TD-35-invariant is niet versoepeld. -
De paginatekst werd buiten de paginator gezocht. De vier
Pagina N van-asserties invirtualization-scroll.spec.tszochten die tekst overal onder de gridwrapper, en de grid draagt daar ook eenaria-live-statusregio die een paginawissel 150 ms na de klik aankondigt en haar tekst daarna nooit wist. Na een navigatie heen en terug staat dezelfde zin twee keer in de boom -- sr-only en zichtbaar -- en dan valt de assertie om op een strict-mode-schending; of dat gebeurde hing af van de vraag of de assertie die 150 ms voor was. Gevonden in de volledige verificatierun van deze ronde. De selector noemt nu de paginator. Testfout, geen productfout: een statusregio die de huidige pagina aankondigt is precies goed.
Added#
-
e2e/helpers/grid-layout-contract.ts-- de browserkant als één pure, zelfstandige functie. Zelfstandig omdat Playwright alleen de functiebody serialiseert en een geïmporteerde hulpfunctie in de browserundefinedis; en juist daardoor in jsdom aanroepbaar, zodat de unittests EXACT de functie draaien die de browser uitvoert in plaats van een meelopende kopie. -
19 unittests (
grid-layout-contract.test.ts) met een gecontroleerde klok (performance.nowonder een spy), dus zonder één echte wachttijd: een lang stil maar ongepubliceerd plateau wordt geweigerd, het venster begint pas bij publicatie, de gemeten tussenstand van 78 ms haalt het niet, een definitief stabiele toestand slaagt, het rapport blijft diagnostisch, en twee grids op één pagina delen geen venster.vitest.config.tsdraait ze mee;tsconfig.jsontypecheckt de twee bestanden.
[10.2.0] - 2026-08-28 - Elk publiek GridConfig-veld doet werkelijk iets#
Drie velden van GridConfig waren gedeclareerd, hadden een standaardwaarde,
werden door de merge doorgegeven en stonden in docs/api/columns.md -- inclusief
een providervoorbeeld dat consumenten aanmoedigde ze te zetten -- terwijl geen
enkel runtimepad ze las. Ze zijn nu alle drie aangesloten, met een wachter die
voorkomt dat er ooit een vierde bij komt.
Publieke GEDRAGSUITBREIDING, geen breuk in de aanroep: wie de velden niet zet merkt niets, en geen enkele consument in deze monorepo zet ze. Wie ze wél al zette kreeg tot nu toe stil de pakketstandaard en krijgt vanaf nu wat hij vroeg -- zie Migration onderaan.
Fixed#
-
pagination.pageSizeis de globale startpaginagrootte. De keten is nuURL / persisted → initialPageSize → GridConfig.pagination.pageSize → 10, met de arbitrage tussen URL en localStorage (statePriority) ongewijzigd. Dat de prop van de config wint is niet nieuw bedacht: het is de vorm die élk levendGridConfig-veld al had. De waarde wordt uitsluitend in het deterministische zaad van de store gelezen, dus SSR blijft deterministisch, de eerste render draagt de maat al, en een provider-rerender reset een lopende grid niet.Een aanwezige maar ongeldige waarde (nul, negatief, gebroken,
NaN) wordt GEWEIGERD en niet geklemd of afgerond, en telt daarna als AFWEZIG: de keten gaat door naar de volgende bron. Een tikfout in één grid slaat de bewust ingestelde platformwaarde dus niet weg. Een maat buitenpageSizeOptionsblijft toegestaan -- die lijst valideert URL-invoer, niet de configuratie van de ontwikkelaar. -
export.csvSeparatorbepaalt werkelijk het scheidingsteken. Het type is aangescherpt vanstringnaarCsvDelimiter: precies één teken uit,;\t|, een POSITIEVE lijst die door het type gesloten is en op runtime opnieuw wordt getoetst, omdat een providerconfiguratie uit gewoon JavaScript kan komen. Een verbodslijst zou een spatie of een letter doorlaten, en een CSV metaals scheidingsteken ziet er geldig uit tot de eerste waarde die de letter bevat.De quoting hangt NIET van het teken af: elk CSV-veld is onvoorwaardelijk gequote, dus een waarde die het scheidingsteken, een aanhalingsteken of een regelovergang bevat is per constructie al veilig. De formule-injectiebescherming staat onveranderd op dezelfde grens, en het TD-22-gedrag rond een leidende plus is niet geraakt. De klembord-TSV deelt bewust geen configuratie met de CSV-export: een plak is een tab.
-
filtering.operatorPresetsis een werkend configuratiecontract.filter-utilslas de moduleconstante rechtstreeks en ging om de config heen; de effectieve presets worden nu door de filtercomponenten doorgegeven, zodat de pure filterlaag geen React-context importeert. Een provider mag ingebouwde presets overschrijven en eigen presets declareren; de map wordt PER SLEUTEL gemerged, zodat één preset toevoegen de ingebouwde niet wegneemt.meta.operatorPresetisBuiltInOperatorPreset | (string & {}): de ingebouwde namen houden autocomplete, een eigen naam is een gewone tekenreeks, en de buurvelden (filterType,allowedOperators) blijven gesloten unions. Een preset kan uitsluitend VERSMALLEN, dus een eigen preset kan nooit een operator activeren die de servercapability verbiedt. Vier foutgevallen zijn expliciet en dev-gewaarschuwd: een onbekende naam (geen versmalling), een ongeldige operatornaam (die ene wordt gedropt), een dubbele operator (ontdubbeld, eerste plaats behouden) en een lege preset (geweigerd -- een lege versmalling laat een filter zonder keuze achter). -
withEmptycompileert. De docblock intypes/filters.tsnoemdemeta.operatorPreset: 'withEmpty'als de gedocumenteerde opt-in, terwijl de union van vijf namen hem niet toestond. De union wordt nu AFGELEID uitOPERATOR_PRESETS, dus de twee kunnen niet meer uiteenlopen. -
Vier verouderde standaarden en een ontbrekende sectie in
docs/api/columns.md:layout.rowHeight40 → 42,layout.headerHeight36 → 48,layout.filterBarHeight32 → 36,columns.minWidth100 → 120, en deui-sectie ontbrak volledig. Alle overige standaarden zijn tegenDEFAULT_GRID_CONFIGgecontroleerd en kloppen.
Changed#
-
Een geneste
GridConfigProviderlegt zijn overrides over de EFFECTIEVE configuratie van de boom waarin hij staat, niet over de kale pakketstandaarden. De documentatie zei al "wrap your app (or a section)", en een sectie die één ding bijstelt hoorde niet stil alles kwijt te raken wat de app-provider erboven had gezet. Voor de buitenste provider verandert er niets. -
GridConfig.export.csvSeparatoris versmald vanstringnaarCsvDelimiter. Type-technisch een versmalling op een publiek veld; runtime onmogelijk te merken, want het veld deed niets. Gemeten aantal consumenten dat het zet: nul. -
De publieke configtypen tellen mee in
lint:api-drift.GridPaginationDefaultsstond als "internal engine" op de uitsluitingslijst en de zeven zustervormen onder een regex als "implementation detail", terwijl ze uitsrc/index.tsworden geëxporteerd. Die uitsluiting was precies de reden dat de enige controle die naar deze vorm keek, er juist niet naar keek.ActiveGridUIConfig,ActiveGridMenuConfig,CsvDelimiterenBuiltInOperatorPresetzijn erbij geëxporteerd en gedocumenteerd.
Added#
-
Een wachter op dode publieke configvelden (
config/__tests__/config-liveness.test.ts). Hij inventariseert elk blad vanDEFAULT_GRID_CONFIGen eist per blad een CONFIG-GEWORTELDE lezing: de leesplek moet beginnen bij een identifier waarvan in datzelfde bestand blijkt dat hij de gridconfiguratie draagt. Een naïeve zoektocht op de veldnaam gaat hier aantoonbaar vals groen --gridState.pagination?.pageSizeis engine-state -- en dat geval staat als test in de suite. Twee registers dragen de uitzonderingen, elk met een verplichte reden; het non-goal-register is leeg en hoort dat te blijven. De analyse is een pure functie, zodat de laatste beschrijving haar met een verzonnen dood veld voedt: een wachter die zelf nooit rood is geweest bewijst niets. -
Tests voor
mergeConfigenGridConfigProvider, die tot nu toe volledig ontbraken: gedeeltelijke secties, mergediepte, de per-sleutel-uitzondering, nesting, isolatie tussen bomen, en dat een providerDEFAULT_GRID_CONFIGniet muteert. -
Een documentatiecontract-test die elke gedocumenteerde standaard in
docs/api/columns.mdtegenDEFAULT_GRID_CONFIGlegt en die eist dat elk publiek configtype in het document genoemd wordt. Dit is de regressie op de vier verouderde getallen en op de drift-uitsluiting. -
Een type-regressietest (
types/__tests__/operator-preset-typing.test.ts) die met@ts-expect-errorvastlegt dat de vrije presetnaam zijn buurvelden niet verbreedt. -
FILTER_OPERATORSenisFilterOperatorintypes/filters.ts: het operatorvocabulaire als runtimewaarde, gesloten door het type, nodig zodra een operatornaam van buiten TypeScript binnenkomt.
Migration#
Geen enkele consument hoeft iets te doen. Twee gevallen verdienen aandacht:
- Wie
pagination: {pageSize: N}ofexport: {csvSeparator: X}al in eenGridConfigProviderzette, kreeg tot nu toe stil de pakketstandaard (10, respectievelijk een komma) en krijgt vanaf 10.2.0 de waarde die er staat. Als die waarde ooit als decoratie is neergezet, is dit het moment om haar te controleren. - Wie een geneste
GridConfigProvidergebruikt om een sectie bij te stellen, erft vanaf nu de instellingen van de provider erboven in plaats van terug te vallen op de pakketstandaarden.
csvSeparator accepteert type-technisch alleen nog , ; \t |. Een andere
waarde was al zonder effect, dus er is geen gedrag dat verandert -- alleen een
compilerfout die er eerder niet was.
Internal#
-
@workspace/active-utilskreeg een optionele delimiterparameter opcsvRow(standaard komma) plusDEFAULT_CSV_DELIMITERen een eigen testsuite. De helper blijft dom: welke scheidingstekens zijn toegestaan is een productvraag, en die woont inactive-grid/src/config/csv-delimiter.ts. Geen gekopieerde serializer, geen wrapper. Zie de CHANGELOG van dat pakket. -
De drie CSV-exportfuncties kregen een optionele trailing
csvDelimiter- parameter. Bewust geen veld opGridExportRunOptions: die opties gelden voor élk formaat, en een scheidingsteken zegt niets over JSON, XLSX of PDF.
[10.1.3] - 2026-08-28 - initialPageSize is de startwaarde, ook zonder bewaarde stand#
initialPageSize deed niets op precies de grid waarvoor het woord "initial"
staat: een verse grid, zonder gedeelde link en zonder bewaarde voorkeur. De prop
werkte alleen daar waar zij per definitie verliest -- naast een URL- of
localStorage-stand die haar overschrijft.
Interne bugfix plus de bijbehorende documentatie. Geen nieuwe publieke prop, geen gewijzigde URL-sleutel, geen nieuwe opslagversie, en geen enkele consument in deze monorepo verandert van gedrag: geen van hen geeft de prop mee.
Fixed#
-
De startstand van de store draagt de prop. De store werd gebouwd met
getInitialGridState(allFeatures, {})-- de kale featurestandaard,{pageIndex: 0, pageSize: 10}-- eninitialPageSizebereikte de state uitsluitend via de pagineringstak vanloadInitialGridState. Die tak draait alleen wanneer de URL of localStorage al een pagineringsstand DRAAGT. Zwegen beide, dan raakte de prop de state nooit aan. Zij wordt nu in het zaad van de store gezet, in deuseState-initializer, en de hydratie legt daar alleen EXPLICIETE externe stand overheen.Het zaad is deterministisch -- de prop is op de server net zo bekend als in de browser -- dus dit verandert niets aan het SSR-gedrag en er is geen zichtbare sprong van 10 naar de ingestelde maat: de eerste render draagt haar al. De reparatie zit bewust niet in een effect, een
setTimeoutof een tweede layout-effect, om precies die sprong niet te introduceren. -
De precedentie staat nu opgeschreven en is getoetst: een expliciete geldige URL-stand, dan een expliciete geldige bewaarde stand (die twee onderling geordend door
statePriority), daninitialPageSize, dan de pakketstandaard 10. De prop overschrijft dus nooit wat een gebruiker zelf koos of deelde, en een latere render met een andere waarde reset een lopende grid niet: de startstand wordt één keer bepaald. -
Een ongeldige
initialPageSizewordt geweigerd, niet gerepareerd. Nul, negatief, gebroken ofNaNlevert de pakketstandaard plus een dev-waarschuwing die de grid noemt -- niet een klemming naar 1 en niet een afronding, want beide veranderen een tikfout in een werkende grid. Dezelfde discipline als de per-slice-validatie van bewaarde stand. Er is bewust GEEN toets tegenpageSizeOptions: die lijst valideert URL-invoer, en een maat buiten de lijst toont de paginator al bij.
Internal#
-
Vier losse literals
10zijn één autoriteit geworden.DEFAULT_PAGE_SIZEinengine/features/row-pagination/rowPaginationFeature.utils.tsis de enige plek waar dat getal staat; de featurestandaard, de nieuwe resolver (state/initial-pagination.ts), de URL-serializer die de standaard juist WEGLAAT, de terugval van de paginator enDEFAULT_GRID_CONFIG.paginationlezen haar. Geen gedragswijziging -- vier defaults die onafhankelijk konden verlopen zijn er nu één. -
Nieuwe module
state/initial-pagination.tsmetresolveInitialPaginationState(),resolveInitialPageSize()enisValidInitialPageSize(). Intern; niet uit een barrel geëxporteerd. -
Nieuwe suites:
state/initial-pagination.test.ts(26),hooks/use-grid-state.initial-page-size.test.ts(22, door de ECHTE hydratielaag heen, inclusief de vierstatePriority-standen) encomponents/__tests__/initial-page-size-contract.test.tsx(6, op de gemonteerde grid, met een render-opnemer voor het flikkerbewijs).state/load-initial-state.test.tskreeg drie gevallen voor de samengestelde URL-slice erbij.
[10.1.2] - 2026-08-28 - Een toets hoort bij het element dat hem kreeg#
Enter activeerde geen enkel besturingselement binnen een raster. De wisknop
van een actief kolomfilter deed niets, het filtermenu ging niet open, het
kolommenu evenmin, en de caret in een filterveld bewoog niet naar rechts --
terwijl Spatie en de muis wel werkten. In 10.1.1 stond bij dat gedrag de
aantekening dat het buiten dit package lag. Dat was onjuist: het lag hier, in
use-keyboard-navigation.
Interne bugfix. Geen publieke API-wijziging, geen nieuwe prop, geen consumer-migratie, en geen wijziging in enige app.
Fixed#
-
De INGANG van het raster geldt alleen voor de eigen tabstop van de container.
containerProps.onKeyDownbestaat voor één ding: staat de focus op de gridcontainer en draagt nog geen cel focus, dan brengtArrowDown,ArrowRightofEnterde focus naar de eerste cel. Die voorwaarde stond inguides/keyboard.mdmaar niet in de code. Zonder haar annuleert de handlerEntervoor élke toets die via de React-boom bij de container aankomt -- en de container omsluit de héle grid, inclusief de headerrijen, de filterrij en (via de React-boom, niet de DOM) elke popover die vanuit de grid door een portal wordt gerenderd.De grens is nu
e.target === e.currentTarget, plus een uitzondering voor Ctrl, Meta en Alt. Er is bewust GEEN lijst van knoppen, links, invoervelden, contenteditables of interactieve rollen: alles wat niet de container zelf is, hoort bij iets anders, en een onbekend doel valt daarmee per constructie aan de veilige kant.onFocusop diezelfde container trok die grens al (AG-06);onKeyDowndeed het niet. -
EnterenSpatieop een besturingselement binnen een BODYcel zijn van dat besturingselement. Het tweede pad, inhandleKeyDown: zodra de action mode een knop of link binnen de cel focus had gegeven, annuleerde de cel de volgendeEnterenSpatiealsnog. Gemeten op/test-showcase-gridmet de focus op de uitklapknop van een rij: nul clicks, op beide toetsen. Spatie ontsnapte hier niet, want een geannuleerde keydown haalt ook de keyup-activering van een<button>weg.Dezelfde grens, dezelfde grond.
Escape,Taben de pijltoetsen blijven van het raster: die dragen de action mode zelf. -
De gevolgen die dit had. Per grid-instantie was het EENMALIG en daarmee moeilijk te reproduceren: de ingang zette bij haar annulering
focusedCellop (0,0), waarna de volgende Enter wel doorkwam. Zij verplaatste bovendien de focus, dus een toetsenbordgebruiker verloor zijn plek in de tabvolgorde.ArrowRightstond in dezelfde lijst, dus ook de caret in een filterveld bleef staan. Het raakte elk raster in elke app, en niet één schil.
Changed#
-
De release-notitie van 10.1.1 is gecorrigeerd (zie hieronder). Drie waarnemingen leidden destijds de verkeerde kant op, en zij staan bij die release toegelicht zodat de conclusie niet nog eens getrokken wordt.
-
De e2e-suite toetst de wisknop nu met Enter, met Spatie en de muis ernaast; de omweg via alleen Spatie is opgeheven. De unittesthelper van
use-keyboard-navigationmodelleerttargetencurrentTargetvoortaan apart -- een helper die er één van maakte, kon het verschil niet uitdrukken en liet dit defect groen.
[10.1.1] - 2026-08-27 - De waarde wijkt, de bediening nooit#
Een filter met een actieve waarde kon zijn eigen wisknop uit de cel duwen. Op
/media?view=list stond in een kolom van 130px 2 geselecteerd, en de X was
onvindbaar: gedeeltelijk of volledig weggeknipt, of getekend bovenop de laatste
tekens van de waarde. Het filter was daarmee niet meer op te heffen zonder eerst
de kolom breder te slepen.
Interne bugfix. Geen publieke API-wijziging, geen consumer-migratie, geen nieuwe
prop -- een consumer hoeft hiervoor niets te declareren en geen eigen minWidth
of workaround toe te voegen.
Fixed#
-
De filtercel heeft één anatomie, en die is nu een component. Alle zes de ingebouwde filters (
text,number,date,select,boolean,interval) droegen byte-identieke, zes keer gekopieerde markup voor de wortel, het waardegebied, de wisknop en de trechterknop. Zij delen nuFilterCellShell(intern), met het contract de waarde wijkt, de bediening nooit:Text[ waarde / invoer ][ wissen 24px ][ menu 24px ] min-w-0, krimpt tot nul shrink-0 shrink-0 -
De hoofdoorzaak: het waardegebied miste
min-w-0. Daardoor was zijn minimumbreedte de MIN-CONTENT van de waardetekst -- voor eentruncate-span is datwhite-space: nowrap, dus de VOLLE tekstbreedte -- entruncatekwam nooit aan bod. Gemeten: een waardegebied met2 geselecteerdweigerde onder 99px te krimpen, waarna de filterwortel breder werd dan de cel en.grid-header-cell(overflow: hidden) de bediening wegknipte. Alleen de span-gebaseerde filters (select,date,boolean) konden zo overlopen; een<input>schaalt nooit mee met zijn waarde. -
De wisknop is een flex-BROER in plaats van een absolute overlay. De oude
absolute right-1had geen gereserveerde ruimte naast zich, dus de X werd bovenop de waarde getekend: aanwezig, aanklikbaar, en visueel onvindbaar. Een broer kan per constructie niet overlappen, en hij volgt bovendien de schrijfrichting in plaats vanrighthard te coderen. -
Een filterbare kolom krijgt een effectieve minimumbreedte van 80px, zodat de bediening bij élke toegestane breedte volledig binnen de cel valt. Afgeleid en niet gekozen: 2x14 celpadding (
--grid-cell-padding-x, de coarse-pointer waarde) + 2x24 doelvlak + 2x2 gap. De vloer beschermt UITSLUITEND de bediening en houdt vertaalde waarden bewust niet leesbaar --2 seleccionado(s)is 15px breder dan2 geselecteerden de telling erin is dynamisch, dus een vloer die tekst garandeert bestaat niet. De vloer VERHOOGT alleen: een kolom met een ruimere eigen of grid-bredeminSizeblijft ongemoeid, en metsettings.showFilterRow: falsegeldt hij niet. -
Toegankelijkheid van de wisknop. Drie zelfstandige defecten die op ELKE kolombreedte golden, ook op een brede: de knop droeg
tabIndex={-1}en was daarmee op geen enkele breedte met het toetsenbord te bereiken; hij had geen toegankelijke naam; en zijn doelvlak was zo groot als zijn pictogram (12x12). Hij is nu focusbaar, heetFilter <kolom> wissen(nieuwe sleutelgrid.filter.clearFilterFor, in alle vijf de beschikbare talen), en meet 24x24 -- WCAG 2.2 SC 2.5.8 Target Size (Minimum), AA. De trechterknop kreeg hetzelfde doelvlak en een zichtbare focusring. -
De kolomnavigatie laat de header met rust. In
handleKeyDownkwam een vroege return voor toetsen uit.grid-header-wrapper. Correctie in 10.1.2: die wachter is ONBEREIKBAAR.getCellPropswordt uitsluitend op BODYrijen toegepast, dus een headertoets komt daar niet langs. Hij staat er sindsdien als vangnet, met die vaststelling erbij.
Bekende beperking (buiten dit package) -- INGETROKKEN in 10.1.2#
Deze notitie was onjuist. De oorzaak lag wél in dit package, in
use-keyboard-navigation. Zie 10.1.2. De oorspronkelijke tekst blijft hier staan met haar weerlegging, want de drie waarnemingen eronder zijn stuk voor stuk plausibel en leiden alle drie de verkeerde kant op.
De oorspronkelijke bewering luidde dat Enter de wisknop niet activeerde in de
admin-schil terwijl Spatie en de muis dat wel deden, en dat dit aantoonbaar
buiten @workspace/active-grid lag. Drie waarnemingen droegen die conclusie, en
zo staan zij ervoor:
- "Een IDENTIEKE gekloonde DOM-knop in dezelfde filtercel activeert wél op
Enter." Vals negatief.
cloneNodekopieert geen React-fiber, dus React vindt voor die knop geen doelinstantie en voert de handlers van de React-voorouders helemaal niet uit. De proef kan alleen NATIVE luisteraars uitsluiten, nooit gedelegeerde. - "Met de kolomnavigatie volledig uitgeschakeld blijft Enter onderdrukt."
Kan niet kloppen.
enableKeyboardNavigation: falsezet zowelenabledals het spreiden vancontainerPropsuit; dan kan deze handler niet draaien. - "Zij wordt pas op DOCUMENT-niveau geannuleerd, dus door een handler die niet
op de DOM-keten van de grid staat." De premisse klopt, de gevolgtrekking
niet. In de Next.js App Router hydrateert React het hele document, dus élke
React-
onKeyDowndraait daar -- ongeacht waar hij in de boom hangt.
De vierde bewering, dat het "elke gewone knop in die schil" raakte, was ook onjuist: buiten een raster werkte Enter gewoon.
[10.1.0] - 2026-08-27 - The grid owns the bulk bar, and an action says what it can act on#
Two additions in one release, and they answer the same question from opposite ends: what may a reader select, and what happens when they act on it.
The grid now draws its own selection action bar -- [N selected] [Actions v],
one shape whatever the number of actions -- and an action declares whether it
can execute a DATASET-WIDE selection. That second half closes a real gap: a
grid could offer "select all 5000 rows" beside an action that only accepts
explicit ids, and every measured bulk action in this repository accepts
explicit ids. Measured on /media: the banner said 32 selected, the bulk bar
said 10, and the delete removed 10.
Alongside that, the exclusive selection model now carries the QUERY that gives
it meaning. Minor and not major: everything is additive, and a grid that
declares no selectionActions behaves exactly as it did.
Added#
-
selectionActions-- the declarative bulk-action contract (ADR-026). A non-empty array plusenableRowSelectionmakes the grid render its selection action bar above the body. The grid owns the bar, the count, the fixed Actions menu, keyboard behaviour, the default and destructive presentations, and the busy state -- an async handler holds the whole menu until it settles, so one action cannot be started twice and two cannot overlap. The consumer owns which actions exist, their labels and what they do.The shape never varies with the number of actions. One action lives in the same menu as five. There is no
ReactNodeslot: a free slot would let every consumer design its own bulk bar again, which is the divergence this contract replaces. There is no "Clear selection" item either -- the header checkbox already does that -- and no disabled item standing in for a missing permission, which is expressed as absence. -
GridSelectionAction.supportsDatasetWideSelection(defaulttrue). Whether an action can execute anexclusiveselection. It works in both directions: an action that cannot is left OUT of the menu while such a selection is active, and when NO declared action can, the grid does not offer dataset-wide select-all in the first place. An invitation into a state where nothing works is worse than no invitation. -
SelectionQueryon the exclusive selection model. "Everything except these ids" is meaningless without the query that produced it, so the model now carries it: the grid's ownfiltersandsearch, plus the consumer'sserverQueryKeyasexternalKey. Carried, never interpreted. A mutation must still re-derive and re-authorize all of it server-side and must never compute its effect fromtotalCount, which is a display number that arrived from the client. Seeguides/selection.md§ Executing an exclusive selection.
Changed#
- The footer stops repeating the selection count while the selection action
bar is showing it. Both rendered the identical string, one selection reading
as two facts. Only grids that declare
selectionActionsare affected; the footer counter is unchanged everywhere else.
Fixed#
-
A server-mode grid could stall on its first load and never ask again (R-32, ADR-025, 2026-08-27). The fetch-trigger effect owned a cleanup that aborted the in-flight request whenever one of its dependencies moved.
debouncedPageIndexis such a dependency, and a page change does not invalidate the dataset — so the invalidation token stayed equal, the re-fired effect skipped on the in-flight guard, and the aborted request'sfinallyset no state and therefore scheduled no further render. The grid sat onisLoading: truewith no rows, no error and no second request, indefinitely. Reachable by hydrating to a page index in another block (enablePersistence/enableUrlSync) and by paginating to an uncached block while the first request was still open. This is the residual of the ADR-015 defect class, on the dependency ADR-015 itself added.The repair: the trigger effect has no cleanup; cancellation of a superseded request stays in
fetchData(which already aborts its predecessor before creating the next controller); cancellation on unmount moved to a dedicated no-deps effect; and the in-flight identity became{token, blockIndex}instead of the token alone, so a different block under the same token is recognised as a legitimately new request. The double-fire guard thatrefetch()/retry()depend on is unchanged, and the previously rejected synchronous guard reset stays rejected.Not a cause, despite the original diagnosis:
settings.showFilterRow: false, an explicitserverOptions.blockSize, andmeta: {filterType: undefined}. All three are supported presentation-side configuration with no path to the datasource, now pinned as a five-case contract matrix. -
Server mode rendered filter controls the server would never honour. The filter row and each filter widget existed on
meta.filterTypealone. In server modeenableColumnFilteris already forced tofalsefor every column without aserver.filtermapping, so a column type preset that injects afilterTypeproduced a fully interactive input that did nothing — and, since server mode ismanualFiltering, did not filter the loaded page either. Both now also require the resolved capability (column.getCanFilter()).settings.showFilterRowremains a visibility override in both directions; forcing the row on no longer manufactures filters that are not declared.Consumer note: a server-mode grid whose columns carry
meta.filterTypebut declare noserver.filtermapping loses its filter row. If the server does accept those filters, declare them —fieldFilter()oncolumn.server, or the field in the resource capability map. The/test-server-gridharness inapps/adminwas corrected this way in the same change.
Added#
-
serverQueryKey— the identity of the EXTERNAL query (R-32, 2026-08-27). A grid owns its own page, sort, column filters and search, and already treats a change in any of those as a new result set. A page can carry a SECOND set of controls outside the grid that narrows the same query server-side — a toolbar search box, a folder picker, a kind filter in the URL — and the grid cannot see those. Without a signal it keeps serving the previous scope's blocks, and it leaves the reader on page 5 of a result set that now has one page: an empty body with nothing on screen explaining why.The prop is an opaque string; the grid never parses it. A change joins the dataset key, so the existing invalidate-and-refetch path runs unchanged — there is no second fetch mechanism — and
useGridSetupadds the two consequences the engine already applies to its own filters:pageIndexback to 0 and the row selection cleared. Build it from the values themselves so returning to a previous scope is genuinely the same key.Fully additive:
BlockCache.buildDatasetKeytakes the key as an optionalexternalKey, andJSON.stringifyomits it when undefined — an existing dataset key is byte-identical to what it was before. Server mode only; inert in client mode. -
The page-index debounce now resets on a dataset change. The 150 ms debounce protects against a request per page while someone click-holds through pagination WITHIN one result set; across result sets there is nothing to protect, and the previous page index belongs to a question that no longer exists. Measured on a grid sitting on page 3 (
blockSize: 10) whose external query changed: blocks 2, 3 and 1 went out before 0 followed — four requests where one was needed, three of them for a result set nobody asked for. The built-in filters never had this problem because theironChangewritespageIndex: 0in the SAME state update, well before their own 300 ms debounce flips the dataset key; an external query arrives in one render and has no such head start.useDebouncedPageIndexgives it back. -
serverOptionsvalidation.blockSizeandcacheSizemust be a finite integer of at least 1; anything else throws aValidationErrorwith codeGRID_SERVER_OPTIONS_INVALIDnaming the field. Previously0and negatives quietly collapsedpagesPerBlockto 1 andNaNreached the server asfetchSize: NaN, both without a message.prefetchis deliberately left to the type system. -
Dev-only warning for an inert
blockSize— a block smaller than the UI page size holds one visible page, so every page change is a server call. Warn-once per grid + blockSize + pageSize. -
Dev-only warning for an idle server-mode datasource — hydrated, no request in flight, no rows, no error, for several seconds. Names the grid and the blocking condition. Under ADR-021 a grid that never fetches is visually identical to one that is still fetching, which is what made R-32 take weeks to localise. Diagnostic only: no UI change, no retry, no production output.
[10.0.0] - 2026-08-26 - A value passes one boundary, and the grid draws its own scrollbar#
Breaking: getDefaultColumnTypes() no longer takes a presentation. The
release's through-line is that a value crosses exactly one boundary on its way
to the screen. Accessors stopped formatting, the presentation now enters the
grid once through the locale prop, and absence means the same thing in the
cell, in the aggregate and in both exports — for null, undefined, '' and
NaN alike, while 0, false and Infinity are values that stay. Alongside
that, the grid stopped showing the platform's scrollbar and started drawing its
own. See the entries below; migration for direct callers of
getDefaultColumnTypes() is to drop the argument.
Fixed#
-
A grid with column groups reserved twice the horizontal scroll range it needed (TD-34, 2026-08-26).
--grid-total-widthwas summed fromgetFlatHeaders(), which by contract returns every node of the header tree "including parent headers" — not the leaf row. Without column groups the two sets are identical, which is why this went unseen; with them, the group row contributes a placeholder for every ungrouped column plus a group header as wide as its leaves together, and the total came out exactly doubled. Measured on a 14-column showcase grid: 2888px of columns published as 5776px, so half the horizontal scroll range was empty space to the right of the last column.The source is now
getLeafHeaders()in both writers (useColumnSizingVarsandwriteCssVarsToContainers). TD-12's invariant is untouched: what that fix protects is that the total comes from full grid state and never from the caller's partialwidthsmap, and its docblock already said "sum of ALL leaf widths" — only the function it named disagreed. Grids without column groups are unaffected; their total is unchanged. -
The header and the body could disagree about how far the grid scrolls (TD-35, 2026-08-26). With
minGridWidth, the grid wrote an inlinemin-widthon the scroll content that omitted--grid-edge-clearance(and the100%floor) while the stylesheet included both. In the body that was harmless, because the clearance lives one level up on.grid-scroll-content; in the header.grid-containerIS the scroll content, so the header lost it. The body could then scroll one clearance further than the header, and at full-right scroll a column's header stood beside its own cells — measured at 10px on the showcase grid, with the body reachingscrollLeft4812 against the header's 4802.The inline declaration now repeats every term of the stylesheet formula instead of replacing it with a narrower one, so
minGridWidthadds a floor rather than a second geometry. Only grids that setminGridWidthwere affected.
Changed#
-
The grid draws its own scrollbar, and it looks the same in every engine (AG-SCROLL-01, 2026-08-26). The body, the pagination bar and the grid's own menu lists showed the platform's scrollbar — on Windows/Chromium the Fluent one, with an arrow button at each end — where they were meant to show the grid's.
Why the platform scrollbar could not be styled into shape. Chromium and WebKit let a page replace it through
::-webkit-scrollbar; Firefox offersscrollbar-widthandscrollbar-colorand nothing else — no lane, no inset, no radius, no hover rule — and an engine that implements both abandons the pseudo-elements the moment either standard property carries a non-initial value. Measured across the three with one stylesheet: an 8px Firefox gutter against a 10px Chromium one, different thumb shapes, and a hover state in one engine only. The obvious capability query is worse than useless: Firefox parses unknown::-webkit-pseudo-elements on purpose, soCSS.supports('selector(::-webkit-scrollbar)')is true there while a width set on that pseudo-element changes the reserved gutter by 0 — a gate built on it silently gave Firefox neither branch.What replaced it.
GridScrollAreawraps Base UI's Scroll Area around the grid's existing scroll element. The scrollbar is a DOM element; the scrolling is not. The native scroll container remains the single scroll authority — Base UI'sViewportIS.grid-scroll-container, not a wrapper around it, soscrollTop/scrollLeft, wheel, trackpad, keyboard, programmatic scrolling, virtualization and the header's scroll synchronisation all keep talking to the same element. Verified in Chromium, Firefox and WebKit: one overflowing scroller per surface, lane 10px, thumb 7px,var(--border), radius 9999px,scrollbar-width: noneon the viewport — identical in all three. No browser is named anywhere in the code.Layout. The vertical scrollbar overlays the right edge, where the trailing clearance is now floored at the declared lane so the last column, its resize line and the aggregation footer stay out from under the thumb. The horizontal one is put back in flow below the viewport, where the platform's used to be — one override instead of a bottom clearance, a footer offset and a pagination adjustment.
--grid-scrollbar-thicknesscarries the declared lane, withGRID_SCROLLBAR_LANE(css-var-utils.ts) as its one source; the:rootvalue only covers the frame before the theme applies.--grid-scrollbar-widthkeeps its TD-20 meaning — the gutter the platform reserved — and now measures 0, which is why the header needs no padding to stay aligned.trailingClearanceFortakes the lane as a second term.theme.scrollbarWidthkeeps both values and gains a meaning it can hold.'thin'is the compact lane (10px, matching the application's own ScrollArea),'auto'the roomier one (14px).'auto'asks for a roomier lane, not for the platform's appearance: at either setting the track, the thumb and the absence of arrow buttons are identical in all three engines. It is no longer written as the real inlinescrollbar-width, which would change what a container reserves while saying nothing about the scrollbar a reader sees.Removed: the
@supportsengine gate, every::-webkit-scrollbar*rule (including the-buttonsuppression, which is moot once nothing paints a platform scrollbar), the standardized-property fallback, and thegrid-menu-scrollbarhook by which the grid restyled active-ui's shared popup scrollbars. Those popups belong to the application shell and now look like every other dropdown in the product; the grid's own lists go through the adapter instead..grid-header-scrollkeeps no scrollbar declarations: it isoverflow: hiddenand follows the body byscrollLeft.Dependency:
@base-ui/reactis now declared byactive-gridbecause the adapter imports it directly. It was already in the package's runtime graph through active-ui (context menu, dropdown, dialog, tooltip, checkbox all use it), so nothing new reaches the bundle — only the manifest now says so, which a standalone package would have to.Scroll performance on the stress grid (1200 rows in the DOM, 3865px of travel, 6s of continuous scrolling, Chromium): median frame 16.7ms before and after, 0 frames over 32ms in both. No material regression.
Fixed#
-
column.getAutoSortFn()andcolumn.getAutoSortDir()were declared but never attached (2026-08-26).assignPrototypeAPIsderives the prototype name withgetFunctionNameInfo, which splits on an underscore. Two entries inrowSortingFeature.tsused a dot ('column.getAutoSortFn'), sofnKeyresolved toundefinedand both landed onprototype['undefined']instead of on the column.Column_RowSortingdeclares both, so a call compiled and then threwis not a functionat runtime; the package never noticed because it calls the underlying utilities directly. Fixed by using the sameparentName_fnKeyform as the ten sibling registrations in that block — no change toassignPrototypeAPIs, no new registration mechanism, no type change. These were the only dotted keys insrc/engine. -
NaNis absence on every layer, not only on screen (REVIEW-AG-001, 2026-08-26). TD-28 named four absent values (null,undefined,'',NaN) and gave them one owner, but three layers still readNaNas a number:isEmptyreported it present, the PRESENTATION export wrote the literal textNaN, andsum/mean/min/max/medianlet a single calculation error wipe an otherwise valid group aggregate. All three now treatNaNexactly as they already treatednull— the aggregates skip it (medianreturns nothing, which is what it already did fornull), the filter calls it empty, and the presentation export writes''.0,-0,falseand every finite number stay data; a lifecycle matrix asserts both halves in one run. DATA export and the cell were already correct and are unchanged. -
Automatic sort inference sampled the wrong rows and discarded its own result (REVIEW-AG-002, 2026-08-26).
column_getAutoSortFn— the default for every column that does not declare asortFn— sampledflatRows.slice(10), which skips the first ten rows instead of taking them, so a grid of ten rows or fewer did no detection at all and always fell back tobasic. And a detectedalphanumericcomparator was overwritten unconditionally bytextfurther down, soitem10sorted beforeitem2. Detection now returns on the first match, over a bounded leading sample of ten present values (absent values carry no type evidence and do not consume a sample slot). Same four comparators, same recognition order. -
A semantic numeric column exported
Infinityas raw JavaScript text (REVIEW-AG-004, 2026-08-26). The screen hands asemanticTypenumber straight toformatNumber, which renders the infinity sign; the presentation export fell through toString(rawValue). Both now take the same path. DATA export still exports non-finite numbers as empty — that is a declared machine contract, not a parity gap. Infinity is deliberately not absence.
Changed#
- The last three accessors that formatted their own value are repaired
(TD-33, 2026-08-26). No package code changed — three consumer column
declarations, plus one detection name added to
lint-accessor-presentation.mjs. The insurance coverage period now carries an ISO 8601 interval, the mdm source summary carries bare numbers, and the pension parameter pivot carries the number, the ISO calendar day ornull; all three compose their readable form in avalueFormatter, so the screen is unchanged while DATA export, filtering and sorting became viewer-independent. The three// accessor-presentation-skip:markers are gone, and the gate now judges every accessor with zero skips. That move exposed a hole in the gate itself:formatNumberwas missing from its formatter list — exactly the function the pivot used — and has been added.
Documentation#
api/columns.mddescribedemptyValueKeyas an i18n key "for null/undefined values" while the orchestration knows four; it now states the four and adds a per-layer absence table plus the note that±Infinityis not absence.guides/export.mdseparates absence (four values) from non-finite (data without a stable machine form) in DATA mode, and its presentation-parity paragraph names the two values that used to diverge.
Changed#
-
A numeric
valueFormatterdecides on presence, never on truthiness (TD-32, 2026-08-25). No package code changed — a consumer fix, a generic regression suite and a narrow gate.Eight column declarations in two apps wrote
valueFormatter: (value) => (value ? format(value) : '-'). That tests truthiness where presence is meant, so a measured0rendered as a placeholder. Seven of them show money on a pension surface (provisionOP/PP/WZP/Total,entitlementOP/PP/WZP) where zero is a computed outcome —contractOP.get(c.id) ?? 0— so a participant with only an old-age pension read as "partner pension not calculated". The eighth,costBpsin mdm, was latent rather than measured: its formatter was annotated(value: number)over a value that is astringat runtime, and'0'is truthy.The screen was not the only surface:
resolvePresentationCellandresolvePresentationAggregateread the same formatter, so a presentation export and a group total carried the same dash. The DATA export was already correct — by contract it consults no formatter (WS-04).The seven money columns dropped their formatter entirely: it duplicated
formatSemanticCurrencyline for line, and its absence branch has been dead since TD-28.costBpskeeps a formatter, becausebpsis product vocabulary a declaration cannot supply; only the truthiness test is gone. Deliberate blankness stays available but has to say so —value === 0 ? '' : …passes,value ? … : ''does not.Guarded by
numeric-zero-value.test.tsx(13 tests: screen, formatter boundary, and parity with DATA export, presentation export,isEmptyand numeric sort) and bylint-numeric-zero-presence.mjsin repo-tools, which aborts with exit 2 ifisAbsentCellValuedisappears or stops running before the formatter branch. No skip marker. -
An accessor delivers a value, not a rendering (TD-30, 2026-08-25). No package code changed — a consumer cleanup plus a documented rule and a narrow gate.
28 grid accessors wrote the presentation of absence into the raw cell value: a hard-coded
'-', or a translated word. Nothing showed on screen — which is why the family could grow through four idioms — but the machine side broke. Measured with the real export and filter functions: with'Niet beschikbaar'in the raw value the DATA export carries that text (andNot availableunder an English session, so the same table exports different bytes per language),isEmptymatches nothing, and sorting places absent rows among the real values by their translation.The rule: an accessor may derive, compose and choose between fields; it may not inject the presentation of absence, and may not apply formatting that depends on the viewer's language. Absence is nullish; the column picks the word with
meta.emptyValueKey. A derived value should be a stable key ('ACTIVE'/'EXPIRED') rather than translated text, sofilterOptionscan supply the labels and the export stays language-independent.24 of the 28 columns look identical afterwards; four session-status columns gain a tone and lose their custom renderer. See
api/columns.md§ What an accessor may return, guarded bylint-accessor-presentation.mjsinrepo-tools— literal forms only, with// accessor-presentation-skip: <reason>for cases whose repair needs its own design decision.Deliberately untouched: the 38
|| ''accessors (measured: no live defect), and the AuditLog principal column, which needsprincipalTyperather than a placeholder and belongs to the logging workstream. -
Boolean columns are a state or a property, and the declaration says which (TD-29, 2026-08-25). No package code changed — this is a consumer convergence plus a documented rule, and the fact that active-grid needed no new API is the evidence that the model fits the existing one.
While
BadgeCelldiscardedfalse, every consumer with a boolean column had to work around it, and that happened six different ways across 34 columns in seven apps. The rule is now one question per column:The column is Declare a state carrying a judgement type: 'badge'+filter+Options statusTones a property whose values are peers type: 'badge'+filterOptions a binary answer with no domain vocabulary type: 'boolean'Three rules come with it: the key space of a boolean column is the string
'true'/'false'(raw booleans infilterOptionsstill resolve the cell label through key coercion, but the select filter then matches nothing); every state carries a word, with colour and icons allowed only as reinforcement; and a boolean badge column carries novalueFormatter, because the formatter branch runs first and turnstype: 'badge'into a dead word.See
api/columns.md§ Boolean columns. The families this repository aligned are guarded bylint-boolean-column-convergence.mjsinrepo-tools; whether a given field is a state or a property remains a consumer decision. -
An empty string is absence, on every orchestration path (TD-28 phase 2, 2026-08-25; ADR-024). This is a deliberate, visible product change.
GridBodyCelltested emptiness withvalue === null || value === undefinedwhile the primitives tested withisAbsentCellValue— two answers to one question, differing exactly on''. The accessor therefore decided presentation:(row) => row.fieldrenderedNot availableand(row) => row.field || ''rendered nothing. That difference is one token wide, lives in a file the reader never sees, and 35 columns across six apps wrote the second form. Two grids in the same app showed the same missing IP address differently.The orchestration now uses the same predicate:
null,undefined,''andNaNare absent;false,true,0,-0and every other finite number are data. 33 columns visibly change from a blank cell to a placeholder (35 affected, two of them on a non-nullable source).Four silent defects disappear with the same test, each with the screen on the wrong side of a line the export already drew correctly:
Number('') === 0produced a fabricated zero on a number, currency or percent column, whiletoFiniteNumberin the DATA export explicitly calls it empty (CORR-08);formatDateOnly('')returns a translation key, and it leaked verbatim into the cell and into the presentation export;'' ? yes : noproduced a fabricatedNoon a boolean column;formatNumber(NaN)returns'', so a calculation error looked like an empty cell instead of a missing value.
Nothing about the data changes. Filtering, sorting, grouping, aggregation and both export modes read the raw value.
isEmptyalready counted''as empty, the select filter already keyednulland''alike, and the DATA export already resolved''to an empty cell. A machine value still never carries a translated placeholder (ADR-022). The absence branch also keeps its position: after grouping, placeholder and aggregate handling, before every formatting path.TextCellis unchanged: as a primitive it still renders''as a valid empty string. Deliberate blankness keeps two explicit carriers — avalueFormatterreturning''(it runs after the absence branch and its result is never re-tested) andemptyValueKey: 'common.placeholder.empty'.emptyValueKeyselects the label, never the emptiness, and no new metadata was introduced.Consumers were deliberately left alone:
row.x || ''is now redundant rather than wrong, so the verification round could prove the fix sat in the right layer. That cleanup — together with the sharper sibling family of 14 accessors that write a presentation value ('-'or a translated word) into the row data — is recorded asROADMAP.mdTD-30.
Fixed#
-
falseand0no longer disappear from categorical cells (TD-27 phase 1, 2026-08-25).BadgeCell,StatusCell,DateCellandLinkCellall opened withif (!value) return null. That guard is meant for an empty cell, but it rejects every falsy value — includingfalseand0, which are data. Sincebody-cell.tsxalready catchesnull/undefinedfirst, the guard only ever saw the values it handled wrongly.Measured impact: six live columns across three apps rendered an empty cell for
false, including a fiscal-audit result column whereGefaaldwas invisible and an entitlement column headedStatuswhose ordinary value (Opgebouwd) never showed. All six became correct without a single line of consumer code.Narrowing the guard alone is not the fix.
displayValuefell back to the raw value, and React ignores a boolean child — so afalsewithout alabelMaphit would have rendered an empty badge outline instead of nothing: the same silent defect in another shape.BadgeCelltherefore derives a string key (String(value)) and looks the label up on that key, the same derivation the select filter already used (String(cellValue ?? '')). Filter and cell can no longer disagree about the same column.The empty set is now stated once, in the internal
utils/cell-value.ts:null,undefined,''andNaNare absent;false,0,-0and every other finite number are values.DateCellaccepts epoch0as 1 January 1970 — its declared type always allowed a numeric epoch, and the active-i18n boundary it delegates to already accepted it.GridBodyCellkeeps sole ownership of the placeholder: primitives return nothing for an absent value, andmeta.emptyValueKeyis unchanged. A caller who uses a primitive directly in a customcellrenderer has bypassed the orchestration and therefore gets no placeholder — seeapi/columns.md§ Empty and falsy cell values.BadgeCellacceptsstring | number | boolean | null | undefined(additive; it replaces anas stringcast inbody-cell.tsxthat was never true).StatusCellstays deliberately string-typed: the status key belongs tometa.statusTonesand is resolved by the orchestration, and widening the prop would put a second key derivation next to the first.Two follow-ups were recorded and are not part of this change: TD-28 (
''bypasses theGridBodyCellplaceholder), shipped separately under Changed above, andROADMAP.mdTD-29 (four idioms for one boolean badge column across consumers). -
enableUrlSyncno longer deletes the page's own query parameters (ADR-023, 2026-08-23). The write-back builtpathname + serialize(...)and replaced the entire search string, so every parameter the grid does not own — a tab id under?section=, an app-owned view under?view=— disappeared on the first sort, page, filter or search change. A page-owned deep link therefore worked exactly once and then silently stopped working, and the failure was invisible because the grid itself kept behaving correctly and only the application state vanished.enableUrlSyncand app-owned URL state were mutually exclusive, which is a composition defect and not a design decision.The driver now declares the five keys it owns (
GRID_URL_PARAM_KEYS:page,pageSize,sort,filters,search) andmergeGridQuerymerges its serialized state into the existing search string. Foreign parameters keep their value, their multiplicity and their relative order and are never re-encoded; grid-owned keys are still added, replaced and removed exactly as before.replaceStateis unchanged, so back/forward semantics are unchanged.Backward compatible. On a route that carries no parameters besides grid state — which is every consumer in this repository today — the merge produces the same string the replacement did. No public API change:
url-driveris internal and is not exported fromsrc/index.ts. New documentation: URL state, including the pre-existing limitation that the five keys are route-global and not namespaced pergridId, so at most one grid per route should enable URL sync. -
docs/guides/server-grouping.mdclaimed expanded-group state syncs to the URL (2026-08-23). It never did: there is no URL key for it andexpandedis a localStorage-only slice. Corrected, and the owned-key list is now documented in one place. -
One date shape, not three (WS-13, PP-038, 2026-08-17). The package carried three hidden and mutually contradictory date defaults, and which one a cell got depended on the rendering path: the
datecolumn-type preset formattedshort,DateCelldefaulted tonumeric, and the presentation export fell back tonumericas well. A column that declared onlyfilterType: 'date'therefore rendered differently from one that declared its semantics. All three now resolve throughformatSemanticDate, and the default is the one PP-038 names for a dense tabular list: short textual for a calendar day (9 mrt. 2026), that same short date plus the time for an instant. DATA export is untouched — it never read a formatter. -
A blank semantic value no longer becomes a real zero in DATA export (CORR-08, 2026-08-15).
resolveDataCellconverted with a bareNumber(), and JavaScript turns'', whitespace,nulland[]into a perfectly finite0. A missing amount therefore left the machine export as a measured zero — for a currency column even with an identity companion column beside it, so as a real amount of 0 in a concrete currency. Missing is now missing (semantic: 'empty', no currency); a real zero (0,'0','0.00') is unchanged, and a boolean is no longer coerced into an amount.
Changed#
- The delimited-escaping boundary moved to
@workspace/active-utils/helpers(CORR-10, 2026-08-15) and is re-exported here unchanged. Nothing about the quoting, the RFC 4180 handling or the formula neutralisation changed, and the behaviour tests stay in this package. The move exists so a second consumer — the message export inactive-actions— can use the same boundary instead of hand-rolling a thirteenth variant.
Changed (breaking for direct callers)#
getDefaultColumnTypes()no longer takes a presentation (WS-13, 2026-08-17). It bound a locale into avalueFormatterclosure on the two date types; those closures are gone, so all five value semantics are now declarations and the presentation enters the grid exactly once — through thelocaleprop, which the cell orchestration, the footer, the group aggregates and the presentation export all read. The second presentation feed that callers had to keep in sync by hand no longer exists to keep in sync. Migration: drop the argument.
Added#
meta.datePreset: the declarative semantic date shape of asemanticType: 'date' | 'dateTime'column (WS-13, PP-038), alongsidemeta.numberFormatandmeta.percentPreset. Closes the known asymmetry registered as OP-39; its first concrete consumer is a calculation result column carrying its ownformatOptions.dateFormat. A calendar-day column accepts only day-shaped presets — a time-bearing preset yields a dev warning and the default, because a calendar day has no time to show.meta.percentPresetaccepts{decimals: n}besides a preset name, so a runtime-supplied domain precision (an actuary-configured output column at three or six decimals) can reach the cell. Same shape asmeta.numberFormat; notation still comes from the presentation context.localeaccepts a presentation-context object besides a locale code (WS-07). The new public typeGridPresentation(string | (DatePresentationContext & NumberPresentationContext)) applies to thelocaleprop ofActiveGrid/LookupGrid,getDefaultColumnTypes, the presentation-export options (GridExportRunOptions,GridPdfExportOptions,GridClipboardOptions) and the date-filter helpers. The value is forwarded untouched to the central@workspace/active-i18nformatters, so a consumer can carry language and notation preferences independently; a bare locale code remains fully valid ("notation follows the language") and existing consumers are unchanged. The package stays tenant-blind: no administration, profile or settings knowledge — the caller composes the context. DATA export is untouched by construction (its resolvers take no presentation input, PP-013).meta.currencyDecimals: deliberate per-column decimals override forsemanticType: 'currency'columns (PP-007's documented exception, e.g. whole amounts on a dashboard). Applied by body cell, footer, group aggregates and presentation export alike via the new sharedformatSemanticCurrencyhelper, so one column can no longer show a 0-decimal body under a 2-decimal footer. Omitted: the currency's natural minor units decide. DATA export ignores it.
Changed#
-
Grid data export defaults to machine/raw semantics; presentation export is an explicit mode (WS-04, PP-013/PP-014 — BREAKING for every export consumer). CSV/XLSX/JSON export used to serialize the
valueFormatterstring literally ("€ 1.234,56"as a text cell, locale-dependent dates, translated column headers as JSON keys), whiledocs/guides/export.mdclaimed the opposite — the export bytes differed per viewer language. The export functions now take aGridExportRunOptionsargument: the default{mode: 'data'}serializes column ids as headers/keys, raw semantic values, machine dates (yyyy-MM-ddfor declared calendar days, ISO 8601 UTC for instants), native numbers/booleans, percent values in their declared stored unit (no factor-100 normalization), and currency amounts as bare numbers with the ISO 4217 identity in a<id>_currencycompanion column (skipped when thecurrencyKeyfield is itself a visible column; suppressed on aggregates without a provably shared currency, PP-010).{mode: 'presentation', locale}is the explicit human-readable form and mirrors the cell orchestration (badge labels,valueFormatter, semantic currency/number/percent formatting). XLSX data mode writes typed cells: numeric/boolean cells, calendar days as timezone-free date serials with ayyyy-mm-ddformat, instants as stable ISO strings (Excel serials carry no timezone), and fraction percents with a native%display format that never alters the stored value. New generic column meta:exportValue(explicit machine value for DATA export) andsemanticType: 'date' | 'dateTime'(declared by thedate/dateTimepresets).exportToPDF*now requires{locale}— PDF is presentation output by definition.copyToClipboard(rows, grid, {locale, includeHeaders?})replaces the boolean third parameter: the clipboard is deliberately classified as a presentation copy (you copy what you see) and is not the machine-export contract. Grouped exports emit raw aggregates in data mode and no longer indent data cells there (an indented number is no longer a number). Rationale:docs/DECISIONS.mdADR-022. -
The
number,threshold,countandpercentcolumn types carry declarative semantics; body and footer format from one source (WS-02, BREAKING for percent columns without their ownvalueFormatter). The barenumbercolumn body used to renderString(value)— no grouping, no locale — while footer aggregations of the same column were locale-formatted (presentation-audit §5.5). The presets now declaremeta.semanticType: 'number'(with optionalmeta.numberFormat, default'decimal';countdeclares'integer') and the cell orchestration, footer, group-footer and server-group aggregation paths all format through the sameformatSemanticNumber. Thepercentpreset no longer sets a fraction-assumingvalueFormatter; it declaressemanticType: 'percent'with an explicit stored unit —percentSource: 'fraction'(0-1 data, the preset default and the historical contract) or'percentage-points'(0-100 data) — plus optionalpercentPresetprecision. A percent column without a declared source renders a plain number without a percent sign and logs a dev warning, mirroring the WS-01 currency no-identity-no-symbol rule. A column-levelvalueFormatterstill outranks all of this. Aggregation fallbacks that previously skipped formatting forfilterType: 'number'columns now format numbers through the central'decimal'preset. -
Group-row aggregated cells honor the column's formatter and semantics (WS-02). The engine's
getDefaultColumnDefno longer injects agetValue()?.toString?.()aggregatedCelltemplate into every column. That default short-circuited the React layer's aggregated-cell rendering, so a grouped row showed the raw float (26907.12000000001) next to body cells that showed€ 26.907,12. A consumer-suppliedaggregatedCellstill wins; without one, aggregates now render through the column'svalueFormatteror declared currency/number/percent semantics, withString(value)as the unchanged final fallback. -
The
currencycolumn type carries currency identity as data instead of deriving it from the viewer's language (WS-01, BREAKING for currency columns). The preset no longer sets avalueFormatterthat calledformatCurrency(value, locale)— a path that could only ever render the language-derived currency (EUR) regardless of what the amount was. It now declaresmeta.semanticType: 'currency'; the cell orchestrator resolves the identity frommeta.currencyKey(a row field carrying the ISO 4217 code) ormeta.currencyCode(a fixed domain contract supplied by the app layer). A currency column without either source renders a plain locale-formatted number without a symbol, plus a dev-warning: no identity means no symbol. Footer and group aggregations of acurrencyKeycolumn only carry a symbol when every aggregated row shares one currency; mixed-currency sums render as a bare number (PP-010) — and server aggregations only format with a fixedcurrencyCodecontract. Exports now emit the raw value for these columns instead of a presentation string. The grid stays tenant-blind: codes come from rows or props, never from settings. Guarded bysrc/config/__tests__/column-type-presets-currency.test.ts. -
DateCellrenders a calendar day in the reader's language instead of a fixed notation. ItsformatStringdefault moves from'dd-MM-yyyy'to date-fns'P, and when nolocaleobject is passed the cell now resolves one itself — the same chain the date filter in the column header already used: an explicit prop wins, then the consumer'scalendarLocaleon<ActiveGrid>, then the mapping of the grid'slocalestring, andenGBwithout a provider.This is a visible change for four of the five languages.
'dd-MM-yyyy'is the Dutch notation under a language-neutral name: a German user read09-03-2026in the column while the filter in that same column header and the date field in the form beside it both said09.03.2026. In Dutch nothing changes —Prenders09-03-2026there.A column that passes its own
formatStringorlocalekeeps it; only the default moved. The fallback without a provider isenGBand deliberately noten-US, which would swap day and month.Guarded by
src/components/cells/date-cell.test.tsx, which pins the notation per language, the provider-less fallback, and the precedence of an explicit prop. Companion change: thenumericpreset in@workspace/active-i18nmoved the same way, and the two are tested against the write side there. -
The pagination row counter says nothing when there are no rows. It read "No results" while the data area directly above already showed "No results found" — the same message twice inside one frame, and on a narrow screen that repetition cost the space "next page" needed. A populated counter ("1-10 of 100") still shows at every width, because it states something that appears nowhere else.
The duplication is measured, not assumed:
sections/body.tsxrenders its empty state under exactly the condition the footer used to speak under (zero rows, no fetch in flight), so wherever the counter fell silent the area above always speaks.grid.pagination.noResultsis removed from all five locales;grid.empty.noResultsis unaffected.Two other shapes were tried and dropped.
flex-wrapon the bar broke it into three separate lines (counter, page size, button row), which reads as a list rather than as one control bar; hiding the counter belowsmregardless of content threw away a useful count on mobile. The bar stays one horizontally scrollable line.
Fixed#
-
autoSizeOnMountno longer skips an empty client-mode grid. The effect requiredhasData(rows > 0 || server mode), so a client grid with zero rows kept its default column widths and truncated its own headers. Visible on every empty list; the passkey overview of a user without passkeys showed "Toegevoeg…" and "Laatst gebr…" beside a screen full of empty space.The condition had a reason — the mount latch fires once, so measuring an empty grid and latching would freeze a grid whose rows arrive later at header width. There are now two latches instead of one condition: one empty measurement is allowed (the header is the best thing there is to measure), and the first render with real rows measures once more and latches for good. The second latch also prevents a render loop:
autoSizeOnMountreaches most consumers as an object literal, so the effect's dependencies change every render.Not covered by a test. jsdom performs no layout, so
scrollWidthis 0 and the measurement this fix restores cannot be asserted there. The change is verified by reading and in the browser, not by a guard. -
The pagination bar's scrollbar is styled like every other one. It was the grid's only scroll container without scrollbar rules, so it fell back to the operating system's: thicker, with arrow buttons. A narrow grid showed two different horizontal scrollbars stacked — the body's thin one and the footer's native one.
[9.0.0] - 2026-08-10 - The grid stops guessing what a value means#
Breaking: BadgeCell loses its variant prop. UI-EXP-013, the workstream
that follows directly on active-ui's UI-EXP-001. Status presentation in the
grid now runs through the central StatusBadge, and the grid no longer decides
by itself which values deserve which colour.
What was wrong. The grid picked its badge variant hardcoded per filter type
in sections/body-cell.tsx: filterType: 'select' always got outline,
filterType: 'boolean' got default for true and secondary for false.
That last rule is the sharpest form of the problem — a presentation layer with
no domain knowledge deciding that true earns the accent colour. And there was
no column contract along which a meaning could travel at all: variant was
reachable only by importing BadgeCell directly, which happened in six files.
Restoring the old behaviour is not offered, on purpose. The variant axis
carried no semantics to restore: default is the theme's accent colour, not
"good". All ten call-sites are in this repo and migrated in the same change
(Critical Rule 12 — no dual API as steady state).
Added#
-
meta.statusTones—Record<string, StatusTone>on a column, mapping each raw cell value to one of the five generic tones of@workspace/active-ui(neutral,info,success,warning,danger). Presence of this map is the only signal that turns a cell into a status badge; no filter type, enum name, colour or label does. A value that is not in the map falls back toneutral. It sits next tofilterOptionsand mirrors it: that one maps value to label, this one maps value to tone, and each has exactly one home. Seeapi/columns.md§ Status columns. -
StatusCell— the imperative counterpart, for a customcellrenderer whose value is not a stable key (a derived or already-translated value, where a value→tone map would break per locale). WrapsStatusBadgeand adds the grid's label resolution and badge typography.
Changed#
-
BadgeCellis the categorical badge and has no colour axis. Type, category, department, source: one fixed presentation. A customcellthat genuinely needs a coloured categorical badge renders<Badge>from@workspace/active-uidirectly, so the choice is visibly outside the grid's badge contract. -
Booleans no longer carry implicit status (visible change, five columns repo-wide).
trueandfalserender the same categorical badge; the yes/no text is the information. A consumer who wantstrueto read as success says so:statusTones: {true: 'success', false: 'neutral'}. -
selectcolumns are unchanged unless they declarestatusTones. The roughly 150type: 'badge'columns in the repo keep the presentation they had; adopting tones there is per-column domain work in each app, not part of this contract change.
Removed#
BadgeCellProps.variant. Migrated in the same change: six files inapps/adminand seven localstatus → varianthelpers. Five status columns and two priority scales moved tostatusTonesorStatusCell; four categorical columns (category ×2, department, form name) lost their decorative colour coding and are now the uniform categorical badge.
Testing#
-
The status contract has its own suite (
src/components/__tests__/status-tones.test.tsx, 11 tests). It asserts ondata-tone/data-appearance— the attributesStatusBadgesets — so it measures the contract ("the tone from the column configuration reaches the central capability") instead of another package's classes. The presentation itself, including contrast, stays tested inactive-uiand is not duplicated here. -
Unmount/remount cleanup is now measured (
src/components/__tests__/unmount-cleanup.test.tsx, 5 tests — roadmap R-30). No product change: the grid already released its listeners and disconnected itsResizeObservers, and this suite pins that instead of assuming it. The tracker matches on listener identity, so a cleanup that handsremoveEventListenera fresh closure would be caught. Proven by mutation: reverting thevisibilitychangecleanup inuse-grid-stateor anobserver.disconnect()turns the suite red. Two paths are deliberately or unavoidably out of reach and named in the file header — listeners on the grid's own (detached) subtree, anduse-flex-layout's windowresizelistener, which jsdom never registers becausemeasureElementstaysnull. -
Byproduct, not fixed here: a paginated grid leaves nine
windowlisteners behind on unmount, traced toactive-ui'sSelect(a standalone<Select>render reproduces it without any grid). Tracked as LBL-005 on the active-ui roadmap; every variant in this suite therefore runs with pagination disabled.Correction, 2026-08-11 (LBL-005). The trace was wrong and so was the conclusion drawn from it. Those nine listeners belong to jsdom itself —
@asamuzakjp/dom-selector, the engine behindElement.matches(), registers them onwindowto track:focus-visible/:hover/:active, lazily on the firstmatches()call in a document and cached from then on.Selectwas merely the first caller. They do not accumulate (nine after one mount/unmount, nine after five) and they do not exist in a real browser, where the selector engine is native: measured in Chrome across 2,927addEventListenercalls, 40 open/close cycles and 10 mount/unmount cycles,resizeis the onlywindowlistener type ever registered and none of the nine appears. The suite now runs paginated variants as well; seeunmount-cleanup.test.tsx.
Documentation#
api/columns.mdgains a § Status columns;api/cells-utils.mdexplains the split between status and categorical;guides/columns.mdno longer teaches a hand-rolledbg-green-100/bg-red-800status cell as its custom-renderer example — that snippet was the exact pattern this release removes.LoadingDisplayValueis now named indocs/api/grid-props.md. The type shipped as a public export in 8.1.0 but the prop documentation spelled its definition out inline (LoadingIndicator | React.ReactNode) in both places it appears, so a consumer reading the docs could not tell there was a name to import. The### Typeblock now mirrors the source and shows the import. No code change; the exported surface is unchanged since 8.1.0.
[8.1.1] - 2026-08-03 - The editing-error icon styles itself from the trigger#
EditableCellWrapper passed the error icon's styling on the render-element
(<AlertCircle className="text-destructive h-3.5 w-3.5" />) while the
TooltipTrigger above it carried pointer-events-auto. Base UI reads
render.props before it unwraps a lazy element, so that arrangement drops the
icon's className the moment the JSX crosses an RSC boundary — the package is
client-only today, so it never fired here, but the shape is the one that broke
41 call-sites across the apps. Both class sets now sit on the trigger and the
render-element is bare.
No behaviour change: TooltipTrigger is a pass-through, so the final element
receives the same four classes either way. Verified in the browser — identical
token set and identical computed box, colour and pointer-events for both
orderings. Class order inside a class attribute never decides the cascade;
CSS source order does.
Guarded repo-wide by lint-render-prop-classname.mjs; rationale in active-ui
ARCHITECTURE.md § Base
UI-composeer-richtlijn.
[8.1.0] - 2026-08-03 - The loading display is yours; the footer tells the truth#
Two changes that look like one, and are not.
The grid no longer states a result count it does not have. A grid has three states — not loaded yet, loaded with nothing, loaded with rows — and counting rows cannot tell the first two apart. The pagination footer only had the count, so during a first fetch it printed "No results": the grid saying "I looked and there is nothing" while the truth was "I am still looking". That is correctness, it is now fixed, and it is not configurable.
How loading looks is now entirely yours, and the default is nothing. Only
you know whether your data arrives in 50 ms or in 3 seconds. An indicator that
appears and vanishes inside 80 ms is more disturbing than an empty area, and a
library that guesses is wrong for half its consumers. So loadingDisplay
defaults to {firstLoad: 'none', refetch: 'progress'} — silent on first load,
a 2-pixel bar on a refetch because rows on screen have just gone stale and a
reader deserves to know.
Full reasoning, including the option that was tried and dropped: ADR-021.
Added#
-
loadingDisplayaccepts an options object.TypeScripttype LoadingIndicator = 'none' | 'progress' | 'skeleton' | 'spinner' | 'text'; interface LoadingDisplayOptions { firstLoad?: LoadingIndicator | React.ReactNode; // default 'none' refetch?: LoadingIndicator | React.ReactNode; // default 'progress' delayMs?: number; // default 0}First load and refetch are different situations — one has an empty grid, the other has rows that just went stale — so they are configured separately instead of through one mode that guesses.
-
'none'and'text'indicators.'none'shows nothing;'text'shows one non-blocking line at the top edge ("Loading…" / "Refreshing data…"), which covers nothing and is therefore safe in both states. -
A
ReactNodemay be passed instead of an indicator name, rendered inside the grid box. -
delayMsholds an indicator back until the fetch has been running that long. A faster fetch shows nothing at all — this is what removes the flash. Defaults to0; 200-300 ms is the recommended companion to any visible indicator. Not defaulted to a non-zero value on purpose. -
prefers-reduced-motionis respected. The progress bar and the skeleton stop animating; they stay visible, they just hold still. -
Exported types:
LoadingIndicator,LoadingDisplayValue,LoadingDisplayOptions.
Changed#
-
loadingDisplaydefaults to{firstLoad: 'none', refetch: 'progress'}. Was'progress'in both states (ADR-016). Nothing in this repo sets the prop (measured), so no existing opt-out changes meaning. -
The short form is symmetric.
loadingDisplay="skeleton"now means skeleton in both states. It previously meant "skeleton on first load, bar on refetch" — an asymmetry you could only discover by reading the source. Write{firstLoad: 'skeleton', refetch: 'progress'}for the old behaviour; it reads the way it works. -
'skeleton'without resolved column widths renders nothing, where it used to fall back to the spinner. "I asked for a skeleton and got a spinner" is not explainable. -
The skeleton's row count follows
pageSizeinstead of a fixed eight, so the table does not jump when the data lands. -
The pagination footer suppresses the row count and the page indicator while
totalCount === 0 && (isLoading || isRefetching).GridPaginationreceivesisLoading/isRefetching— the same signalsbody.tsxhas always used for its empty-state guard. During a refetch that still has rows on screen the previous count stays, so the footer does not flicker. -
skeletonColumnswas never a consumer concern.grid.tsxderives it from the visible leaf columns and always has. The prop documentation said otherwise.
Tests#
components/__tests__/loading-states.test.tsx— the three states, the defaults, the symmetric short form, the object form, a consumer node, the text variant, the skeleton's row count and its no-columns behaviour, anddelayMsunder fake timers (including: a fetch that finishes inside the threshold never flashes). Both guards were mutation-checked — removing the footer suppression fails the correctness tests, making the first-load default visible fails the appearance tests.
Not changed#
use-data-orchestrator.ts— untouched.isLoadingalready means "first load" andisRefetching"reload"; no new state was needed andisInitializedstays private.
[8.0.0] - 2026-07-29 - The grid role is made true#
Breaking: behaviour changes without a code change on the consumer side.
Second session of R-27. enableKeyboardNavigation now defaults to true, so
every grid that never set it gains cell navigation, and two callbacks that
keyboard navigation used to suppress now fire.
Restoring the old behaviour is one prop: enableKeyboardNavigation={false}.
Measured before the change: zero consumers in this repo set the flag to false,
so no existing opt-out silently changes meaning.
Changed#
-
enableKeyboardNavigationdefaults totrue. The grid declaresrole="grid", and a grid is keyboard-navigable by definition — shipping the role without the navigation is an incorrect declaration, not a missing nicety (WCAG 2.1.1, level A). Measured before the change: of 192 files inapps/rendering<ActiveGrid>plus 7 elsewhere inpackages/, four set the flag, and all four are admin test pages. No production screen could reach a grid cell by keyboard. -
Arrow keys now move cell focus in grids that previously ignored them. There is no behaviour being replaced: with the flag off the grid exposed no focusable element for the body at all, so there was no prior arrow-key behaviour to break.
Home,End,Ctrl+Home,Ctrl+End,PageUpandPageDownbecome active on the same terms. Seeguides/keyboard.md. -
onRowClick,onRowDoubleClickandonCellClickfire while keyboard navigation is on. They were suppressed whenever navigation was enabled (base-row.tsxfor the row callbacks,body-cell.tsxfor the cell one), together with thecursor-pointeraffordance on clickable rows. A row click and a cell focus are events at different levels; there was no reason for the exclusion beyond an early simplification. This is a change in both directions: the four grids that already set the flag gain callbacks they did not previously receive. -
The grid's tab stop moved from
.grid-scroll-containerto therole="grid"element. This supersedes the 7.8.0 decision to keep the scroll container as the tab stop. A focusable element with no role directly insiderole="grid"is anaria-required-childrenviolation, rated critical — it appeared in three of the five gate configurations the moment navigation was switched on.
Added#
-
Enterstarts an edit on an editable cell. This was the whole point of the session: there was previously no keyboard route into editing. The only triggers wereonClickandonDoubleClickon the cell, and a<div role="gridcell">does not synthesise a click fromEnter. Verified for all three editing modes —cell,rowandbatch.onCellActivateis deliberately not also called for editable cells: one keypress must not both activate and edit. Non-editable cells are unaffected. -
The skip link is an exit instead of an entrance. It now lands on a sentinel placed directly after the
role="grid"element, so the header, filter row and body are skipped while pagination and the batch toolbar stay reachable. Measured motivation: a grid with selection and expanding renders two tab stops per row, so a 10-row page adds 20 and a 100-row page adds 200, with no way out but to tab through all of them. -
An accessible name on the remove-grouping button (
controls/grouping-toolbar.tsx), naming the column it removes rather than saying "Remove grouping" once per grouped column. Third instance of the icon-only-named-only-by-titleclass, after the header menu trigger in 7.8.0. -
Guard tests for accessible names axe cannot check, and gate coverage for keyboard navigation in both states.
Fixed#
-
The skip link moves focus. It targeted the
role="grid"element, which had notabIndex; a fragment link to a non-focusable element does not move focus. Same root cause as the item below, not a separate defect. -
The focus indicator is visible when entering the grid. The
focus-visible:ring-*classes sat on therole="grid"element while the tab stop was the inner scroll container, so:focus-visiblecould never match and a keyboard user entering the grid saw nothing (WCAG 2.4.7, level AA). Moving the tab stop onto the element that already carried the classes fixed this and the skip link together — two symptoms of one cause.
Translations#
Two keys replaced and one added, in src/i18n/defaults.ts and all five datyx
locales (nl, en, de, fr, es):
| key | status |
|---|---|
grid. | removed |
grid. | added (replaces grid.) |
grid. | added (skip-link landing point) |
grid. | added (takes a {{column}} value) |
Migration#
- Grids that want the pre-8.0.0 behaviour: pass
enableKeyboardNavigation={false}. - Consumers supplying their own
t: add the three new keys above, or accept the English defaults.grid.skipLinkis no longer read. - Consumers that set
enableKeyboardNavigationand relied ononRowClick,onRowDoubleClickoronCellClicknot firing: those callbacks now fire.
[7.8.0] - 2026-07-29 - The controls have names, and a gate keeps them#
New behaviour for existing consumers, plus a new dependency. Minor rather
than patch: nine translation keys are added, so a consumer supplying its own t
must feed them or fall back to the English defaults; and the package gains an
axe-core dev-dependency for the accessibility gate.
First session of R-27, the accessibility audit. The finding fixed here is the
one the roadmap did not list and the only one that reached users today rather
than latently.
Fixed#
-
The pagination controls have accessible names. All four page buttons (first, previous, next, last) were icon-only with no
aria-labeland notitle, which axe reports asbutton-nameat impact critical (WCAG 4.1.2, Name Role Value, level A). A screen-reader user could not tell what any of them did — and they are the only way to reach page two. The page-size select had the same problem. -
The per-column filter buttons have accessible names. All six filter variants (text, number, date, boolean, select, interval) render the same icon-only trigger. They are now named after the column they filter (
Filter {{column}}), so a screen-reader user can tell the per-column triggers apart instead of hearing six identical unnamed buttons. -
The system columns are no longer nameless headers.
row-numberandexpanderdeclareheader: () => null, producingcolumnheaderelements with no text — announced as a blank column, and reported asempty-table-header. They now carry visually hidden text rather than only anaria-label, because an empty header is a problem for sighted keyboard users too, and because the rule looks for text rather than for an accessible name.
Changed#
- The filter row exposes
gridcell, notcolumnheader. Measured cause: withcolumnheaderthere were two of them at everyaria-colindex— ten for five columns — so a screen reader resolving a cell's column header found the real header and an empty filter cell at the same index. Naming those cells would have made it worse, not better: the column would then appear to have two different headers. The filter row holds controls, not column names, and the widget inside each cell carries its own name.
Added#
-
An axe-core accessibility gate (
components/__tests__/a11y-axe.test.tsx), over five grid configurations. It runs in vitest, and therefore in CI — the Playwright suite needs two apps up and stays manual verification.The gate documents its own limit, in the test file. Measured during the R-27 Stop 1 session: axe reported
tabindexPASS (16 nodes) andnested-interactivePASS (15 nodes) whileprimitives/resize-handle.tsxhas notabIndex, norole, noonKeyDownand noaria-*— it cannot be operated by keyboard at all. A green axe run would have missed the entire keyboard axis. This gate catches regression; it is not evidence of conformance.
Migration#
Nine keys were added under grid.pagination.*, grid.filter.* and
grid.header.*. A consumer passing its own t should add them; without them the
English defaults in src/i18n/defaults.ts apply, so nothing breaks silently —
the controls just stay English. The datyx locales carry all nine in nl, en, de,
fr and es.
[7.7.0] - 2026-07-28 - The right edge is measured, and the token says what it means#
Breaking: theme.scrollbarWidth changes type. It took a CSS length
('12px'); it now takes 'thin' | 'auto'. Measured over apps/ and
packages/, no consumer sets it, so nothing in this repo has to change. See the
migration note below.
This closes TD-20 and TD-18. Both came down to the same mistake: two
different quantities were called 8 and had to agree by hand.
Fixed#
-
The header's right edge lines up with the body's, at every zoom level. The header reserves a strip matching the body's scrollbar gutter through
padding-right: var(--grid-scrollbar-width), but that token was a fixed 8px while the gutter is a platform constant in DIPs divided by the page zoom. Measured on Windows/Chromium withscrollbar-width: thin: 10 CSS px at 100% zoom, 8 at 125%. The grid now measuresoffsetWidth − clientWidthon the body container and writes it back, so the two edges meet wherever the zoom sits.configuration gutter header pad (before) delta (before) delta (after) thin, 100% zoom10 8 -2 0 thin, 125% zoom8 8 0 0 auto15 8 -7 0 The drift was
min(0, pad − gutter), not a constant — it vanished at 125% zoom, which is why it was never reported. -
The content clearance can no longer be moved out from under the flex fill.
--grid-edge-clearanceis declared on:rootasvar(--grid-scrollbar-width), so it resolved there. A:rootoverride of the scrollbar token therefore moved the clearance while the JS reserve stayed at 8, breaking ADR-020's INV-18 and producing a phantom horizontal scrollbar in the fit state — measured 4px at12pxand 8px at16px. The grid now writes both variables on its own containers, so an override cannot reach them. Measured after: 0 in both cases.Note this was never reachable through the documented prop: a theme setting
scrollbarWidth: '12px'moved only the header padding, because inline styles on the wrapper come too late to change a:rootsubstitution. The breaking route was the:rootsnippet indocs/guides/styling.md. -
Trailing clearance survives platforms that reserve nothing. Deriving the clearance from the measurement alone collapsed it to 0 where scrollbars overlay the content, satisfying INV-18 while breaking INV-3 — the last column's boundary, and the resize line on it, landed on the card edge. The clearance now carries a floor (
MIN_TRAILING_CLEARANCE, 8px) while the header padding keeps tracking the raw gutter. Measured withscrollbar-width: none, fit state: clearance0pxbefore,8pxafter, withdeltaand phantom scroll 0 in both.
Changed#
-
theme.scrollbarWidth: string→'thin' | 'auto'. A length could never work:scrollbar-widthoverrides::-webkit-scrollbar { width }in Chromium, so the value never reached the scrollbar — measured, the token moved 8→24 with no change to the reserved gutter. What it did instead was silently shift the header padding and, via:root, the clearance. The two-valued preference is the only lever the engine honours, and the geometry now follows from measuring its effect.'none'is not offered: it removes mouse-scrolling on most platforms. It stays available to a consumer through plain CSS, documented with that caveat. -
scrollbarWidthis no longer inTHEME_TO_CSS_VAR.scrollbar-widthis a real CSS property and is not inherited (measured: set on an ancestor, the container's computed value staysauto), so it cannot travel in the wrapper's style object.use-flex-layout.tsapplies it to the scroll containers. -
EDGE_RESERVEis gone. The flex fill and the CSS clearance both come fromtrailingClearanceFor(), so INV-18 holds by construction rather than by two constants agreeing. The one remaining constant isMIN_TRAILING_CLEARANCE, and it is documented as a design choice rather than a guess at the gutter. It also serves as the pre-measurement reserve, which needs no separate constant: "at least the design minimum" is the honest answer before the gutter is knowable. -
docs/guides/styling.md: the:rootoverride snippet is replaced with an explicit warning; the WebKit/Firefox labels are corrected (the rule marked "Firefox" is the one that governs Chromium); the token table row now names the theme preference instead of presenting the variable as the scrollbar width; and a "Right-edge layout" section sets out the two quantities and why they differ. -
e2e/last-column-clearance.spec.tsgained a token matrix —:rootoverrides at 12px and 16px, both preference values, and the zero-reservation case — all asserting the same four invariants. Playwright is not part of CI, so this is manual verification.
Migration#
theme.scrollbarWidth no longer takes a length.
// Before — a length that never reached the scrollbar<ActiveGrid theme={{scrollbarWidth: '12px'}} />
// After — a preference the engine honours<ActiveGrid theme={{scrollbarWidth: 'auto'}} /> // thicker, native width<ActiveGrid theme={{scrollbarWidth: 'thin'}} /> // the default; omit it entirelyThe type change is caught at compile time; there is no silent behaviour change to look for. If you were setting it to make the grid's right-hand strip wider or narrower, that is now automatic: the layout measures whatever the preference costs and adjusts the header padding and trailing clearance to match.
If you override --grid-scrollbar-width in your own CSS, remove that override.
The grid writes the variable at runtime, so yours is ignored for layout, and on
earlier versions it caused a phantom horizontal scrollbar.
[7.6.0] - 2026-07-28 - The filter axis stops accepting work it discards#
Behaviour change for existing consumers. Minor rather than patch: a
server-mode grid with enableFilterBuilder used to render a toolbar button and
a filter panel, and now renders neither. Measured over apps/ and packages/,
the only consumer is a test harness
(apps/admin/app/(main)/test-server-grouping/grid/grid.tsx:130) and
externalFilter has no consumer at all, so the practical blast radius is zero.
The version still moves because a visible affordance disappears.
This closes TD-15, the last pair of API-002 findings and the two the audit
actually named.
Fixed#
-
externalFilterdev-warns in server mode instead of being ignored. The predicate is applied in exactly one place --use-grid-setup.ts, undermode === 'client'-- so on a server-mode grid it was built and never called, with no signal of any kind. It now warns once pergridId.Warn-only, deliberately: there is no affordance to withhold, and applying the predicate to the loaded page instead would filter one page of a server dataset and make the row counts disagree with the data. Where the predicate is used to hide rows, a server-mode grid shows more than the consumer intended -- a visibility gap, not a data leak, since permission and tenant scoping are enforced server-side by the resource layer and never by this predicate.
-
enableFilterBuilderis fail-closed in server mode. The toolbar button and the panel used to render; a user could compose a full nested AND/OR expression, which was evaluated into the same client-only predicate asexternalFilterand then dropped.ServerFetchParamshas no expression field, so nothing reachedfetchFneither. Button and panel are now withheld, with a dev-warn pergridIdexplaining the absence and pointing atF-12.Fail-closed rather than warn-only because a user, not a developer, does the work here -- a console warning never reaches the person losing it. Same reasoning as the editing fail-closed in 7.5.0.
Client mode is unchanged for both props, including a seeded
initialFilterExpressionon a grid that does not enable the builder.
Changed#
-
docs/guides/filter-builder.mdclaimed the composed expression was forwarded inServerFetchParams.filterExpressionand translated server-side by the resource view. No such field exists, in this package or in@workspace/active-resources. The guide now states the client-mode-only reality and points atF-12for the planned server route. -
The same guide claimed the expression was persisted -- serialised to a
?filterExpression=URL parameter underenableUrlSync, and stored under the grid's namespaced key underenablePersistence. Neither driver carries it:url-driver.tshandlespage,pageSize,sort,filtersandsearch;validate-persisted-state.tsacceptssorting,columnFilters,columnOrder,columnSizingandglobalFilter. Documented as consumer-owned, with the round-trip viaonFilterExpressionChange+initialFilterExpression. -
docs/api/grid-props.mdgained a "Filter axis: client mode only" section and per-prop mode notes; the JSDoc onexternalFilter,enableFilterBuilder,initialFilterExpressionandonFilterExpressionChangecarries the same.
[7.5.0] - 2026-07-28 - Two silent misconfigurations now announce themselves#
Behaviour change for existing consumers. Minor rather than patch: two
configurations that used to render a working-looking grid now render a
different -- and honest -- one. Measured over apps/ and packages/, no
consumer configures either combination today, so the practical blast radius
is zero; the version still moves because the contract changed.
Both cases come from the verification of audit finding API-002, not from the
audit itself. The audit named only externalFilter and enableFilterBuilder
(still open as TD-15); these two were found by compile-probing the 24
combinations the GridProps union permits.
Fixed#
-
pagination.mode="infinite"is clamped to server mode. On a client-mode grid the flag used to hide both pagination bars while the client paginated row-model stayed active, so the grid showed one page of 10 with no control to reach the rest -- and then rendered an "All N rows loaded" footer reporting the full row count. A 500-row grid displayed 10 rows and claimed all 500 were loaded. It now falls back to standard pagination, with a dev-warn once pergridIdexplaining that infinite scroll needsfetchFn.Server-mode infinite scroll is untouched -- that is the supported configuration and the only live consumer.
-
Editing is fail-closed when the commit callback does not match the mode.
editModeand its callback are a pair:'cell'/'row'commit throughonDataCommit,'batch'throughonBatchCommit. A mismatch used to leave the cells editable while the commit path dead-ended -- batch with onlyonDataCommitshowed a "Save all" button that returnedfalseand discarded the buffer, and cell mode with onlyonBatchCommitswallowed every commit. Either way the user's work vanished without a signal.The grid now withholds the editing meta (no
startEdit, hence no editable cells) and the BatchToolbar, and dev-warns once pergridIdnaming the missing callback. A read-only grid with no editing callbacks at all is unaffected and never warned about.
Changed#
docs/api/grid-props.mddocumentededitModeas'cell' | 'row'; the type has carried'batch'since batch editing landed, andonBatchCommitwas absent from the table entirely. Both corrected.
[7.4.0] - 2026-07-27 - Server refusals are visible at every grouping level#
Behaviour change for existing consumers, and a removed constant. Minor rather than patch: a failed drill-down now surfaces an error where it used to pass unnoticed.
Fixed#
-
A refused CHILD fetch or LOAD-MORE no longer fails silently. Both paths logged the error and then, in the child case, collapsed the group again -- from the user's seat indistinguishable from a group that simply would not open. Both now set the same
errorstate the top-level fetch already used, so the grid's overlay shows the server's message with a retry.This matters now that
@workspace/active-resources(0.4.0) can refuse a group listing outright withGROUP_CARDINALITY_EXCEEDEDwhen the grouped column has too many distinct values. Adding that server cap without this fix would have produced exactly the silent-failure class the resource mechanism exists to remove.
Removed#
- The hardcoded
fetchSize: 10000for non-leaf group fetches. Measured, it reached nothing: the server ignoresfetchSizeon the group-listing path entirely (a listing returns every group or is refused, it does not page), and the value was clamped tomaxFetchSizeon the way in and then unused. Both levels now ask for the configured block size.
[7.3.0] — 2026-07-26 — Column validation is actually run (EDT-001)#
Behaviour change for existing consumers. Commits that succeed today can be
blocked after this release. Minor rather than patch for that reason, even
though no API changed: meta.validate has been declared and documented since
the beginning and was never called.
Fixed#
-
meta.validatenow runs, before the consumer is told anything. It was declared onGridColumnMetaand documented inguides/editing.mdandapi/columns.md, but nothing read it. Measured before the fix: the validator ran 0 times, the commit succeeded, and the rejected value was delivered toonDataCommit.It now runs on all three commit paths:
- cell mode and row mode — in
useEditingState.commit(), beforeonDataCommit. In row mode every column with a validator is checked, all failures are reported at once, and one failing cell blocks the whole row. The entered values stay in the editor. - batch mode — in
useBatchEditingState.commit(), when the cell is flushed to the dirty buffer rather than at "Save All". Validation belongs to the input: the message can appear on the cell the user is still in, and an invalid value never enters the buffer. Known limitation: a value that reaches the buffer by any other route is not validated.
Sync and async validators are both supported. A validator that throws blocks the commit and reports
grid.edit.validationFailed-- a crashing validator is not an approval. - cell mode and row mode — in
-
A
valueParserreturningundefinedno longer commits an empty value. Such a parser is saying it cannot parse the input. Thatundefinedused to be stored and committed, so the consumer received the column key withundefinedas its value -- which a Prisma update reads as "leave this field alone" andJSON.stringifydrops entirely, failing silently on every layer. The input is now rejected visibly withgrid.edit.invalidValueand the commit is blocked until the value parses.
Added#
grid.edit.validationFailedandgrid.edit.invalidValuetranslation keys (English defaults; override through your ownt).data-slot="editing-error"andaria-label={message}on the cell error affordance, so the message is reachable for screen readers and assertable in tests.
Changed#
- The editing spinner (
isValidating) now covers validation and the consumer commit. One indicator for both phases; the guide only ever promised that there is an indicator. guides/editing.mdcorrected: the error is an icon with a tooltip inside the cell, not text below the editor. That was already the case; the guide described something that did not exist.
What to check before upgrading#
If you declare meta.validate on any column, that validator now decides
whether a commit proceeds. A validator written when it was dead code may
reject more than you expect. The same applies to a valueParser that returns
undefined for values you currently rely on being written.
Known limitation, unchanged by this release: valueParser runs on every
keystroke against a controlled input, so a parser that rejects intermediate
values makes incremental typing impossible. See ROADMAP.md TD-24.
[7.2.0] — 2026-07-26 — Structurele validatie van persisted state (TD-21)#
Gedragswijziging voor bestaande consumenten. State die uit localStorage
wordt hersteld, wordt vanaf nu ook op vorm gecontroleerd, niet alleen op
versienummer. Een slice die niet aan zijn structuur voldoet wordt genegeerd en
valt terug op de default; de overige slices worden gewoon hersteld.
Voor een consument met gezonde opgeslagen state verandert er niets. Voor een consument met corrupte state verandert er twee dingen, beide ten goede:
- Vier slices lieten de grid crashen bij mount (
sorting,columnFilters,columnOrder,columnPinning.leftals niet-array →.filter is not a function), en bleven dat doen bij elke herlaadbeurt tot de gebruiker zijn storage handmatig wiste. Die grid komt nu gewoon op. - Acht slices kwamen corrupt en zonder signaal door tot in de store
(
sorting.descals string,paginationals array,globalFilterals object, …). Die worden nu genegeerd in plaats van doorgegeven.
Wie vertrouwde op het doorgeven van een niet-conforme slice — bijvoorbeeld een
pageSize als string — krijgt vanaf nu de default. Dat is de enige manier
waarop dit als breuk voelt, en daarom een minor bump en geen patch.
Toegevoegd#
src/state/validate-persisted-state.tsmet per-namespace validator-maps (MAIN_STATE_VALIDATORS,LOCK_STATE_VALIDATORS,PINNING_STATE_VALIDATORS). Intern; geen publieke API.- Dev-only
console.warnper genegeerde slice, met slice-naam, grid-id en het soort afgewezen waarde. In productie wordt de slice wél genegeerd maar niets gelogd.
Gewijzigd#
persistenceDriver.loadaccepteert een optionele validator-map en is daarmee de enige poort waar opgeslagen data binnenkomt — ook voor de twee ADR-006-namespaces (lock-state, pinning-state), die tot nu toe niets valideerden.- Een niet-object payload (
null, array, string, getal) wordt als afwezig behandeld in plaats van als leesbaar object. - Het hydratie-opschoonblok in
loadInitialGridStatedraait onvoorwaardelijk. Het hing aanoptions.columnIds, waardoor een directe consument zonder die optie ongefilterde — en vóór deze release crash-gevoelige — state kreeg.
[7.1.0] — 2026-07-26 — Eén uitvoergrens voor CSV/TSV + formule-sanitizer (TD-19)#
Gedragswijziging voor bestaande consumenten. Een geëxporteerde waarde die een
spreadsheet anders zou lezen dan als letterlijke tekst wordt vanaf nu voorzien van
een leidende apostrof. Dat raakt CSV-export én klembord-kopie, inclusief
kolomkoppen, groepskoppen en groepsvoetteksten. =1+1 komt in de cel aan als
'=1+1 in plaats van als de uitkomst 2. De sanitizer staat standaard aan en
heeft in deze tranche geen opt-out; dat is fail-closed en later additief uit te
breiden.
Wat niet verandert: de uitvoer van formatNumber en formatPercent. Een
negatief bedrag blijft onaangeroerd — gemeten in vijf locale-varianten
(-1.234,5, -1,234.5, -1 234,5, -1234,5, -50 %). De regel toetst gedrag,
geen tekenlijst, en hoeft daarvoor geen getallen te parsen.
Toegevoegd#
src/utils/export-delimited.ts— de enige plek waar nog een CSV-veld of TSV-veld wordt gerenderd (csvField,csvRow,tsvField,tsvRow,neutralizeFormula). Vóór deze release deden twaalf losse plekken dat zelf.
Gewijzigd#
- CSV-kolomkoppen worden nu gequote zoals datacellen. Een header met een komma leverde eerder meer kolommen in de kopregel dan in de datarijen.
- Groepskoppen worden nu geëscaped; een groepswaarde met een
"brak eerder de CSV. - Het klembordpad quote nu velden die een tab, newline of
"bevatten. Eerder verschoof één cel met een tab alle volgende kolommen van die rij. exportToCSVGroupedenexportToCSVServerGroupeddeelden byte-identieke CSV-opbouw; die is samengevoegd tot één renderer.
Ongewijzigd, bewust#
- XLSX. Gemeten niet kwetsbaar: SheetJS schrijft een string als
t="str"zonder<f>-element, dus er valt niets te evalueren. Zie DONE voor de drie bewijsniveaus. - JSON- en PDF-export. Die vallen buiten de grens; een neutralisatieteken is daar ongewenst.
[7.0.0] — 2026-07-25 — Verwijderingstranche Deel B: het /server-subpad weg#
Breaking. Het hele @workspace/active-grid/server-subpad is verwijderd. Nu de
sessie-grid ontkoppeld is en de twee insurance-grids naar @workspace/active-resources
zijn gemigreerd, had het oude serveroppervlak geen consument meer. active-grid is
daarmee een puur UI/state/client-adapter-package zonder serveroppervlak.
- Verwijderd (
/server-subpad, bestaat niet meer): de wire-contract-types (GridServerActionRequest/Response,GridActionResult,GridAuthCheck,PrismaModelDelegate), de raw-closure-escape-hatches (rawSort/rawFilter/rawGlobal/rawGroupBy/rawAggregate+ hunRaw*Mapping-types), en de contract-resolver (resolveColumnContract/resolveAllColumnContracts,ResolveAllArgs,ResolvedColumnContract/Map,ServerColumnOverride). - Verwijderd (root-export): de
createServerFetchFn-client-adapter (+CreateServerFetchFnConfig) enqueryBuilder(+PrismaWhereClause/PrismaFilterCondition). Server-mode loopt uitsluitend viacreateResourceServerFetchFn+@workspace/active-resources. - Behouden (client-/UI-materiaal): de per-kolom-
column.server-declaratie metscalarField/byField/… en hetServerColumnContract-type — nu uitsluitend client-side hydration-materiaal (extractWireContractinuse-grid-setup, TD-3-snoeiing van een verouderde sort/filter). Er is geen server-side resolver meer. - Migratie: een grid dat nog
createServerFetchFngebruikte migreert naar een resource-view (active-resources) +createResourceServerFetchFn; de enige niet-resource-uitzondering is de Redis-sessie-grid (ADR-004), een hand-rolledActionResult-action zonder wire-contract. - ADR-013/017-statussen bijgewerkt (server-resolutie nu volledig weg); de docs onder
docs/apiendocs/guideschirurgisch bijgewerkt (drift-detector groen),DATA-ACCESS.mdop de eindtoestand.
[6.0.0] — 2026-07-23 — Verwijderingstranche Deel A: de grid-server-factory weg#
Breaking (@workspace/active-grid/server). Nu alle 26 grids naar
@workspace/active-resources zijn gemigreerd, is het dode deel van het oude
serveroppervlak verwijderd:
- Verwijderd: de factory
createGridServerAction(+CreateGridServerActionConfig),generateDmmfDefaults(+DmmfFieldMeta/DmmfModelMeta), de group-by-runtime (handleGroupByRequest), en de publiekewarnReserved/warnStripped-exports. Alle zes per-database grid-actions die op de factory leunden zijn mee verwijderd (getActiveGridDataAction,getPaginatedPensionDataAction, plus de vier dode pension-acties). - Behouden (gereduceerd oppervlak): de wire-contract-types
(
GridServerActionRequest/Response,GridActionResult,GridAuthCheck,PrismaModelDelegate),queryBuilder,resolveAllColumnContracts, de raw-mappings,extractWireContracten decreateServerFetchFn-client-adapter — levend gehouden door de Redis-sessie-grid (ADR-004) en de geparkeerde insurance-grids. Volledige verwijdering in Deel B; voorwaarden indocs/DATA-ACCESS.md(merged intoARCHITECTURE.md§ Datatoegang en eigendom in 2026-09). - ADR-011 ingetrokken; ADR-013 + ADR-013-A + ADR-017 (deels) superseded.
[5.2.0] — 2026-07-23 — LookupGrid capability-doorgifte#
LookupGridaccepteert een optioneleserverCapabilities-prop en geeft die door aan de interneActiveGrid. Additief en niet-breaking: legacy-lookups zonder de prop gedragen zich ongewijzigd. Nodig voor resource-backed lookups (person-lookup, participant-lookup) die de capability-map moeten doorgeven.
[5.1.0] — 2026-07-22 — Capability-keten tot op operatorniveau#
Breaking (resource-grids): GridServerCapabilities is niet langer de
drie-key-array-vorm ({sort, filter: string[], group?}) maar de volledige
structurele spiegel van active-resources' ResourceCapabilities
({filter: Record<id, {filterType, nullable?, operators}>, sort, group, aggregate, search, presets}). Consumers geven de deriveCapabilities-map nu
ongesplitst door; de eerdere hand-mapping verdwijnt.
- Filtercomponenten leiden hun operatorlijst af uit de capability-map
(gestampt als
meta._serverFilterCapability), niet meer uit de ingebouwde per-type-defaults.allowedOperators/operatorPresetblijven als presentatie-narrowing: doorsnede met de map, kan alleen versmallen (expliciete entry buiten de map → genegeerd + dev-warn). - Kolom-
type:is presentatie; wijkt hij af van het capability-filterTypedan wordt de filter fail-closed uitgeschakeld met dev-warn (select-presentatie is compatibel met elk type — set-vorm). - Hydratie-snoei nu ook op operator-granulariteit: een persisted/URL-filter met een niet-meer-toegestane operator wordt vóór de eerste fetch gesnoeid.
- Toolbar-zoekbox is capability-gestuurd: bij
search: falseonderdrukt + dev-warn;useToolbarSearchWarningcheckt op het resource-padcapabilities.search(de legacyserver.global-check vuurde daar onterecht). - De dev-only drift-check in
createResourceServerFetchFnvergelijkt de volledige map canoniek (tot op operatorniveau), niet alleen key-sets. - Nieuwe exports:
GridServerFilterCapability,GridServerFilterType,GridServerAggregateOp.
[5.0.0] — 2026-07-20 — Base UI-migratie#
Alle Radix-afhankelijkheden zijn vervangen: de grid-menu's en de filter-popover zijn skins over de active-ui-componenten (Base UI) geworden. Dit lost de klasse runtime-fouten op waarbij een Radix-item in een Base UI-root zijn context niet vindt ("MenuItem must be used within Menu") — de menu-roots kwamen al uit active-ui, de items nog uit eigen Radix-primitives.
Vereist @workspace/active-ui >= 2.0.0 (Base UI). Zie active-ui's
CHANGELOG [2.0.0] voor de volledige API-verschillen.
Migratie-instructie (voor de geneax-werkstroom)#
De complete migratieroute — inclusief de stille faalklassen die typecheck
en tests niet vangen, detectie-greps en de handmatige-naloop-checklist —
staat in packages/active-ui/docs/MIGRATION-BASE-UI.md. De stappen
hieronder zijn de active-grid-specifieke samenvatting.
Deze release wordt gesynct in de gecoordineerde geneax-werkstroom, sámen met active-ui 2.0.0 — nooit los (het sync-script blokkeert beide tot die werkstroom; zie DATYX-WERKWIJZE.md par. 11.9). Stappen per repo:
- Sync active-ui én active-grid in één werkstroom
(
node scripts/sync-package.mjs <naam> <repo>na het opheffen van deSYNC_BLOCKED-entries). - package.json's daar: verwijder alle
@radix-ui/*-deps enradix-uiuit active-ui/active-grid/active-content-equivalenten; voeg@base-ui/react(^1.6.0) toe aan active-ui én aan elk package dat zelf Base UI-parts componeert;pnpm install. - Migreer de repo-eigen consumers volgens active-ui CHANGELOG [2.0.0]
(Breaking-lijst):
asChild→render, menu-onSelect→onClick, Select/ToggleGroup/Checkbox/Accordion/Tooltip-signaturen, focus-props,--radix-*-CSS-variabelen. - Draai de eigen typecheck + tests; de fouten wijzen exact de resterende
call-sites aan (de typecheck vangt alles behalve
onSelect-props — die verdwijnen stilzwijgend, grep er expliciet op).
Breaking#
FilterPopoverTrigger: was Radix'Popover.Trigger(metasChild), is nu active-ui'sPopoverTrigger(Base UI) — compositie viarender={<El/>}. Alle zes ingebouwde filters zijn mee-gemigreerd; host-apps die zelf filter-popovers bouwen migreren hunasChild-sites.FilterPopoverContent: props zijn nu active-uiPopoverContent-props;onOpenAutoFocusbestaat niet meer (gebruikinitialFocus={false}).GridMenuItem/GridContextMenuItemen verwanten: props volgen de active-ui menu-items (Base UI):onSelect→onClick, keep-open viacloseOnClick={false}.GridContextMenuSubTrigger/GridMenuSubTriggerrenderen geen eigen chevron meer (de active-ui sub-trigger brengt die mee).GridContextMenu(context-menu-wrapper): trigger-compositie viarender; hetchildren-element moet een enkel React-element zijn.
Removed#
- Dependencies
@radix-ui/react-context-menu,@radix-ui/react-dropdown-menuen@radix-ui/react-popover.
Fixed#
- "MenuItem must be used within Menu" bij het kolom-kiezer-menu — de
gemengde Radix/Base UI-menustack; opgelost door de stack-uniformering
(integratie-test
enableHiding: falseis weer groen zonder testaanpassing).
[4.4.1] — 2026-07-01#
Fixed#
- Double server query on a hydrated filter (server mode) — a server-mode
grid whose filter came from the URL (
enableUrlSync) or localStorage (enablePersistence) fired two queries on cold mount: an unfiltered fetch from the empty first render, then a filtered fetch once hydration and the 300ms typing-debounce settled (a brief unfiltered flash, and the heaviest query run for nothing). The data-orchestrator now holds its first server fetch until the initial state is resolved (an internalhydratedsignal fromuse-grid-state) and reads the settled (raw) filter for that first fetch instead of the debounced value, so exactly one already-filtered query fires. Subsequent (typing) fetches stay debounced; a grid with no hydrated filter fetches immediately with no added delay; client mode has no server fetch and is unaffected. Internal only, no public API change. The v4.4.0 forward-sync andstatePrioritymachinery (loadInitialGridState,resolve-initial-state,use-filter-sync) is untouched. Covered by a fetch-count regression guard (URL- and localStorage-hydrated → one filtered fetch; no-filter → one immediate unfiltered fetch).
[4.4.0] — 2026-07-01#
Added#
statePriorityprop — a per-slice conflict-resolution strategy between the URL (enableUrlSync) and localStorage (enablePersistence) for the four contested state slices (sorting,columnFilters,globalFilter,pagination). Four values:'url-first'(default),'localstorage-first','url-only','localstorage-only'. Resolution is per-slice, so a URL that carries onlysortingstill lets localStorage supply thecolumnFilters; a per-source-block merge could not express this. The column-shape slices (columnVisibility,columnOrder,columnSizing,columnPinning,rowPinning,expanded) live only in localStorage and are outside the strategy — they always follow localStorage whileenablePersistenceis on. The default'url-first'reproduces the pre-4.4.0 merge behaviour exactly, so existing consumers are unaffected. New pure resolver instate/resolve-initial-state.ts;GridStatePriorityis exported. Covered by a full strategy-by-conflict-combination matrix plus a regression anchor proving the default equals prior behaviour.
Fixed#
-
select(multi-value) filters lost on URL rehydration — aselect/badge filter holds a bare array value (string[]/number[]), but the URL driver serialized it through the scalarcontainsfallback, so it came back as an operator-object and the select filter (which readsArray.isArray) silently dropped it when bothenableUrlSyncandenablePersistencewere on (e.g. on back-navigation). The URL driver now round-trips array values losslessly via anintransport token (col:in:v1,v2), preserving the array shape and coercing numeric-looking elements back to numbers so numeric selects round-trip as numbers. Sorting and pagination were never affected. Links produced by older versions using thecontains:a,bform still deserialize (fail-safe backward-compat), so existing shared links keep working. Covered by new round-trip and backward-compat tests. -
Inline filters (
text/number/date) wiped or desynced a URL-hydrated value on cold mount — these components keep a debounced local input state that was seeded once, before the async engine hydration, so it captured the empty pre-hydration value.useFilterSynconly reset local state on an external clear, never adopting an externally-SET value. Fortext/numberthe debounce apply-effect then pushedsetFilterValue(undefined)with the stale empty value and cleared the freshly-hydrated filter (the URL lost itsfilters=param a moment after load);dateshowed a blank picker while the filter stayed active.useFilterSyncis now a forward-sync hook: it adopts the engine value into local state on every external change (set and clear) via asyncFromExternalcallback, keyed on[currentFilter]so typing never triggers a re-adopt and a debounced commit converges to a no-op. The apply-effect's clear branch no longer clobbers an externally-set value the user has not touched, while still allowing user clears and the harmless empty no-op.interval/select/booleanwere already controlled-from-store (immune) and the toolbar global-search shares the same forward-sync shape. Covered by new integration tests that mount the real filter component with a URL/localStorage- hydrated value (the exact gap that hid this defect) plus a typing-still-applies test.
[4.3.1] — 2026-06-28#
Fixed#
- Numeric
select(dropdown) filters — a multi-valueselectfilter on a numeric column (filterType: 'number', inherited from the DMMF forInt/Float/Decimal/BigIntfields) now coerces its values to numbers before building the{in: [...]}predicate. The select path previously stringified every value unconditionally, so a fixed-value dropdown on a numeric column emitted strings that the database rejects on a numeric column. Numeric dropdown filters are now supported generically, alongside the existing string-enum dropdowns; stringselectfilters are unchanged. Covered by two new unit tests.
[4.3.0] — 2026-06-17#
Added#
- Generic
intervalfilter — a newColumnFilterTypefor matching a row's interval (two numeric boundary fields) against a numeric query range, with awithin(containment) /overlapmode toggle. Strictly numeric and domain-agnostic: the grid knows nothing about what the numbers represent (day-numbers, prices, versions). Pieces:IntervalFilterValue({from: number | null, to: number | null, mode: 'within' | 'overlap'}) andIntervalModeintypes/filters.ts. The filter is inactive when both bounds arenull.IntervalFiltercomponent (components/filters/interval-filter.tsx), wired intoFilterWrapperforfilterType: 'interval'. Renders the mode toggle, the popover and the apply/clear plumbing; the two-bound entry defaults to a pair of numeric inputs and is replaceable viameta.intervalInput(IntervalInputProps) — the seam where a host plugs in a domain entry (e.g. a year picker that maps to day-numbers) without the grid knowing what the numbers mean.buildIntervalWhere(value, {loField, hiField})inutils/build-interval-where.ts— a pure, framework-neutral helper that turns anIntervalFilterValueinto a Prisma-shapedwherefragment. No Prisma import, no domain knowledge; a server resolver composes the result. Semantics:withinemitslo >= from AND hi <= toper given bound (agte/lteexcludes a null row bound by construction, so open-ended rows never match a closed query side);overlapis null-tolerant on both bounds.- i18n keys
grid.filter.intervalWithin/grid.filter.intervalOverlap(host-translated; English defaults provided).
Notes#
- The declarative
column.serverquery-builder path skipsintervalcolumns with a dev-warning (they carry a value, not an operator); resolve them server-side viabuildIntervalWhere. A declarativefilter:intervalmapping may follow if a project adopts the contract for interval columns.
[4.2.2] — 2026-06-08 — Laatste-kolom resize-lijn op de grens + structurele viewport-clearance#
Patch release. De zichtbare resize-lijn van de laatste kolom staat nu op de
kolomgrens (consistent met de middenkolommen), en de card-rand-vrijwaring komt
van een structurele viewport-clearance in plaats van de oude lijn-inset.
Geen API-wijziging; de CSS leeft in @workspace/active-ui (gespiegeld). Architectuur-besluit en
meet-onderbouwing: DECISIONS.md ADR-020.
Changed#
- TD-11 inset-rol vervangen door structurele viewport-clearance (HD-holder).
NIET teruggedraaid — het onderscheid is essentieel: de TD-11-inset
(
--grid-resize-handle-edge-gap, 8px) trok de laatste-kolomlijn naar binnen om hem van de card-rand te houden, waardoor hij los van de kolomgrens "zweefde" (inconsistent met middenkolommen — GAP-2). Die card-rand-rol verhuist nu naar een clearance die in de scroll-content-breedte is gereserveerd (--grid-edge-clearance, default =--grid-scrollbar-width= 8px), gekoppeld aan de bestaande flex-EDGE_RESERVE(8px) inuse-flex-layout.ts. De resize-::after-lijn van[data-edge="last"]staat daardoor op de kolomgrens (left:100%; translateX(-100%), edge-gap 0); de 12px hit-area en de TD-11 e2e-geometrie blijven ongemoeid. - Waarom dit nodig was (gemeten, niet beredeneerd): de oude container-gutter
(
scrollbar-gutter:stable+ headerpadding-right) is niet onvoorwaardelijk — bij een horizontaal-overflowende grid zónder verticale scrollbar collapst de gutter aan het scroll-einde tot ~1px, waardoor een naïeve edge-gap-0 de lijn op de card-rand zou zetten (de TD-11-regressie). Alleen clearance in de scroll-content-breedte (niet container-padding/-margin, die worden gedropt) overleeft het scroll-einde.
Added#
- e2e
last-column-clearance.spec.ts— regressie-net: lijn-op-de-grens (L→cell ≈ 0, geen sliver) en card-clearance in álle staten: niet-gescrold (L→card ≈ 19), h-scroll-einde zónder vscroll (L→card ≈ 9), h-scroll-einde mét vscroll (L→card ≈ 9, scrollbar flush),fit-h-scroll = 0waar kolommen passen, en pinned-right (sticky + pin-shadow + lijn op de grens). De INV-3-drempel is op het productiegetal gepind (clearance 8 →L→card ≥ 4, waardoor de 1px-scroll-einde-regressie de test rood maakt). De bestaande TD-11 e2e (resize-handle-last-column.spec.ts) blijft groen.
Invariant#
- INV-18 (structurele laatste-kolom-clearance): de scroll-content reserveert
--grid-edge-clearancetrailing-breedte bovenop de kolom-total, en de flex-fill reserveert exact hetzelfde (EDGE_RESERVE). Beide MOETEN gelijk blijven — lopen ze uiteen, dan breekt óffit-h-scroll = 0(content > viewport) óf INV-3 (clearance < fill-reserve). Zie DECISIONS ADR-020.
[4.2.1] — 2026-06-07 — Regressietest: default-paginering-contract#
Patch release. Geen gedragswijziging — borgt bestaand gedrag met een test.
Added#
- Regressietest
default-pagination-contract.test.tsxdie vastlegt dat een grid zónder paginering-props opent op page-size 10 met selector[10, 20, 50, 100]. Dit gedrag komt uit twee gescheiden code-paden die gereconcilieerd moeten blijven: de start-grootte uitstate/load-initial-state.ts(initialPageSize || 10) en de selector-opties uitcontrols/pagination.tsx(config.pageSizeOptions ?? globalConfig.pagination.pageSizeOptions→DEFAULT_GRID_CONFIG). Ze vallen alleen samen omdat 10 lid is van de default-set. Consumers (datyx-v3 P1/P1b) leunen platform-breed op deze default; de test faalt zodra één van beide defaults wegdrijft.
[4.2.0] — 2026-06-02 — Context-menu sluit by default bij actie-klik (+ keepOpen opt-in)#
Minor release. Een nieuwe additieve keepOpen-prop op context-menu-items
(minor) plus een gecorrigeerde default voor het sluitgedrag (Changed). Gevonden
tijdens het groen maken van de e2e-suite (R-3 spec-onderhoud).
Changed#
- Een custom context-menu-actie-item sluit nu het menu bij klik (web-conventie),
in plaats van open te blijven. Voorheen riepen álle custom items (en submenu-
children) onvoorwaardelijk
e.preventDefault()aan inonSelect(components/menus/context-menu-content.tsx), waardoor het menu open bleef — terwijl alle ingebouwde items (copy, export, row-edit, row-pin, reset- columns) wél sluiten. Die inconsistentie binnen hetzelfde menu was het bewijs dat het geen bewuste keuze was. Custom items sluiten nu net als de ingebouwde; de actie vuurt ongewijzigd. - Niet-brekend in de praktijk: een grep over alle consumers (admin/pension/
insurance/pepp/portal/mdm) toonde dat geen enkele custom menu-item op het open-
blijven-gedrag leunt (alle acties zijn one-shot: navigeren, dialog openen,
server-action; 0 items gebruiken
isLoading/keepOpen). Sluiten-bij-klik is daar de correcte UX (het menu blijft o.a. niet meer achter een confirm-dialog hangen).
Added#
keepOpen?: booleanopContextMenuItem— opt-in om het menu na een klik open te houden, voor items die herhaald gebruikt worden (multi-select-stijl). Geldt voor top-level items en submenu-children. Items metisLoadinghouden het menu eveneens open (defensief vangnet; merk op datisLoading-items in de praktijkdisabledzijn en dus niet selecteerbaar —keepOpenis de functionele opt-in).
Tests#
e2e/context-menu.spec.ts— de bestaande "klik sluit het menu"-test klopt nu zonder wijziging; nieuwe test borgt dat eenkeepOpen-item het menu open houdt én de actie vuurt. De/test-client-grid-harnas heeft eenkeepOpen-demo-item gekregen (5 locales).
[4.1.9] — 2026-06-02 — Escape annuleert (committeert niet) met commitOnBlur + keyboard-navigatie#
Patch release. Correctness-fix in de keyboard-navigatie Escape-afhandeling; geen publieke API-wijziging. Gevonden tijdens het bouwen van de keyboard-nav-E2E (R-3-subset).
Fixed#
- Escape in een open cel-editor committe de waarde i.p.v. te annuleren wanneer
commitOnBlurénenableKeyboardNavigationaanstonden. De keyboard-nav Escape-tak (hooks/use-keyboard-navigation.ts) rieptarget.blur()aan om de focus terug naar de cel te brengen. Een programmatischeblur()vuurt eenfocusoutmetrelatedTarget = null;handleGridBlur(components/grid.tsx) leest dat als "focus verliet het grid" en committe — metcommitOnBluraan — de waarde vóórdat decancelEdit()van de editor was doorgewerkt (de blur-commit won van de annulering). De handler herfocust nu de cel zondertarget.blur(): focus verplaatst naar een element ín het grid (relatedTarget= de cel), zodathandleGridBlureen no-op is en de annulering wint. Escape annuleert nu ongeachtcommitOnBlur. Raakt o.a. de pension glide-path-editor (de enige productie-grid metcommitOnBlur).
Tests#
components/__tests__/active-grid-integration.test.tsx— nieuwe regressietest (Escape + commitOnBlur + keyboard-nav → geen commit, originele waarde terug); bewezen faalt-zonder-fix (committe "Typed but escaped"), slaagt-ermee.- Tevens geborgd in de nieuwe E2E
e2e/keyboard-nav.spec.ts(zie DONE — R-3 keyboard-nav-subset).
[4.1.8] — 2026-06-02 — Single-click-edit reset de cel niet meer bij interactie met de open editor#
Patch release. Correctness-fix in de cel-edit-start-logica; geen publieke API-wijziging.
Fixed#
- Met
editTrigger="click"ging een gekozen<select>-optie verloren: de cel bleef op de oude waarde staan. Een klik op een optie (de native<select>zit ín de cel) bubbelt naar deonClickvan de cel, die meteditTrigger="click"opnieuwhandleEditStartaanriep.startEdit()her-initialiseert de editing-state vanaf de originele rij-waarde, waardoor de net-gekozen waarde werd weggegooid (components/sections/body-cell.tsx).handleEditStartdoet nu niets meer wanneer de cel al in bewerking is (isSameCell), zodat een klik die vanuit de open editor omhoog bubbelt de bewerking niet herstart. BijeditTrigger="doubleClick"speelde dit niet (enkele klikken starten daar geen bewerking) — vandaar dat het alleen met enkele-klik-bewerken zichtbaar was. Raakt ook tekst/getal-editors waar een tweede klik in het veld de tussentijdse invoer zou resetten.
Tests#
components/__tests__/active-grid-integration.test.tsx— nieuwe regressietest (single-click select-edit → gekozen optie blijft staan); bewezen faalt-zonder-guard (reset naar origineel), slaagt-ermee.
[4.1.7] — 2026-06-02 — Datum kiezen committeert niet langer de oude waarde (TD-13 focusout-pad)#
Patch release. Voltooit de TD-13-portal-uitsluiting; geen publieke API-wijziging.
Fixed#
- Een datum kiezen in de cel-editor-kalender verwierp de keuze en behield de
oude datum. TD-13 ([4.1.5]) gaf de document-
mousedown-handler een portal-uitsluiting, maar het ouderefocusout-pad (handleGridBlur,components/grid.tsx) had die niet. Bij het klikken op een kalenderdag verschuift de focus van de editor-input naar een knop in de Radix-popover- portal buiten de grid-DOM;handleGridBlurzag eenrelatedTargetbuiten het grid en committe — met de oude waarde, want deonChangevan de kalender (de nieuwe datum) draait pas op declickná defocusout. De editor sloot, de keuze ging verloren. Beide commit-paden delen nu éénisWithinEditorPortal-helper, zodat focus/klik in een editor-eigen floating-layer (de date-picker) noch committeert noch annuleert; de edit blijft open tot de gebruiker de datum kiest en daarna echt buiten klikt.
Tests#
components/__tests__/active-grid-integration.test.tsx— nieuwe regressietest voor hetfocusout-pad (focus naar de date-popover → géén commit); bewezen faalt-zonder-fix, slaagt-ermee. De bestaande TD-13-mousedown-popover-test blijft groen.
[4.1.6] — 2026-06-02 — Focus-steal bij single-click-edit + keyboard-navigatie#
Patch release. Correctness-fix in de keyboard-navigatie focus-afhandeling; geen publieke API-wijziging.
Fixed#
- Met
editTrigger="click"énenableKeyboardNavigationkreeg de cel-editor wel de bewerk-ring maar geen cursor. Eén klik zette tegelijk de focused-cell én opende de editor; de editor-input auto-focuste tijdens de commit-fase, waarna de "scroll-to-focus"-useEffectinhooks/use-keyboard-navigation.tsde cel-<div>herfocuste en de caret weer uit de net-gemounte input trok (ring zichtbaar, geen cursor; pas een tweede klik plaatste de cursor). De effect herfocust de cel nu alleen wanneer de focus nog niet ín de cel zit (!el.contains(document.activeElement)), zodat pijltjes-navigatie tussen cellen onveranderd werkt maar een interactief kind (de open editor-input) zijn focus behoudt. Gevonden tijdens het bouwen van de admin editing-test-harnas.
Tests#
components/__tests__/active-grid-integration.test.tsx— nieuwe regressietest (single-click + keyboard-navigatie → editor-input heeft focus); faalt zonder de guard, slaagt ermee.vitest.setup.tspolyfillt nuElement.prototype.scrollIntoView(jsdom implementeert het niet), zodat de scroll-to-focus-effect onder test draait.
[4.1.5] — 2026-06-02 — Commit open cel-edit bij klik buiten het grid (TD-13)#
Patch release. Correctness-fix voor commitOnBlur; geen publieke API-wijziging
(de commitOnBlur-prop bestond al — deze fix laat hem zijn gedocumenteerde
contract "clicking away should save" ook nakomen bij een klik op een
niet-focusbaar element buiten het grid).
Fixed#
- Een klik buiten het grid op een niet-focusbaar element (pagina-achtergrond,
een kop, een kaart) commit nu de openstaande cel-edit wanneer
commitOnBluraan staat. Voorheen was het enige commit-op-blur-mechanisme de wrapper-onBlur(handleGridBlur,components/grid.tsx), die alleen vuurt bij een echtefocusout— d.w.z. wanneer de focus naar een ander focusbaar element verschuift. Een klik op een niet-focusbaar element verschuift de focus in de browser niet, dus er kwam geenfocusouten de edit bleef stil openstaan ondankscommitOnBlur; een daaropvolgende navigatie/refresh/unmount kon de pending edit alsnog verliezen (TD-13, Spoor B #25). Headless geverifieerd (mousedown-helft); de focus-helft is gedocumenteerd DOM-gedrag.
Added#
- Document-niveau
mousedown-handler (components/grid.tsx), alleen geattacht wanneercommitOnBluraan staat. Hij spiegelt de commit-beslissing vanhandleGridBluren hergebruikt dezelfdecommit()(L7 — geen nieuw commit-pad). Klikken binnen het grid worden genegeerd (cel-logica regelt die al), en klikken binnen een editor-eigen portal — met name de date-picker- Popover, die Radix buiten de grid-DOM rendert ([data-radix-popper-content-wrapper]/[data-slot="popover-content"]) — committen bewust niet, zodat een datumkeuze in de kalender de edit niet voortijdig afsluit.
Changed#
- Re-entrancy-guard in
useEditingState.commit(hooks/use-editing-state.ts). Eén klik op een focusbaar element buiten het grid kan zowel de nieuwe document-mousedown-handler als de daaropvolgendefocusoutin dezelfde tick triggeren; zonder guard zouden beideonCommitaanroepen (dubbele server-write). EencommittingRef-vlag dedupliceert de in-flight commit en wordt in eenfinallyweer vrijgegeven.
Tests#
components/__tests__/active-grid-integration.test.tsx— vier nieuwe TD-13- tests: commit-bij-klik-buiten (faalt-bij-regressie), géén commit bij klik in de date-popover-portal, géén commit bij mousedown binnen het grid, en géén outside-commit wanneercommitOnBluruit staat (Escape blijft cancelen). De bestaandeuse-editing-state-blur.test.ts-ankers blijven ongewijzigd groen.
[4.1.4] — 2026-06-01 — Scroll-to-top bij nieuwe query in interne-scroll-grids (R-28)#
Patch release. Gedrags-correctie in een vaste-height server-mode-grid; geen
publieke API-wijziging (een interne datasetKey is door de useGridSetup-
return geregen, niet geëxporteerd).
Fixed#
- Bij een nieuwe query (filter/sort/search) scrollt de interne viewport van een vaste-height server-mode-grid terug naar de top. Voorheen bleef de interne scrollbar staan waar hij stond: na een nieuwe query laadde een verse page-0, maar de viewport keek nog naar de onderkant in plaats van de eerste rij van het zoekresultaat — afwijkend van de conventie (AG Grid e.a. brengen je bij een nieuwe query naar de top). De omgekeerde-richting-bevinding van de R-28-verificatie: de hypothese was "scroll verspringt", de werkelijkheid was "scroll werd te goed behouden → er ontbrak een reset".
Changed#
- Trigger is de orchestrator-
datasetKey(use-data-orchestrator.ts), geëxposed via deuseGridSetup-return en geconsumeerd door eenuseLayoutEffectincomponents/grid.tsx(vlak naast — en in dezelfde pre-paint-scrollTop-familie als — de bestaande group-collapse scroll-correctie, L7). OmdatdatasetKeyop de gedebouncede filter/search-waarden gebouwd is, vuurt de reset één keer per toegepaste query, nooit per toetsaanslag.datasetKeybevat geenpageIndexen verandert niet bij rij-mutaties (patchRow/addRow/removeRow), dus pagineren en rij-mutaties resetten de scroll niet — zonder extra guard. - Scope strikt: server-mode + vaste height (interne overflow-auto-viewport).
height="auto"/document-scroll-grids vallen erbuiten (guard!isAutoHeight); daar zou een reset de hele pagina laten verspringen — een aparte UX-vraag, geparkeerd als ROADMAP R-31.
Verification#
- typecheck 0, lint clean (
eslint .exit 0), 1379/1379 tests groen (98 files; +4 t.o.v. de 1375-baseline). Nieuwe testscroll-to-top-on-query.test.tsx: query-reset post-debounce (niet per toetsaanslag), rij-mutatie-behoud, auto-height-uitsluiting, en coëxistentie met de group-collapse-correctie. Geneax-1 mirror gesynct (§11.9, src + docs, geen npm-dep): typecheck + test groen identiek.
[4.1.3] — 2026-06-01 — Server-grouped JSON-export-consistentie (TD-16)#
Patch release. Correctness-fix in een download-pad, geen API-wijziging.
Fixed#
exportToJSONServerGroupedpast nu devalueFormattertoe op leaf-data, consistent met zijn sibling-formats. De server-grouped JSON-leaf-export schreef leaf-waarden rauw viaitem[col.id], terwijl alle siblings de formatter wél toepassen: de server-grouped CSV/Excel/PDF-varianten viabuildServerGroupedExportRows(utils/export-utils.ts:713) en de client-exportToJSONviaformatCellValue. De JSON-leaf-tak (utils/export-utils.ts:970) is nu door dezelfde formatter/value-resolutie geroute als de 713-sibling:applyFormatter(col, val, item)met fallback op de rauwevalen''voor null/undefined. Een grid met bijv. een currency-valueFormatterlevert in server-grouped JSON-export voortaan"€ 1234.50"in plaats van1234.5.- Reikwijdte-noot (713 vs 970). De ROADMAP-entry suggereerde dat de directe
property-read
item[col.id]op zowel 713 als 970 een gedeelde root was en "beide samen" gefixt moest worden. Tegen de code blijkt dat onjuist: in server-grouped mode is leaf-data platteTData[]vanfetchFn, geenRow<TData>-objecten, dusgetValue/de kolom-accessor bestaat daar niet — de directe property-read is op beide plekken noodzakelijk en legitiem. 713 past de formatter al correct toe en is het te kopiëren sibling-patroon; het enige defect zat op 970 (ontbrekende formatter). 713 is ongemoeid gelaten.
[4.1.2] — 2026-06-01 — Stale-while-revalidate fallback verhuisd naar de cache-store (TD-17)#
Patch release. Interne refactor, geen consumer-zichtbaar gedrag.
Changed#
- Stale-while-revalidate fallback van een render-
useRefnaar deBlockCacheStoreverhuisd. De "last good page"-snapshot die de vorige rijen zichtbaar houdt tusseninvalidate(...)en de fresh-fetch-resolve (ADR-016 stale-while-revalidate) lag in eenlastGoodPageRefinuse-data-orchestrator.ts, tijdens render geschreven én gelezen. Die ref is vervangen door een plain-field mirror op de store, gelezen via een nieuwe interne methodeBlockCacheStore.peekPageWithFallback(...)— een render-time write in dezelfde categorie als het bestaandepageSliceCache-render-write-precedent (geennotify(), geen re-render). Het gedrag is byte-identiek: dezelfde fallback-mechaniek, alleen een andere drager. De ADR-016-anti-flicker-garantie blijft behouden (geen empty-flash / geen extra blank frame bij filter/sort/cross-block-transitie) en de publiekeOrchestratorResult-shape is ongewijzigd.BlockCacheStoreis internal-only — geen publieke API-wijziging. - Regressie-anker: de geharde
GRID-ARCH-16-tests inuse-data-orchestrator.test.tsleggen de fallback-zichtbaarheid in het invalidatie-venster nu met harde asserts vast (de oudeGRID-ARCH-06liet dat achter een conditioneleif (... && isRefetching)die vacuüm kon slagen).
[4.1.1] — 2026-05-30 — Filter/a11y closeout (TD-7, TD-8, TD-9)#
Patch release. Formele afhechting van drie items waarvan de code al in de
working tree zat — gebundeld gecommit (repo-tag v1.1.116) maar nog niet in
een eigen active-grid-release vastgelegd; de [4.1.0]-entry hieronder
markeerde ze daar nog als deferred backlog. De fixes zijn in eerder werk
ontstaan; deze release verifieert (tests groen) en documenteert ze.
Fixed#
- TD-9 — Client-side
evaluateFilterdeed voorequals/notEqualseen string-vergelijking, ook opnumber- endate-kolommen. Een toegewijd equals/notEquals-blok routeert nu op het runtime-type van de filterwaarde (number → numeriek,Date→ dag-gelijkheid, string → case-insensitive text), zodat randgevallen als cel"5.0"vs filter5en leading zeros correct matchen. Client-mode-only; het server-/contract-pad was al correct. Tests:src/utils/filter-utils.value-shape.test.ts+src/utils/filter-utils.test.ts. - TD-7 — Header-sort was muis-only: de sort-actie zat op een
<div>zondertabIndex/role, dus keyboard-only-gebruikers konden een kolom niet sorteren. De label is nu keyboard-bereikbaar (role="button"+tabIndex+ Enter/Space-handler), naast de al bestaandearia-sort. Test:e2e/header-sort-keyboard.spec.ts. - TD-8 — Drempel-stijl numerieke kolommen (score, bedrag, leeftijd,
percentage) hadden geen manier om standaard op ">=" te filteren — een
gebruiker die "4" typte kreeg exact-match. Opgelost met een nieuw
thresholdcolumn-type-preset ingetDefaultColumnTypes(eennumber- variant metdefaultFilterOperator: 'greaterThanOrEqual'). Hetnumber- preset blijft ongewijzigd op exact-match. Patch, geen minor:getDefaultColumnTypesis een@internalGeneax-helper buiten de externe semver-surface, endefaultFilterOperatorbestond al als publiek veld. Test:src/config/column-type-presets.threshold.test.ts.
Notes#
- Provenance + de drift-correctie op de stale TD-9-regelnummers uit de oude ROADMAP-tekst: zie de gecombineerde entry in DONE.md.
[4.1.0] — 2026-05-25 — Filter-correctness sweep + resize-scroll root-fix#
Minor release. Bundles four related changes that landed in one investigation arc: a UX-coherence improvement on right-aligned columns, two filter-correctness fixes from different bug classes, and a root-cause fix for a scroll-position regression on resize-handle mousedown.
Two notes for later readers, made explicit so the arc is reconstructable:
- The two filter-bug fixes pak verschillende klassen aan. Variant 3
(DMMF
filterType-merge +Number-cast) is a value-shape fix: a string-typed value flowing through an operator that expects a number. The type-bound operator-defaults aanscherping is an operator-availability fix: an operator offered by the UI on a type where it is semantically meaningless or unsafe. Both surfaced as "filter werkt niet op number-kolom" but require different solutions — see TD-10 indocs/ROADMAP.mdfor the systematic value-shape-audit that catches the remaining members of variant 3's class. - TD-12's fix neemt de root weg, geen symptoom-herstel. Five
iterations of runtime instrumentation eliminated four plausible
mechanisms before pinning the actual root:
--grid-total-widthwas being computed fromObject.values(widths).reduce(sum)insidewriteCssVarsToContainers, and the live-resize-subscriber's partial single-column write made that sum collapse to one column's width — triggering a CSS-cascade min-width collapse that shrunk the body content below viewport-width and clamped scrollLeft to 0. The fix decouples--grid-total-widthfrom the input widths-map and reads it fromgrid.getFlatHeaders()so partial writes can no longer corrupt the total. No RAF-save-restore, no FIXME pleister.
Header-flip + filter-row flip — shared getColumnControlPosition helper#
Right-aligned columns now have their header-menu trigger AND their
filter-row icon on the left of the cell, with the column label /
filter input flowing into the cell to the right. The drag-grip stays
on the constant left position regardless of alignment. Before this
release the header menu followed headerAlign but the filter row
always rendered controls on the right, breaking visual coherence on
right-aligned numeric columns.
- New helper
getColumnControlPosition(meta)inutils/control-position.ts— pure function that returns'left'or'right'based onheaderAlign. Single source of truth for every control-positioning decision in the header and filter row. header-cell.tsxuses it to flip the menu-trigger position.- All five inline filter components (
text-filter.tsx,number-filter.tsx,select-filter.tsx,date-filter.tsx,boolean-filter.tsx) consume it viaflex-row-reverseon their outer container — keeps DOM-order stable (so tab-order and accessibility don't drift) while flipping visual order. - Tab-order intentionally preserved as DOM-order — flipping is purely
visual via Tailwind
flex-row-reverse. This matches how a sighted user reads the cell (label → icon for left-aligned, icon → label for right-aligned) without disturbing keyboard navigation.
Variant 3 — DMMF filterType-merge in resolver + Number-cast in NumberFilter (value-shape class)#
The "spam-score-filter laadfout" bug: typing a value into a numeric
column's inline filter triggered a Prisma type-validation error
("Argument equals: Got invalid value "5". Reason: Provided value
is string, but the field is of type Float"). Root: the wire-contract
filter mapping (built via scalarField() / fieldFilter(path))
omitted filterType, the server's inferFilterType fallback
misclassified {operator: 'equals', value: '5'} as 'text' (because
the value is a string), and buildTextCondition produced a
{equals: '5', mode: 'insensitive'} Prisma fragment that fails type
validation on a Float column.
Two-layer fix (defense-in-depth on the same value-shape mismatch):
server/resolve-column-contract.ts— newresolveFilterSubKeyhelper with a targeted property-merge: when both wire and DMMF declare afilter:fieldmapping, properties that DMMF supplies but wire does not are inherited from DMMF onto wire. Currently inherited:filterType,nullable. The merge fires only when the_kindmatches — cross-_kindmerges (e.g. wirefilter:multiField- DMMF
filter:field) are rejected to avoid incoherent objects. Consumer-explicit values still win.
- DMMF
components/filters/number-filter.tsx— strictNumber()-cast on the inline-input'sdebouncedValuebeforesetFilterValue.Number()was chosen overparseFloat()because the former rejects"5abc"(yieldsNaN) while the latter would silently produce5. The cast plus a NaN-guard ensures the filter payload'svalueis always a real number, not a string-that-looks-like-one. Range mode'svalueTogets the same treatment.
Either layer alone is sufficient for the spamScore case; both are
present so future filter components that don't cast at the source
still produce correct queries via the resolver-merge, and future
consumers using scalarField() without options still get type-safe
queries because the resolver supplies the DMMF type.
Type-bound operator-defaults aanscherping (operator-availability class)#
The "isEmpty-laadfout" bug: clicking the isEmpty operator in a
numeric column's filter popover crashed Prisma with "Provided value
is null, but the field is not nullable" on a Float @default(0)
column. Aggregate Prisma-schema-meting (n=950 fields across four
databases): Float and Boolean are 0% nullable in our schemas;
Int 11%, DateTime 8%, Decimal 18%. Most columns where the UI
offered isEmpty/isNotEmpty had no semantically meaningful "empty"
state to query — the operator was an attractive nuisance.
Tightening (the largest visible part):
types/filters.ts—isEmpty/isNotEmptyremoved from the default operator-lists forgetNumberOperators(9→7),getDateOperators(6→4), andgetBooleanOperators(3→1). Kept ingetTextOperators(text columns do have a meaningful empty state —nulland""— and the server-shape handles both since this release). Module-level JSDoc documents the rationale.
New API surface and infrastructure:
config/operator-presets.ts— newwithEmptypreset for the rare nullable number/date column whereisEmptyIS meaningful. Consumers opt-in per-column viameta.operatorPreset: 'withEmpty'.types/server-contract.ts—FilterFieldMapping.nullable?: booleanfield added. Used by the server-side condition builder to pick the right Prisma shape per nullability.server/dmmf-defaults.ts—DmmfFieldMeta.isRequired?: booleanfield added.generateDmmfDefaultspopulatesFilterFieldMapping.nullable = !isRequiredautomatically.server/resolve-column-contract.ts—resolveFilterSubKey(added in Variant 3 above) also inheritsnullablefrom DMMF to wire.
Server-side condition shapes per (filterType × nullable):
| Case | Prisma shape for isEmpty |
|---|---|
| nullable text | top-level AND: [{OR: [{path: {equals: null}}, {path: {equals: ''}}]}] |
| nullable text + isNotEmpty | top-level AND: [{AND: [{path: {not: null}}, {path: {not: ''}}]}] |
| non-nullable text | flat {path: {equals: ''}} (only "" can be empty for a required String) |
| nullable other (date/number/boolean) | flat {path: {equals: null}} / {not: null} |
| non-nullable other (date/number/boolean) | dropped + warn (defense-in-depth, see below) |
Defense-in-depth: on a non-nullable non-string column, an isEmpty
filter that somehow arrives at the server (URL-state manipulation, a
persisted older grid-state) is silently dropped with a dev-warn. The
UI no longer offers the operator on those types since this release,
so this path only fires on edge cases. No Prisma error, no silent
unfiltered dataset (the operator is already invisible in the popover).
This bug-class is distinct from Variant 3's value-shape class: here the operator never should have been offered on the column type, full stop. TD-10 covers the systematic value-shape audit for variant 3's class — different class, different fix.
TD-12 — --grid-total-width decoupled from partial widths-map (resize-scroll regression)#
When mouseclicking a resize-handle on a column that had only become
visible via horizontal scroll, the body's scrollLeft would clamp to
0 for one frame, undoing the user's scroll-position. Mechanism (only
nailed after five iterations of runtime instrumentation):
- Mousedown → engine sets
columnResizing.isResizingColumn = X - React commit →
useFlexLayout's live-resize-subscriber (use-flex-layout.ts:296-355) registers a store-subscribe - That subscriber writes
{[X]: width}(single-entry map) towriteCssVarsToContainerson every store-change - Inside
writeCssVarsToContainers,--grid-total-widthwas computed asObject.values(widths).reduce(sum)= the resizing column's width alone (~186px for an Ontvangen-column) - CSS-cascade
min-width: max(100%, calc(var(--grid-total-width) * 1px))evaluates tomax(viewport, 186px)=viewport, the body's content wrapper shrinks to viewport-width, the row gets clipped, browser sees content fits viewport, clampsscrollLeftto 0
Root-fix (utils/css-var-utils.ts): --grid-total-width is now
computed from grid.getFlatHeaders().reduce((sum, h) => sum + h.getSize(), 0) — the same source useColumnSizingVars uses for the
outer grid div's inline-style total. Partial widths-maps (live-resize
single-column writes; main-effect's resize-start writeWidths that
excludes the resizing column) can no longer corrupt the total.
CssVarGrid interface extended with getFlatHeaders to match the
new computation. Existing tests that pinned the old buggy semantic
("Total width reflects only the widths in the map") inverted to
assert the new invariant — partial maps produce the full grid-state
total.
Symmetric latent bug fixed in the same wijziging: the main-effect's
writeWidths filter excludes the resizing column on resize-start to
avoid clobbering the live-subscriber's mid-drag writes. Pre-fix, that
filtered map produced --grid-total-width = 1458 − resizingColWidth
(≈ 1272) — a different magnitude of the same class. The single
decouple-from-input fix covers both paths.
Verified live in the running app post-fix:
--grid-total-width stays at the full grid total on every frame
sampled across the mousedown → mouseup window; scrollLeft stays at
its pre-mousedown value.
Tests#
utils/css-var-utils.test.ts— 4 existing assertions inverted to the new invariant; 2 new tests cover the live-resize-subscriber's single-column write and the main-effect'swriteWidths-filter partial-map casetypes/filters.test.ts— 4 length-counts adjusted (number 9→7, date 6→4, boolean 3→1); 4 new tests assertisEmpty/isNotEmptyabsent from non-text defaults, present in text defaultsconfig/operator-presets.test.ts— new test for thewithEmptypresetserver/dmmf-defaults.test.ts— 2 new tests covernullablepropagation fromisRequiredand the undefined-fallback for older adaptersserver/resolve-column-contract.test.ts— 5 new tests cover thefilterTypeandnullableinheritance from DMMF, consumer-override precedence, and cross-_kindrejectionutils/query-builder-contract.test.ts— 7 new tests cover all(filterType × nullable)combinations forisEmpty/isNotEmpty, plus combination with value-based filters on other columns, plus the end-to-end value-shape-safety net (numericequalsdoesn't fall back totext)utils/control-position.test.ts— 5 new tests for the shared helper (added in the header/filter-flip work)e2e/resize-handle-scroll-preservation.spec.ts— new Playwright spec, two tests: scrollLeft-invariant on mousedown after horizontal scroll, and--grid-total-widthinvariant across 8 sampled frames
Full suite: 1339 / 1339 passing (was 1337 before this release).
Migration notes#
No breaking changes for typical consumers. All four wijzigingen are internal or additive:
isEmpty/isNotEmptyremoved from default number/date/boolean operator-lists — no consumer in this monorepo used those defaults directly (verified viagrep NUMBER_OPERATORS|getNumberOperatorsacrossapps/). External consumers relying on these defaults can opt back in viameta.operatorPreset: 'withEmpty'.meta.nullableonFilterFieldMappingis optional and consumers who omit it get pre-1.4.0 behavior (treated as nullable for shape-building).getFlatHeadersadded toCssVarGridinterface — this is the minimal structural type used bywriteCssVarsToContainers, not a consumer-exported type.
Backlog niet meegenomen in deze tag#
- TD-7 — Header-sort keyboard-toegankelijkheid (div mist tabIndex/role); separate a11y work
- TD-8 —
NumberFilterdefault-operator UX (drempel-operator overwegen); UX-keuze per kolom of preset - TD-9 — Client-side
evaluateFilterdoet tekstvergelijking op numerieke kolommen voorequals/notEquals - TD-10 — Value-shape audit per operator×type — systematisch dekkingsonderzoek voor de variant-3-klasse
- TD-11 — Laatste kolom mist resize-handle aan de buitenrand
Update (v4.1.1): TD-7, TD-8 en TD-9 zijn alsnog afgehecht — zie de [4.1.1]-entry bovenaan. TD-10 en TD-11 blijven open.
Zie docs/ROADMAP.md voor de actuele backlog.
[4.0.3] — 2026-05-22 — Low-effort cleanup sweep (traycer-v2 Phase 2 — closeout)#
Patch release. Closes the remaining low-effort items from the traycer-v2
audit: TD-6 (t?.() audit), I-15 (FilterFieldConfig JSDoc clarity),
I-18 (GridColumnMeta grouping), and I-19 (server-mode.md drift). All
four are documentation, JSDoc, and a small internal i18n-wiring fix —
no runtime API changes, no external-consumer impact.
TD-6 — Filter-builder switched to useGridTranslation() from prop-drilling#
Filter-builder components consumed translations via a t?: GridTranslateFunction
prop. With the v4.0 i18n-coverage fix (41 keys added to nl/grid.json
en/grid.json) the wrapper-useGridTranslation()always returns a string (real translation or English defaults.ts fallback), so the inline?? "English"fallbacks were redundant defensive code.
Switched all three filter-builder components to consume translation via the context-hook directly:
FilterBuilderPanel—tprop removed,useGridTranslation()used internally. 5 inline?? "English"fallbacks removed.FilterConditionRow— same pattern. 4 fallbacks removed.FilterGroupRenderer— same pattern. 6 fallbacks removed.<ActiveGrid>—t={props.t}removed from theFilterBuilderPanelcall-site ingrid.tsx.
Filter-builder components are internal (not exported via main barrel) — this is a pure refactor with no external API surface impact.
I-15 — FilterFieldConfig / FilterFieldConfigMap JSDoc clarified#
The original JSDoc referenced toPrismaWhereFromFilters — a function
removed in v4.0.0 — and didn't distinguish between the filter-builder
expression path (which uses these types) and the contract-aware
grid path (which doesn't). External adopters reading the type-hover
would not know which paradigm applies. Updated to:
- Explicitly state the type belongs to the filter-builder expression
path (
toPrismaWhereFromExpression). - Cross-link to ADR-013 and
migration/v3-to-v4.md§2 for the contract-aware path. - Explain the side-by-side coexistence of the two filter-configuration paradigms and which to use when.
I-18 — GridColumnMeta JSDoc-grouping introduced#
The type had ~50 fields with inconsistent inline-comments and no formal grouping. Added an 8-category JSDoc summary at the top of the interface plus section-marker comments between field-groups:
- Display & alignment
- Filtering (filterType, customFilter, operator config, filterOptions, date-filter config, suppressFilterIcon — previously scattered across three locations)
- Editing
- Aggregation
- Cell formatting & tooltips
- Layout
- Header customization
- System
Within these groups, all fields stay where they were — no field-order changes, no semantic changes, only comment-delineation. Note: a few filter-related fields previously sat outside the "Enterprise Filter Configuration" comment (date-filter config in particular) — they've been moved adjacent to the other Filtering fields for consistency.
I-19 — docs/guides/server-mode.md drift sweep#
Two findings:
### toPrismaWheresection documented a function removed in v4.0. Replaced with a short note explaining the removal and redirecting to the contract-aware path + migration guide.- No documentation of
groupBy + aggregatehelpers — symmetric gap after fase-3. The guide covered sort/filter/global but not the v4.0 additions. Added a new## Server-side grouping (groupBy + aggregate)section between Global search and Debouncing, with:- Intro explaining the automatic factory branching (group-level vs. leaf fetch).
- Helpers-per-sub-key table covering
groupByField,groupByRelation(with reserved-status note),aggregateScalar,aggregateCount, and the raw variants on/server. - Example illustrating combined
groupBy + aggregateon a numeric column (salary). - Fail-closed + reserved policy summary:
groupByinstruction without mapping →warnStripped;groupByRelation()mapping →warnReserved; aggregate has no DMMF defaults (S3-asymmetry). - Cross-links to ADR-017 and migration/v3-to-v4 §1 / §3.
Verification#
pnpm --filter @workspace/active-grid test— 1312/1312 passing (unchanged — pure refactor + JSDoc + docs).pnpm --filter @workspace/active-grid typecheck— clean.
ROADMAP follow-up#
TD-6 is closed by this release. The remaining traycer-v2 items (I-15/I-16/I-17/I-18/I-19 didn't have their own ROADMAP entries — they were closed inline as part of this sweep where appropriate).
Resterende ROADMAP after this release: R-3, R-6, R-24, R-26, TD-1, TD-2. All trigger-driven; none urgent.
[4.0.2] — 2026-05-22 — Layering-fix + group-by branch extraction (traycer-v2 Phase 2)#
Patch release. Closes the medium-priority architecture and complexity
findings from traycer-v2 (R-25 + TD-5). Two separate kinds of change,
done in sequence so any regression can be attributed to the right
half: R-25 is a pure relocation (no semantic change), TD-5 is an
extraction (no behavior change). Together they tidy up the
src/server/ cluster ahead of an upcoming request that will touch the
group-by branch.
R-25 — utils/ → server/ import inversion resolved#
Layering violation identified by traycer-v2 I-07: utils/query-builder.ts
imported warnStripped + warnReserved from
server/resolve-column-contract.ts. utils/ should not depend on
server/. Closer inspection revealed the warning helpers were never
called from resolve-column-contract.ts itself — only from
query-builder.ts (5 call-sites) — so the helpers logically belonged
in utils/ from the start.
Changes:
- New file
src/utils/contract-warnings.ts— owns the six warning-helper symbols (warnStripped,warnReserved,_resetWarnedKeysForTesting,_resetWarnedReservedKeysForTesting, plus the two privateSet<string>warning-memo registers). server/resolve-column-contract.ts— warning section deleted; replaced with a short comment noting the move. JSDoc cross-link towarnStrippedrepointed to the new location.utils/query-builder.ts:3— import path changed from"../server/resolve-column-contract"to"./contract-warnings".server/index.ts— re-exports of the four public helpers (warnReserved,warnStripped, both_reset*test-helpers) now re-export from../utils/contract-warnings. Public/serverAPI surface is unchanged — consumers importing from@workspace/active-grid/serversee no difference.- Test imports —
server/resolve-column-contract.test.tsandutils/query-builder-contract.test.tsupdated to import from the new location. Cross-folder test-import (server-test → utils-source) is acceptable per traycer-v2 analysis — tests are not a runtime layering concern.
TD-5 — createGridServerAction group-by branch extracted#
Per traycer-v2 I-14: the v4.0 group-by/aggregate branch in
create-grid-server-action.ts was ~173 lines inside an already-large
factory (~670 lines total). Extracted to a dedicated file with two
small private helpers, ahead of an upcoming request that will touch
this area. Pure refactor — no behavior change.
Changes:
- New file
src/server/group-by-handler.ts(~230 lines, internal module — not exported from/server) with three symbols:handleGroupByRequest(args)— top-level handler that branches between leaf-fetch (groupKeys.length >= serverGroupBy.length→findMany) and group-level-fetch (prismaModel.groupBy). Accepts an object-style parameter for self-documenting call-sites.buildGroupKeyWhere(serverGroupBy, groupKeys, contractMap)— pure helper that builds the WHERE-fragment from already-drilled group-keys. Skips relation/raw mappings (translator already warned).translateGroupResults(rawGroups, byField, drillDownLevel, totalLevels)— pure helper that translates Prisma'sgroupByraw output into the{data, groups}shape thatuseServerGroupingconsumes. Handles Prisma's three_countshapes (number,{_all},{field}), flattens_sum/_avg/_min/_maxblocks into camelCase aggregation keys, and computeshasSubGroupsfrom drill-down depth.
create-grid-server-action.ts— the inline 173-line branch is replaced by a singlehandleGroupByRequest({...})call wrapped in the existingsuccess: trueenvelope. ImportshandleGroupByRequestfrom the new module.- New tests —
src/server/group-by-handler.test.ts(27 unit tests) pinning the current correct behavior oftranslateGroupResultsandbuildGroupKeyWhere. Three describe-blocks: count-extraction (5 cases), aggregations-flatten (6 cases), hasSubGroups invariant (4 cases), data-and-key preservation (4 cases), andbuildGroupKeyWhereend-to-end (8 cases). Pinning-purpose: lock the current correct behavior before the upcoming request touches this area. The factory-level integration tests increate-grid-server-action.test.tsare unchanged.
Why both in one patch: R-25 and TD-5 both touch the src/server/
cluster. Doing them in one commit-context kept the test-cycle and
review-context unified. R-25 was applied first (smallest blast-radius
— pure file move), validated, then TD-5 (extraction + new tests),
validated. Either change can be attributed individually by looking at
which file was touched.
Verification#
pnpm --filter @workspace/active-grid test— 1312/1312 passing (+27 new pinning tests for the group-by handler).pnpm --filter @workspace/active-grid typecheck— clean.pnpm typecheck(monorepo) — 38/38 packages clean.
Cross-link#
Both were identified as Phase 2 items by the Traycer-v2 audit of 2026-05-22, §§ I-07 (R-25) and I-14 (TD-5); that transcript has since been removed. The aankomende request that TD-5 prepares for is not part of this release — TD-5 ships as pure preparation, with the pinning-tests as a safety net for the upcoming work.
[4.0.1] — 2026-05-22 — Doc-drift sweep + dead-code removal (traycer-v2 Phase 1)#
Patch release. Closes the high-priority doc-drift findings from traycer-v2 (the audit run immediately after v4.0.0). No runtime changes; all fixes are documentation, JSDoc comments, dead-code removal, and dead-link repairs.
Removed — calculateAutoSize + autoSizeAllColumns (deprecated standalone functions)#
The two @deprecated-marked standalone functions in
src/utils/auto-size-utils.ts had zero callers in Geneax (verified via
grep). The useAutoSize hook is the canonical replacement. Per §7.14
(no backwards-compat cruft), @deprecated-markers should not exist;
since these functions had no consumers, they have been removed in
the same commit (file deleted, barrel-export removed). The useAutoSize-
hook's autoSizeAllColumns method (different code-path; runtime-method
on the hook return-value) is unchanged.
Removed — isCacheHit alias in use-data-orchestrator.ts#
Half-removed refactor identified by traycer-v2. The isCacheHit-alias
was identical to isLiveCacheHit (const isCacheHit = isLiveCacheHit;)
and its comment "Kept for naming-compatibility with downstream effects"
described a refactor-stage that had moved on. All three downstream uses
(prefetch-adjacent effect, fetch-trigger effect, related JSDoc) are now
updated to isLiveCacheHit. Net: -3 lines, +0 behavior changes.
Changed — stale phase-reservation comments brought up to v4.0#
Five comment-blocks in src/types/server-contract.ts and one JSDoc-
block in src/types/columns.ts still described the schema as "phase 1
active, phase 2/3 reserved" — a v3.0.0 truth. With v4.0 all five sub-
keys are runtime-active. Comments updated:
- Top-level JSDoc of
ServerColumnContractnow states "all runtime- active since v4.0.0" with the phase-→version mapping. - Per-field JSDocs of
global/groupBy/aggregatenow state "Runtime-active since v3.1.0" / "v4.0.0" instead of "Reserved for phase 2/3". - Section headers in
server-contract.tssay "runtime-active since vX.X.X (ADR-013/017)" instead of "phase 1 active" / "reserved for phase 2/3". GridColumnDef.serverJSDoc incolumns.tsupdated symmetrically with the same phase-→version mapping plus cross-link toARCHITECTURE.md's server-side query-contract section.
Changed — dead-link sweep for the removed docs/internal/ folder#
The v4.0.0 docs cleanup removed docs/internal/rfc-server-query-contract.md
- four other working documents (essence absorbed into ADRs and DONE.md)
and moved
docs/internal/escape-hatch-patterns.mdtodocs/contributing/. Cross-references in source code were not updated at that time; traycer-v2 caught it. Now updated:
- 5 references to the removed RFC (
src/index.ts,src/server/resolve-column-contract.ts,src/server/dmmf-defaults.ts,src/server/create-grid-server-action.tstwice) repointed todocs/DECISIONS.mdADR-013 /docs/ARCHITECTURE.mdserver-contract section. - 8 references to the moved escape-hatch-patterns (
src/types/columns.ts,src/engine/features/column-faceting/columnFacetingFeature.types.ts,src/engine/fns/filterFns.ts,src/engine/fns/sortFns.ts,src/engine/types/ColumnDef.ts,src/engine/types/Grid.ts,src/engine/types/GridOptions.ts,src/engine/types/type-utils.ts,src/react/index.ts) updated to point todocs/contributing/. - Cross-package dead links in
R14-R21-RELEASE.md(repo root) anddocs/adrs/ADR-005-active-grid-library-style-react-19-patterns.md(root-level cross-package ADR) updated to reflect the absorption of internal working documents intoDONE.md§ R-14 + R-21.
Changed — docs/INDEX.md baseline + ADR count + ROADMAP description#
- Baseline:
v3.1.0 — server-side query-contract fase 1 + 2→v4.0.1 — server-side query-contract compleet (alle vijf sub-keys runtime-actief). - ADR count:
12 ADRs→18 ADRs. - ROADMAP description:
R-2 t/m R-15→R-3/R-6/R-24/R-25/R-26 + TD-1/ TD-2/TD-5/TD-6(the actual current set after v4.0's ROADMAP cleanup and v4.0.1's traycer-v2 additions). - Migration table extended with
migration/v3-to-v4.md(added in v4.0.0 but never linked from INDEX.md).
Changed — docs/FUNCTIONEEL-OVERZICHT.md v4.0-alignment#
sonnerremoved from the external runtime-dependencies list (sonnerwas actually removed in v2.0.0, but FUNCTIONEEL-OVERZICHT still listed it).- AG Grid comparison table extended with a row for the schema-aware per-column server-query-contract (Active Grid's strongest server- side differentiator vs. AG Grid Community / Enterprise).
Documented — GroupAggregation / ServerFetchParams.groupAggregations migration#
Both type definitions now carry inline JSDoc explaining that
active-grid itself stopped populating groupAggregations on the wire
in v4.0 — the field is retained for backward-compat with custom (non-
factory) fetchFn implementations. Per §7.14, no @deprecated
marker was added; the migration path is documented inline with a
cross-link to docs/migration/v3-to-v4.md §5.
New ROADMAP items (from traycer-v2)#
Four new items were added to docs/ROADMAP.md:
- R-25 🟡 — Architectuur:
utils/→server/import-inversie oplossen. MovewarnStripped/warnReservedtoutils/contract- warnings.ts. Trigger: volgende contract-werk-ronde. - R-26 🟢 — Benchmark-uitbreiding: contract-resolver overhead + server-grouped + wide-grid scenarios. Trigger: pre-v4.1-release of performance-claim.
- TD-5 🟢 —
createGridServerActiongroup-by-branch extractie (~170-regel branch naarhandleGroupByRequest-helper). Trigger: volgende wijziging in het bestand. - TD-6 🟢 —
t?.()fallback-pattern audit + selectieve upgrade na de v4.0 i18n-coverage-fix. Trigger: i18n-cleanup-ronde.
Verification#
pnpm --filter @workspace/active-grid test— 1285/1285 passing (unchanged).pnpm --filter @workspace/active-grid typecheck— clean.
Cross-link#
This release closes findings from the Traycer-v2 audit of 2026-05-22. That
transcript was temporary material and has since been removed; what it found
lives in the entries above and in ROADMAP.md / DONE.md.
[4.0.0] — 2026-05-22 — Server-side query-contract fase 3 (group-by + aggregaten) + legacy-removal (ADR-017)#
Major release. Completes the server-side query-contract: all five
sub-keys (sort, filter, global, groupBy, aggregate) are now
runtime-active. Removes the three legacy query-builder functions that
were preserved since v3.0.0 "tot R-23 het opruimt". Migration guide:
docs/migration/v3-to-v4.md (removed in 2026-09; the helpers it described are listed in api/server-contract.md).
Added — group-by + aggregate helpers (phase 3)#
- Main barrel:
groupByField(path?),groupByRelation({relation, path}),aggregateScalar({path?, ops: AggregateOp[]}),aggregateCount({relation?}). Pure object-builders without Prisma imports (bundle-grens-discipline). /serversubpath:rawGroupBy(build),rawAggregate(build)— server-only escape-hatches for cases the declarative shapes do not cover (one-to-many grouping, computed aggregates via $queryRaw).
Added — query-builder contract-aware variants#
queryBuilder.toPrismaGroupByFromContract(serverGroupBy, contractMap, options)— produces PrismagroupBy.by: string[]from active runtime selection- resolved contract. Supports
GroupByFieldMappingandRawGroupByMappingwith{type: "field", path}.GroupByRelationMappingis reserved (see "T3 correction" below).RawGroupByMappingwith{type: "expr", expr}is also reserved.
- resolved contract. Supports
queryBuilder.toPrismaAggregateFromContract(contractMap, options)— walks every column with a non-falseaggregatemapping and builds_count/_sum/_avg/_min/_maxblocks. Under single- source-of-truth (B-keuze), no separate runtime selection — every declared aggregate is included.
Changed — createGridServerAction (factory)#
GridServerActionRequestadditively extended withgroupBy?: GroupByColumn[]andgroupKeys?: unknown[]. Pre-existing flat-mode behaviour unchanged when both are empty/absent — strict backward-compatibility for the flat-fetch path. The wire-protocol extension is symmetric with how fase 1 addedcolumnContractand fase 2 routedglobalFilter.GridServerActionResponseadditively extended withisGrouped?: booleanandgroups?: GroupInfo[]. Pre-v4.0 flat-mode responses keep both absent.PrismaModelDelegatestructural type extended with optionalgroupBy?: (args) => Promise<unknown[]>. Pre-v4.0 contractual fields (findMany,count) unchanged.- Factory branches on
request.groupBy: when present and non-empty, the factory enters the group-by-or-leaf path. Two sub-cases:groupKeys.length < groupBy.length→ group-level fetch viaprismaModel.groupBy({by, where, orderBy?, _count, _sum, ...});groupKeys.length >= groupBy.length→ leaf-levelfindManywith group-key WHERE applied alongside the column-filter / globalFilter conditions. Sort-within-group uses the existingtoPrismaOrderByFromContract-translation ofstate.sorting(Q1 verification: implementation is exactly "goedkoop bovenop" per the stop-moment-1 inschatting).
Changed — DMMF defaults (S3 asymmetry)#
generateDmmfDefaults now adds groupBy: groupByField(path) to every
scalar default mapping. Aggregate is intentionally NOT auto-defaulted
(see ADR-013 phase-3-extension). Auto-aggregate
over numeric scalars is semantically dangerous; consumer must declare
aggregate explicitly per column. The test does NOT generate aggregate defaults (S3 asymmetry) pins this so a well-intentioned "cleanup"
doesn't later add aggregate-defaults.
Changed — loadInitialGridState (TD-3 contract-aware pruning)#
Hydration's stale-state pruning now accepts wireContract?: Record<string, ServerColumnContract>
and mode?: "client" | "server" options. In server-mode with a
provided contract, sort/filter entries are dropped when either the
column-id doesn't exist (v3.1.2 behaviour) OR the wire-contract has
no corresponding server.sort / server.filter mapping (TD-3 new).
Symmetric across both sub-keys. Threading via useGridSetup →
useGridState → loadInitialGridState. Wire-contract extraction
moved to a shared extractWireContract helper (utils/extract-wire-contract.ts)
re-used by createServerFetchFn.
Changed — useServerGrouping (B-keuze)#
groupAggregations parameter removed from UseServerGroupingProps.
Aggregates are now derived from column.server.aggregate by
createGridServerAction, not transmitted as a separate wire payload.
ServerFetchParams.groupAggregations field remains in the type (no
shape-removal) for backward-compat, but useServerGrouping no longer
sets it. Custom (non-factory) fetchFns that read the field receive
undefined from v4.0 onwards — see migration guide.
Changed — <ActiveGrid> API#
serverGroupAggregations?: GroupAggregation[] prop removed.
Consumer migration: per-column server.aggregate = aggregateScalar({ops: [...]})
declarations. See migration guide §4.
Changed — T3 correction (groupByRelation reserved, not active)#
Plan-time recommendation T3 ("1:1 declaratief via groupByRelation()")
discovered at stap-B implementatie not to be runtime-implementable:
Prisma's groupBy.by accepts only scalar field-names on the model;
GroupByRelationMapping's {relation, path}-shape carries nowhere
the FK-scalar Prisma needs. Three options considered, optie I
chosen: GroupByRelationMapping stays as a reserved type (C3-belofte
honoured; no schema-change), but is runtime fail-closed with a
warnReserved dev-warning. Consumer migration for 1:1 grouping:
groupByField("<fkScalar>") directly (the FK column on the host
model). For one-to-many: rawGroupBy() with explicit Prisma fragment.
The type stays reserved for a future phase with DMMF-FK-inference;
see migration guide §3.
Removed — legacy query-builder functions#
The three functions preserved since v3.0.0 "tot R-23 het opruimt":
queryBuilder.toPrismaGroupBy— replaced bycreateGridServerAction's internal flow +toPrismaGroupByFromContract+toPrismaAggregateFromContract.queryBuilder.toPrismaWhereFromFilters— replaced bytoPrismaWhereFromContract.queryBuilder.toPrismaOrderBy— replaced bytoPrismaOrderByFromContract.queryBuilder.toPrismaWhere— internal helper that only the three legacy functions used.handleSelectFilter— internal helper that onlytoPrismaWhereFromFiltersused.
Breaking for external adopters that call any of the three directly.
Geneax-internal has zero callers (verified pre-removal). Migration recipe
in docs/migration/v3-to-v4.md §2.
Added — warnReserved dev-warning#
New companion to warnStripped in server/resolve-column-contract.ts.
Fires once-per-(model, column, capability, reservedKind) when a
contract mapping is declared but the current runtime phase doesn't
activate it (the canonical v4.0 case being GroupByRelationMapping).
Distinct from warnStripped ("no mapping declared at any layer")
because the consumer DID declare something — the warning needs to
explain reservation, not absence. warnStripped's capability-union
widened from "sort" | "filter" to also include
"global" | "groupBy" | "aggregate".
Tests#
- +12 in
helpers/server-contract.test.ts(groupBy + aggregate helper-builders). - +19 in
query-builder-contract.test.ts(toPrismaGroupByFromContract- toPrismaAggregateFromContract).
- +2 in
dmmf-defaults.test.ts(groupBy defaults; aggregate S3 pin). - +8 in
server/create-grid-server-action.test.ts(factory branches: top-level/leaf/intermediate groupBy, Q1 sort-within-group + filter- under-group, empty-groupBy fall-through, fail-closed group-mapping). - −54 removed for the deleted legacy functions
(
query-builder.test.tsandfilter-expression.test.ts). - Net: 1285 tests passing (was 1339 — the 8 net-additions are swamped by the 54 legacy-tests being removed; the contract-aware variants have richer per-mapping coverage than the legacy direct tests had).
Fixed — bevinding 1 + 2 (in-fase tijdens visuele verificatie van fase 3)#
Tijdens stop-moment-2 van fase 3 ontdekt: drag-to-group werkte niet
(blokker; bestaande chips wel uit te X'en), progress-bar bleef draaien
na un-group-all, en een nieuwe Performance.measure console-error
tijdens un-group. Diagnose-rapport bewees: latente bugs sinds v3.1.3
(ADR-014), niet v4.0-regressies. De stap-F-migratie raakte geen drag-
to-group-code; de bugs zaten in ADR-014's isServerGroupingActive-
predicate dat naar props.serverGroupBy keek i.p.v. de lokale single-
source-of-truth state die ADR-014 zelf introduceerde, plus een loading-
flag-leak in useServerGrouping's dataset-key-effect.
Fixes binnen v4.0.0:
isServerGroupingActivegesplitst in twee predicates incomponents/grid.tsx.isServerGroupingMode(prop-sticky; consumer-intent) drijft engine-wiring decisions die consistent moeten blijven (enableGrouping: false, intercept-setup,useServerGrouping(enabled), one-way mirror).isServerGroupingActive(state-based; afgeleid van lokaleserverGroupBy) drijft runtime decisions (loading-route, data-injection, exportConfig). Sluit de gap die ADR-014 liet toen het de lokale state introduceerde maar dit ene predicate niet meenam.<GroupingToolbar>nieuwe optionelecanAcceptDrop?: (columnId: string) => booleanprop. Default fall-back opcolumn.getCanGroup().<ActiveGrid>wires automatisch een server-grouping-aware predicate in server-grouping mode datcolumn.columnDef.server?.groupBycheckt i.p.v. het engine-enableGroupingflag (dat in mode altijdfalseis).- B-2 in
use-server-grouping.tsdataset-key-effect: vlagisLoading/isRefetchingniet wanneergroupBy.length === 0(geen fetch firet, geen resolve om te resetten). Bij transitie naar empty: expliciet beide flags opfalseals defensieve maatregel. - Bevinding 3 (Performance.measure error) wordt na deze fix herverifieerd. Aanname: secundair aan de halfgare-transitie- setState-storm die nu opgelost is. Als hij blijft, aparte diagnose.
Fixed — drag-to-group source-side (ADR-018, koers-correctie na bevinding-1 visuele verificatie)#
Walts visuele verificatie van de bevinding-1+2-fix toonde aan dat de
drop-acceptatie-fix correct was maar onvolledig: de header was niet
eens grijpbaar (cursor veranderde niet, geen drag-icoon, geen drag-
initiatie). Diagnose: de drag-source-machinery (useColumnReorder.getDragHandleProps)
bestond, maar was gegated op enableColumnReordering (default false).
Test-grids gebruiken enableGrouping zonder enableColumnReordering,
dus geen draggable headers — en de drop-zone (GroupingToolbar) heeft
niets om te ontvangen. Latente design-fout sinds useColumnReorder
toegevoegd; onopgemerkt omdat alle test-grids hun grouping via de
serverGroupBy-prop instelden. Volledig vastgelegd in
ADR-018.
Vier wijzigingen binnen v4.0.0:
useColumnReorderAPI splitenabled→enableSource(drag- initiatie) +enableTarget(drop-op-andere-header voor reorder). Drop-zones zijn onafhankelijk; één bron, meerdere bestemmingen die elk eigen acceptatie bepalen. De hook is intern (geen barrel-export, één caller) — geen migratie voor externe consumers.header.tsxrendert dragProps wanneer reorder OR grouping aan staat. Per-header beslissing voorisGrabbable: reorder ON → alle leaf-headers grabbable; grouping-only ON → alleen kolommen metcolumn.server.groupBygrabbable (symmetrisch met de drop- acceptatie-fix; geen silentieel-rejected slepen op niet-groepbare kolommen).header-cell.tsxcursor-affordance gated opisGrabbable(per-header), niet openableColumnReordering(per-grid). Grouping- only grids tonen nu ookcursor-grabop groepbare kolommen.- Grip-icoon (
GripVertical) op hover voor draggable kolommen,opacity-0→group-hover:opacity-100,text-muted-foreground/50,h-3.5 w-3.5. Walts ontdekbaarheids-punt: AG-Grid toont een grip; Geneax veranderde alleen de cursor. Geen UI-ruis in rust-staat, duidelijke affordance bij hover.
Toekomstbestendigheid bevestigd in ADR-018: de huidige
enableSource/enableTarget-splitsing + per-header isGrabbable-
beslissing zijn voldoende structurele voorbereiding voor latere
destination-disambiguation bij gelijktijdige activering van reorder +
grouping (AG-Grid-stijl). Drop-zone-zijde feedback kan additief
toegevoegd worden zonder model-herziening. Uitgesteld als
ROADMAP R-24
— trigger: publieke release of een grid die beide tegelijk aanzet.
Fixed — i18n-coverage-gat in nl/grid.json + en/grid.json (Walts visuele observatie op /test-server-grouping)#
Walt zag Engelse strings ("Filter Builder", "Grouping:", "Remove all
grouping", screen-reader-announcements, batch-toolbar, lookup-dialog)
op een grid op een systeem dat op Nederlands stond. Oorzaak: het
nl/grid.json-bestand had 48 keys minder dan defaults.ts in
active-grid. De useGridTranslation-hook valt bij ontbrekende keys
terug op de Engelse defaults; vandaar het visuele effect. en/grid.json
had identiek dezelfde 48 gaten, maar de fallback-naar-defaults.ts is
óók Engels — visueel correct voor EN, onzichtbaar tot het op NL werd
getest.
Per-key audit toonde dat 41 keys live runtime-callers hebben en 7 keys 0 callers:
-
41 live keys vertaald in beide locale-bestanden:
filterBuilder.*(12),grouping.*(8),announce.*(5),selection.*(4 extra: pageSelected/selectAllRows/allSelected/ clearSelection),lookup.*(4: searchPlaceholder/selectButton/ cancelButton/doubleClickHint),serverGrouping.*(3: loading/ loadMore/loadingMore),batch.*(3: saveAll/discardAll/changesCount),skipLink,export.formatPdf. -
7 dode keys verwijderd uit
defaults.ts(geen runtime-callers gevonden via grep vangrid.<key>in productie-code; geen dynamische template-aanroepen):serverGrouping.noGroupsserverGrouping.expandGroupserverGrouping.collapseGroupserverGrouping.exportProgressbatch.confirmDiscardbatch.saveFailedlookup.noResults
Vermoedelijk overblijvers van eerder verwijderde features; consistent met de "absorberen, niet archiveren"-filosofie verwijderd in plaats van defensief mee-vertalen. Twee tests in
batch-translations.test.tsdie de dode keys bewaakten zijn meegenomen — vervangen door een korte comment-noot die de keuze documenteert.
Closed without code-fix — TD-4 (useDebounce leeg→leeg ref-mutatie) — bron bewezen, kost gemeten verwaarloosbaar#
TD-4 was de overgebleven render-budget-vraag uit de v3.1.4-diagnose-
ronde. Een eerste sluit-poging op redenering werd door Walt
teruggedraaid; nieuwe meet-instrumentatie (setState-source-tracer met
stack-trace + render-cadence + fetchBlock-identity-rotation-detector)
leverde één hard-refresh van /test-server-grouping waaruit zowel
bron als kost ontegenzeggelijk afleesbaar was.
Bron bewezen — een vierde, niet eerder vermoede kandidaat:
NumberFilter.useEffect (number-filter.tsx:142) doet
column.setFilterValue(undefined) in zijn passive-mount-effect. Twee
instances (salary + age, beide filterType: "number") → React batcht
de twee setStates in één commit → één gecombineerde columnFilters
ref-mutatie op render #3. De write is content-SAME (filter verwijderd
uit een al-lege lijst → nieuwe lege lijst-ref), maar de Store heeft
geen deep-compare dus de ref wordt vervangen en subscribers krijgen
het mutatie-signaal. De drie eerder vermoede kandidaten (TD-3
hydration, columnFiltering init-effect, useColumnManager setup-effect)
waren allemaal mis.
Kost gemeten verwaarloosbaar — cascade-detector toonde
render #21: debouncedColumnFilters muteert (output, 300ms debounce- fire) + render #21: fetchBlock useCallback IDENTITY rotated (EENMAAL).
Géén verdere mutaties of rotations na render #21. Per ADR-015
Fix A zit fetchBlock in een ref; useCallback-rotation triggert geen
consumer re-render-cascade. Per-mount eindeffect: 1 useCallback-
recreatie, géén consumer re-render, géén triggered fetch. Niet
meetbaar in user-perceived performance.
Geen fix. NumberFilter's mount-time no-op setFilterValue(undefined)
zou kosteloos kunnen worden gefixt (skip wanneer de waarde al
undefined is in de store), maar de cascade-impact is verwaarloosbaar
en preventief fixen van een latente theoretische bug die al
geneutraliseerd is, is precies waar YAGNI tegen waarschuwt. Walt's
voorwaarde "Als de render-kost verwaarloosbaar blijkt: forceer geen
fix" is gerechtvaardigd door meting.
Instrumentatie weg uit use-grid-state.ts en use-data-orchestrator.ts.
Volledige conclusie, cascade-trace en lessons-learned in
DONE.md.
Verification#
pnpm --filter @workspace/active-grid test— 1285/1285 passing (+2 voor B-2 inuse-server-grouping.test.ts; −2 voor de twee dode-key-tests inbatch-translations.test.tsdie meegenomen werden bij de i18n-coverage-fix).pnpm typecheck— 38/38 packages clean.- Visual (Walt te verifiëren on test-grids + production /users):
/test-server-grouping: groep-rijen + aggregaten zichtbaar (sum of salary, avg of age per group); drag-to-group werkt vanaf de drag-bron (grip-icoon op hover bij groepbare kolommen, cursor verandert, drag start, drop op toolbar groepeert); niet- groepbare kolommen (zonderserver.groupBy) tonen géén grip-icoon en zijn niet grabbable; un-group-all + re-group cycli stabiel (meerdere keren); geen regressie op v3.1.5 stale-while-revalidate; geen regressie op v3.1.4 fetch-stability./test-minimal-grouping?step=0..7: alle stappen stabiel.- Geneste grouping (2 levels) stack + unstack via drag stabiel.
- Verificatie of bevinding-3 (Performance.measure error) verdwenen is na deze fix.
- Productie-grids (geen consumer-migratie verplicht): flat-mode grids onveranderd.
[3.1.5] — 2026-05-22 — Rustig grid: progress-indicator + stale-while-revalidate (ADR-016)#
Combineert twee UX-fixes die hetzelfde architecturale gat aanpakten: de active-grid engine had geen unified loading-indicator die rustig naast bestaande content werkt. Skeleton-overlay en empty-state-flash verdwijnen; rijen blijven zichtbaar tijdens filter/sort/cross-block- pagination met een 2-pixel progress-bar bovenaan. Verlaat bewust de v2.2.0 "defensive option B"-cache-gate ten gunste van stale-while- revalidate — die was correct onder het skeleton-overlay-model, niet onder de nieuwe progress-indicator. Zie ADR-016.
Changed — OverlayManager default = "progress"#
packages/active-grid/src/components/sections/overlay-manager.tsx —
nieuwe default loadingDisplay="progress":
- Eén 2-pixel indeterminate animated bar bovenaan het grid voor zowel
isLoading(initial load) alsisRefetching(refetch). - Geen opaque overlay meer. Bestaande rijen blijven volledig zichtbaar en interactief tijdens loading.
- Skeleton en spinner blijven beschikbaar als opt-in
(
loadingDisplay="skeleton"of="spinner") voor consumers die de oude look verkiezen.
packages/active-ui/src/styles/grid-theme.css — progress-bar-kleur
van color-mix(--muted-foreground @ 20%) naar
color-mix(--primary @ 45%). Net zichtbaar genoeg dat een trage
productie-query (500 ms+) niet als "hangt het?" voelt; nog steeds
rustig genoeg om niet te storen.
Changed — useDataOrchestrator = stale-while-revalidate#
packages/active-grid/src/hooks/use-data-orchestrator.ts —
!isDatasetStale-gate op cachedPage verwijderd; lastGoodPageRef
toegevoegd als fallback voor het invalidate-window:
const livePage = mode === 'server' ? store.peekPage(pageIndex, pageSize, pagesPerBlock) : undefined;
const lastGoodPageRef = React.useRef<typeof livePage>(undefined);if (livePage) lastGoodPageRef.current = livePage;
const cachedPage = livePage ?? lastGoodPageRef.current;const isLiveCacheHit = livePage !== undefined;const isCacheHit = isLiveCacheHit;De fetch-trigger en prefetch effects blijven op isLiveCacheHit
gebaseerd — het ref-fallback is puur display, blokkeert geen
legitieme fetches. Token-gating (setBlockIfToken) blijft
stale-write-bescherming. Het return-pad surfaced loading-flags
fallback-aware: fresh cache-hit → isLoading=false, isRefetching= false; stale-fallback → echte state surface → progress-bar runt
boven stale rijen.
Changed — Body empty-state-guard#
packages/active-grid/src/components/sections/body.tsx +
body-virtual.tsx — guard uitgebreid van if (isLoading) naar
if (isLoading || isRefetching). Onder stale-while-revalidate
zelden noodzakelijk (cachedPage is via fallback zelden leeg) maar
sluit het first-load-window en dient als safety-net. De achterhaalde
comments ("stale data is kept visible with a dimming overlay") zijn
herschreven naar de nieuwe semantiek.
Withdrawn — defensive option B (v2.2.0)#
De v2.2.0 R-14 CHANGELOG-entry documenteerde defensive option B als:
"Defensive option B: cache is gated by
isDatasetStale. Filter/sort change forces a brief loading flash instead of stale-while-revalidate; intentional design to guarantee no stale frame can reach the DOM."
Dat ontwerp paste binnen het skeleton-overlay-model van v2.2.0: zonder onmiskenbare loading-cue was stale-while-revalidate misleidend. v3.1.5's progress-indicator (deze release) maakt de loading-state visueel onmiskenbaar, waardoor het bestaansrecht van defensive option B vervalt. Industry-precedent (React Query, SWR, TanStack Query, Apollo) bevestigt stale-while-revalidate als standaard wanneer een loading-indicator zichtbaar is. Defensive option B is daarmee expliciet teruggetrokken; de R-14-CHANGELOG-entry hieronder bevat een forward-reference naar deze v3.1.5-entry zodat het spoor in beide richtingen klopt.
v2.2.0-tests groen onder stale-while-revalidate#
Beide v2.2.0-integratietests die ADR-016's diagnose markeerde als "verifiëren, niet aannemen":
R-14: page-navigation cache-hit invariant(use-data-orchestrator.test.ts:520). Test intra-block pagination: zelfde datasetKey, alleen pageIndex verandert.isDatasetStale=falsein dit pad; voorstel B raakt deze test niet. Test-comment vermeldt zelfs expliciet: "Wat dit test NIET is: filter-change-flash". Resultaat: groen.R-14 S3: stale-prevention on groupBy change(use-server-grouping.test.ts:719). Test dat een mid-flight oude- groupBy-fetch wordt afgewezen viasetGroupIfToken-token bij resolve, en dat expanded-state synchroon clearet bij groupBy-switch. Token-pad blijft ongewijzigd onder voorstel B; expanded-clearing gebeurt inuseServerGrouping's dataset-key-effect (los vanuseDataOrchestrator). Resultaat: groen.
Geen test-aanpassingen nodig. Geen design-conflict tussen stale-while-revalidate en de stale-write-protection-tests.
Verification#
pnpm --filter @workspace/active-grid test— 1308/1308 passing.pnpm typecheck— alle 38 packages clean.- Visueel (Walt te verifiëren): rijen blijven zichtbaar tijdens
filtering, sortering, snelle cross-block-pagination op
/test-server-grid,/test-server-grouping,/test-minimal-grouping?step=0..7, en/users(productie-grid met echte Prisma-query-latency).
Follow-up#
- TD-4 (was over render-cascade van debouncedColumnFilters-ref- mutatie + skeleton-flash) — de skeleton-flash-helft is nu structureel opgelost via deel 1 + 2. Wat resteert is alleen de render-cascade efficiency-vraag. TD-4-instrumentatie blijft actief; herzien in separate ronde.
- TD-5 is in v3.1.5 doorgevoerd: zie "Internal — TD-5 fix" hieronder.
Internal — TD-5 fix (action-via-ref op prefetch-effect)#
Tweede instantie van het ADR-015 action-in-deps anti-pattern,
opgelost in dezelfde release. use-data-orchestrator.ts:352-358's
prefetch-effect bevatte prefetchAdjacentBlock in zijn deps. Bij de
useCallback-cascade die fetchBlock triggerde, zou dit dezelfde
abort-cleanup veroorzaken als ADR-015's bug op de fetch-trigger
effect. Niet user-visible (prefetches togglen geen isLoading),
maar het patroon was onveilig en wachtte op de eerstvolgende cascade-
trigger om opnieuw destructief te worden.
const prefetchAdjacentBlockRef = React.useRef(prefetchAdjacentBlock);React.useEffect(() => { prefetchAdjacentBlockRef.current = prefetchAdjacentBlock;});
React.useEffect(() => { if (mode !== 'server' || !isCacheHit || !cachedPage) return; prefetchAdjacentBlockRef.current(pageIndex, cachedPage.totalCount); return () => { prefetchControllerRef.current?.abort(); };}, [mode, isCacheHit, cachedPage, pageIndex]); // prefetchAdjacentBlock niet meer in depsADR-015's "Cross-cutting les" — actions horen niet in effect-dep- arrays — toegepast op een tweede plek in dezelfde hook.
[3.1.4] — 2026-05-21 — fetchData action-via-ref in useDataOrchestrator (ADR-015)#
Fixes the /test-server-grid "no results" hang where in-flight fetches
were aborted by spurious effect-cleanups before they could resolve. The
race surfaced sporadically because the abort-cascade fires after the
useDebounce 150-300 ms timer, while the mock fetch has a random 200-500 ms
latency — the symptom appeared as "soms wel, soms niet werkt het". Same
latent-since-v1.0.x flank as ADR-014's grouping-sync: aggressive
re-render cadence introduced by the v1.0.71 R-14 useSyncExternalStore
rewrite widened the timing window enough to consistently expose it.
Root cause (in one paragraph)#
The fetch-trigger useEffect in useDataOrchestrator had fetchData
in its dep array. fetchData is a useCallback with 11 inner deps,
several of which (fetchBlock, prefetchAdjacentBlock,
prefetchNextBlock) are themselves useCallbacks that depend on
debouncedColumnFilters / debouncedGlobalFilter. When one of those
useDebounce results ref-mutated (verified runtime: arr[0]→arr[0]
— same empty content, different reference), the cascade rotated
fetchData's ref. The fetch-effect saw the new ref → fired cleanup →
aborted the in-flight controller → spuriously cancelled a fetch
mid-flight. The in-flight-token-guard (line 495) then blocked the
re-fire because the aborted fetch's async finally hadn't run yet
(inFlightTokenRef.current = null happens on a later microtask).
Deadlock: cache empty, guard blocks, no state-change route resets it.
Fixed#
Action-via-ref pattern. fetchData is now wrapped in a ref that's
synced via a no-deps effect on every render. The fetch-trigger
useEffect calls fetchDataRef.current() and no longer includes
fetchData in its deps:
const fetchDataRef = React.useRef(fetchData);React.useEffect(() => { fetchDataRef.current = fetchData;}); // no deps — keeps ref at latest version
React.useEffect(() => { if (mode !== 'server' || isCacheHit || isDatasetStale) return; if (inFlightTokenRef.current === store.getInvalidationToken()) return; fetchDataRef.current(); return () => { controllerRef.current?.abort(); prefetchControllerRef.current?.abort(); };}, [mode, store, isCacheHit, isDatasetStale, debouncedPageIndex]);// ^^^^^^^^^^^^^^^^^^^// primitive Object.is-stable// — preserves cache-miss// trigger on pagination.debouncedPageIndex is added as a primitive trigger so pagination to a
non-cached block still fires the fetch after the 150 ms debounce
settles (previously this was implicit via fetchData's inner-deps).
Other legitimate triggers preserved: isCacheHit flip (cache-miss),
isDatasetStale flip (sort/filter/globalFilter via datasetKey),
fetchFn swap (separate detection-effect bumps invalidation token).
Considered, rejected#
A simpler symptom-patch — sync-resetting inFlightTokenRef.current = null
inside the effect cleanup — would unblock the re-fetch path but break
the refetch() / retry() double-fire guard (imperative refetch sets
the token synchronously; a subsequent re-render must NOT fire again on
the same token). Root-fix over symptom-patch; see ADR-015.
Cross-cutting les (shared with ADR-014)#
Two distinct bugs (ADR-014 bidirectional grouping-sync, ADR-015
action-in-deps) share one wortel: identity-instability causing
false-positive effect-re-fires. Documented vuistregel for active-grid
engine code: actions (one-shot initiators like fetchData,
triggerInvalidate, emit*) belong in a useRef-sync, not in an
effect-dep-array. Effect-deps should hold only data fields where a
change is a legitimate re-fire signal.
Removed#
- Diagnostic instrumentation that proved the loop:
__diagPrev,__diagInnerPrev,__diagEffectFiresrefs and[DIAG …]console logs inuse-data-orchestrator.ts;__fetchCounter/__activeFetchCounter/[orders fetch …]logs inapps/admin/.../test-server-grid/grid/grid-utils.ts;__orderGridMountCounter/ mount-tracker useEffect inapps/admin/.../test-server-grid/grid/grid.tsx; theinstrumented-fetch.tswrapper inapps/admin/.../test-minimal-grouping/(re-pointed to the regularcreateGroupingFetchFn).
Follow-up TD#
- TD-4 (was TD-6 vóór de documentatie-audit op 2026-05-22, zie
hernummerings-noot in
ROADMAP.md): thedebouncedColumnFiltersref-mutation (leeg-naar-leeg) that triggered the cascade is still latent. Fix A makes it irrelevant for the hang, but it produces unnecessary re-renders. Suspected origin: a Strict-Mode interaction withuseDebounce'suseState(value)initialiser, or an upstream state-mutation instate.columnFiltersthat wasn't traced. Worth a focused follow-up to keep render-budget predictable for hot-path grids. - TD-5 (also surfaced by the 3.1.4 diagnose, registered during
the documentatie-audit): the prefetch-effect at
use-data-orchestrator.ts:352-358has the exact same action-in-deps anti-pattern that Fix A solved on the fetch-trigger effect.prefetchAdjacentBlockis in its deps; when the same cascade rotates its ref, the cleanup aborts in-flight prefetches. Not user-visible today (prefetches don't toggle isLoading) but a known defect pattern that should be fixed in the same round as TD-4 (shared diagnostic scaffolding).
Verification#
pnpm --filter @workspace/active-grid test— 1305/1305 passing.pnpm typecheck— all packages clean.- Visual:
/test-server-grid,/test-server-grouping, and/test-minimal-grouping?step=0..7— to be confirmed by Walt.
[3.1.3] — 2026-05-21 — Server-grouping single source of truth (ADR-014)#
Fixes the server-grouping ping-pong remount loop that surfaced on
/test-server-grouping and was empirically proven via the
/test-minimal-grouping?step=N bisection page plus per-render dep-ref
instrumentation in useServerGrouping. The loop existed since the
initial server-grouping commit (3fa1648, v1.0.7) but stayed latent
until the v1.0.71 R-14 useSyncExternalStore-rewrite of the cache stores
made render-cadence aggressive enough to expose it.
Root cause (in one paragraph)#
ActiveGrid held the active server-grouping in two states —
effectiveServerGroupBy (local) and the engine's grouping slice
(store) — and synchronised them via two useEffects in opposite
directions (effective → engine and engine → effective). On mount the
local state initialised from props.serverGroupBy (non-empty), the
engine slice initialised at [] (engine default), and both effects
fired in the same commit with stale closure-captured values of the
other state. Each effect undid the previous flip; effectiveGroupBy
oscillated every render → useServerGrouping's fetchTopLevel
useCallback got a new ref each render → the
useEffect([enabled, fetchTopLevel]) re-fired → topControllerRef
aborted the previous fetch → new fetch fired → infinite abort/refetch.
Fixed#
Single source of truth for the active server-grouping:
- State
serverGroupBy: GroupByColumn[]inActiveGridis the sole bron. Initialised fromprops.serverGroupBy ?? []. Toolbar mutations callsetServerGroupByvia an intercepted engine-onGroupingChangecallback (handleEngineGroupingChange); they no longer write into the engine store directly. The previous engine→effective syncuseEffect(grid.tsx:257-277) and itsuseStore(grid.baseStore, selectGrouping)subscription are deleted. - One-way mirror
useEffectwritesserverGroupBy.map(g => g.field)intogrid.baseStore.state.groupingvia directstore.setState(notgrid.setGrouping(...), which would re-call the intercept). No-op-detection: returnsprevwhen the engine grouping content already matches, so subscribers are not notified on inert renders. - Engine
onGroupingChangeoverride wired via newinterceptEngineGroupingChangeprop onuseGridSetupandonGroupingChangeOverrideonuseGridOptions. Only set whenisServerGroupingActiveis true — client-grouping consumers keep the engine's defaultmakeStateUpdater("grouping", grid). - Consumer-facing
props.onGroupingChangeobservation callback is unaffected. Theuse-grid-setup.ts:393-401store-subscriber sees the mirror-effect's engine update and fires the callback on every grouping change, exactly as before.
Removed#
- Diagnostic instrumentation in
use-server-grouping.tsthat pinpointed the loop ([DIAG useServerGrouping] dep refs changed: …logs) is removed in the same commit as the fix.
Architecture note — see ADR-014#
The bidirectional-sync pattern is an anti-pattern: it relies on closure- captured staleness to converge, which fails on initial-state mismatches. The fix replaces it with a single source of truth and a one-way mirror to the engine — the engine state becomes a derivative, not a peer. Future server-grouping refinements (fase 3, R-23 — group-by + aggregaten runtime) build on this foundation.
Decisions on the four prior v3.1.x defensive patches#
Diagnose confirmed they are all innocent and defensive — no revert needed. Retained as-is:
isSameServerGroupByvalue-equal comparator (3.1.2) — still guards theprops.serverGroupBy → serverGroupBymirror edge against inline array literals.recordKeyRemountUnmountcleanup (3.1.2) — still blocks the Next.js navigation false-positive indetectKeyRemount.- Badge-preset
enableSorting: trueremoval (3.1.1) — still correct. - Bouwsteen-3 stale-state pruning in
loadInitialGridState(3.1.2) — still closes the fase-1 hardening gap.
Verification#
pnpm --filter @workspace/active-grid test— 1305 tests passing.pnpm typecheck— 38/38 packages clean.- Visual: minimal-grouping repro stable through all 8 steps (0-7);
/test-server-groupingstable (no flicker, no console abort/refire noise);/test-server-gridto be re-verified post-fix per the separate-diagnosis caveat in the briefing.
[3.1.2] — 2026-05-21 — Fixes: serverGroupBy loop, remount-detector false-positive, bouwsteen-3 hardening#
Three fixes surfaced by fase-2 visual testing. None changes the public API or required consumer migration; together they tighten the engine against pre-existing instability and close one loose end from fase-1.
Fixed — serverGroupBy infinite remount loop#
ActiveGrid previously used a reference-comparison to gate the
sync-effect that mirrors props.serverGroupBy into the
effectiveServerGroupBy state (grid.tsx:170-176). Consumers that pass
serverGroupBy as an inline array literal — the common case — get a
fresh array reference every render, defeating the ref-check: setState
fires every render → re-render → fresh inline array → setState →
infinite remount loop. Symptom: multi-second hang on
/test-server-grouping with a deep
recursivelyTraversePassiveMountEffects stack-trace.
Replaced with a value-equal comparator (isSameServerGroupBy in
src/utils/is-same-server-group-by.ts) that compares field and sort
per entry. Pre-existing bug (not fase-1/fase-2 regression); engine-side
fix protects every future consumer that passes the prop inline.
Fixed — detectKeyRemount false-positive on Next.js navigation#
The module-level mountTracker in key-remount-detector.ts persists
across Next.js navigations within the same JS environment. Navigating
away from a grid-page and back (legitimate routing flow) re-mounted
ActiveGrid with the same gridId; the detector fired the warning
designed for the <ActiveGrid key={refreshKey}> anti-pattern even
though the consumer did nothing wrong.
Added recordKeyRemountUnmount — a cleanup-pair called from
ActiveGrid's mount-effect. The cleanup clears the tracker entry only
when the user spent ≥ 2 seconds on the page before unmounting (likely
legitimate). Rapid unmount/remount cycles (< 2s, the actual anti-pattern
target) leave the entry intact so the next mount still fires the
warning. Trade-off: slow key-prop anti-patterns where the user mutates
every 5+ seconds become false-negatives — acceptable; their blast
radius is low compared to the false-positive's confusion cost.
Added — Bouwsteen-3 stale-state pruning for sorting / columnFilters#
loadInitialGridState already filtered out stale column-IDs from
columnVisibility, columnSizing, columnOrder, and columnPinning.
It did not filter sorting and columnFilters — entries
referencing column-IDs no longer present in the current grid sat in
state forever (TanStack does not auto-prune). After fase-1 introduced
bouwsteen 3, the same entries can also reference columns that exist
but have no server.sort / server.filter mapping; visual effect is
identical (state present, UI inert).
Added symmetric pruning for sorting and columnFilters based on the
columnIds set already passed for the other state slices. Option 2a
from the diagnose-report — based on column-id existence, not on
contract presence. Contract-aware pruning (option 2b) is deferred to
TD-4 because it requires threading the resolved-contract into
hydration — a fase-3-territory refactor.
Tests#
Total 1305 (was 1286, +19):
is-same-server-group-by.test.ts— 8 cases covering undefined/empty equivalence, inline-array-loop scenario, field/sort mismatches, order-matters.key-remount-detector.test.ts— 4 new cases: legitimate-unmount clears tracker, rapid-unmount preserves tracker, defensive no-op paths for unknown / undefined gridId.load-initial-state.test.ts— 5 new cases + 2 updated for the bouwsteen-3 pruning (filter on present column-id, drop key when all stale, respect missing columnIds, URL-hydration prune symmetry).
Not in this patch#
- Contract-aware stale-state pruning (TD-3 — was TD-4 vóór de
documentatie-audit op 2026-05-22, zie hernummerings-noot in
ROADMAP.md): drop sort/filter entries whose column exists but has noserver.sort/server.filtermapping. Requires threading the resolver into hydration; scheduled for fase 3. - Investigation of consumer-side remount cause in
test-server-grid: with the detector fix, the warning Walt observed disappears on navigate-back. If a real consumer-side remount cause still presents itself it surfaces post-fix; until then it was the detector noise.
[3.1.1] — 2026-05-21 — Fix: badge preset no longer forces enableSorting: true#
Removed enableSorting: true from the badge type-preset in
getDefaultColumnTypes. The preset predated bouwsteen 3 (ADR-013) and was
spreading enableSorting: true onto every badge column via
applyColumnTypes. When a badge column declared server.sort: false
(e.g. the userRoles-column on the users-grid, where sort is semantically
empty because users have multiple roles), the result was an inconsistent
column-def with enableSorting: true AND server.sort: false — exactly
the configuration that bouwsteen 3's dev-warning catches.
End-state behavior unchanged. Sort UI remained off in both cases — bouwsteen 3's "force off + warn" already handled it. The fix removes the warning by removing the upstream source of the conflict.
- Client-mode badge columns: TanStack's own default (
enableSorting: true) still applies, no behavioral change. - Server-mode badge columns with
server.sort: bouwsteen 3 derives the flag from the mapping; behavior unchanged. - Server-mode badge columns without
server.sort: bouwsteen 3 now derivesenableSorting: falsecleanly without the warn-and-force-off path. Same end-state, cleaner derivation.
The dev-warning itself stays in place — it still catches the case where
a consumer explicitly writes enableSorting: true on a column without
declaring server.sort. Just no longer fires for "innocent" badge
columns.
[3.1.0] — 2026-05-21 — Server-side query-contract phase 2 (globalFilter)#
Implements the global runtime of the column-contract. The toolbar's
single-search box now translates to a Prisma OR-clause across columns
that opt in via column.server.global, joined to column-filters and
extraWhere via top-level AND. Columns without a global mapping
fail-closed — typed queries silently drop their column contribution
instead of reaching Prisma as a schema-blind fragment, identical to
the phase-1 policy for sort/filter. Closes R-22; group-by + aggregations
(R-23) remain reserved.
This is a minor bump — purely additive. Zero production grids use
the search-box today; consumers opt in voluntarily by setting
toolbar.search.enabled = true and declaring column.server.global
mappings.
Added#
@workspace/active-gridmain barrel — global helpers:globalField(path?, options?),globalSomeByField({...}),globalEveryByField({...}),globalNoneByField({...}). Same declarative pattern as the phase-1 sort/filter helpers; pure object builders without Prisma imports.@workspace/active-grid/serversubpath — server-onlyrawGlobal(build)escape-hatch for closures that the wire-shape cannot transmit.queryBuilder.toPrismaGlobalFromContract(query, contractMap, options)— new contract-aware function. Joins per-column condition fragments under oneOR-clause; returnsundefinedfor empty queries or when no column contributes.useToolbarSearchWarninghook — internal. Fires a one-time-per-gridIddev-warning when a server-mode grid setstoolbar.search.enabled = truebut no column declaresserver.global. Does not force-off the search-box; consumer choice is respected, the warning surfaces the development-fault. Matches the B-3 design decision from the phase-2 plan (not a phase-1 bouwsteen-3 symmetry — the toolbar is grid-level, not per-column-interactive).
Changed#
createGridServerActionfolds globalFilter into the where-clause via top-levelAND, not via spread. This preserves anyOR-clause that aFilterMultiFieldMappingcolumn-filter may have produced; the two OR-clauses stay semantically separate. WhenglobalFilteris empty or no column contributes, behavior is identical to v3.0.0.
Schema-loosening — path?: string on three field-mappings#
GlobalFieldMapping.path, GroupByFieldMapping.path, and
AggregateScalarMapping.path are now path?: string (optional), matching
the existing SortFieldMapping.path? and FilterFieldMapping.path?.
v3.0.0 had path: string (required) on these three — an asymmetric design
gap that surfaced when implementing the phase-2 helpers. Loosening a type
constraint (required → optional) is non-breaking: existing
path: "x"-mappings continue to type. No consumer is forced to migrate;
the C3 schema-stability promise for the reserved sub-keys is not violated
(that promise is about wiring breaking changes through major bumps, not
about refinements that strictly broaden the set of valid consumer code).
The change is explicitly logged in ADR-013 — Bekende technische schuld → Schema-loosening v3.1.0. Future asymmetry-fixes will need the same explicit ADR note; treating schema-loosening as a free-for-all "refinement" path would erode the C3 discipline that makes the reserved keys trustworthy in the first place.
Migration#
None required. Consumers that want server-side global search add
server.global to the relevant columns and set
toolbar.search.enabled = true. See
migration/v2-to-v3.md § "Step 6 — opt in to globalFilter (v3.1.0)" (removed in 2026-09).
[3.0.0] — 2026-05-21 — Server-side query-contract (phase 1: sort + filter)#
Introduces a per-column server-side query-contract that replaces the
schema-blind query-builder. Closes a structural Prisma-crash class
(prismaModel.findMany invocations that included a relation-name in
orderBy / where) and aligns active-grid's mental model with
enterprise patterns (AG Grid SSRM, TanStack Table): the grid sends a
neutral intent, the server translates it via explicit per-column
mappings, and unmapped fields are fail-closed instead of forwarded to
the ORM. See ADR-013 — full design rationale
including overwogen alternatieven (A1/B2/C1/C2) is in the ADR sub-
section "Overwogen alternatieven", geabsorbeerd uit het oorspronkelijke
RFC-werkdocument bij de v4.0 documentatie-opruiming.
This is phase 1 of three: sort and filter are active; global,
groupBy, and aggregate are reserved (their type-schemas are locked
so phase 2 and 3 cause no schema-change on consumers, but their runtime
behavior is a no-op with dev-warning if used).
Breaking changes#
FilterFieldConfig/FilterFieldConfigMapremoved from the public exports. Per-column filter mappings now live oncolumn.server.filtervia the declarative helpers (scalarField,fieldFilter,multiField,someByField,everyByField,noneByField).CreateServerFetchFnConfig.filterFieldConfigremoved; replaced by a newcolumnsfield that letscreateServerFetchFnextract each column'sservercontract into a serializablecolumnContractmap transmitted per request.GridServerActionRequest.filterFieldConfigremoved; replaced byGridServerActionRequest.columnContract: Record<string, ServerColumnContract>.column.enableSorting/column.enableColumnFiltersemantics hardened. When the column has noserver.sort/server.filtermapping (and no DMMF default exists for the column-id), the header no longer offers sort/filter UI even when these flags are unset/true. ExplicitenableSorting: truewithout a mapping logs a dev-warning and is forced off to avoid silently-stripped instructions. ExplicitenableSorting: falsealways wins (consumer override). The policy is applied only formode="server"grids — client-mode defaults are untouched.
Added#
@workspace/active-gridmain barrel — declarative helpers:scalarField,byField,byCount,fieldFilter,multiField,someByField,everyByField,noneByField. Plus the contract-shape types:ServerColumnContract,ServerColumnOverride,SerializableSortMapping,SerializableFilterMapping,SerializableGlobalMapping,SerializableGroupByMapping,SerializableAggregateMapping,ResolvedColumnContract,ResolvedColumnContractMap, and each tagged-mapping interface.@workspace/active-grid/serversubpath — server-only:rawSort/rawFilter(closure-based escape-hatches for theserverColumnOverridesfactory parameter),generateDmmfDefaults,resolveColumnContract,resolveAllColumnContracts,warnStripped. Plus types:DmmfModelMeta,DmmfFieldMeta, and the fourRaw*mapping interfaces.createGridServerAction({ serverColumnOverrides, dmmfModelProvider })— new opt-in factory parameters.dmmfModelProvider(modelName)returns DMMF metadata for the requested Prisma model; the factory generates scalar-only default mappings from it.serverColumnOverridesis a per-column override map that may containrawSort/rawFilterclosures and has the highest authority in conflict-resolution.queryBuilder.toPrismaOrderByFromContractandtoPrismaWhereFromContract— contract-aware variants used by the factory. The legacytoPrismaOrderByandtoPrismaWhereFromFiltersremain only for the yet-to-migrate group-by path (phase 3 will move group-by onto the contract too and remove them).- Three-layer conflict-resolver. DMMF defaults (lowest) → wire
contract (
column.server) → server-side overrides (highest). Per column-id, the highest authority wins as a whole; explicit per-sub-keyfalseoverrules lower layers for that sub-key only. Unmapped sort/filter instructions are silently stripped with one dev-warning per(model, column, capability)tuple.
Changed#
createServerFetchFn(config)accepts a newcolumnsfield (GridColumnDef<TRow>[]) used to extract per-request column-contract. Migration is mechanical: pass the samecolumnsarray you already give to<ActiveGrid>.useColumnManager/enhanceColumnsthread the grid'smodethrough so server-mode-only capability resolution can run.- dbCore + dbArchives action wrappers wired with a structural DMMF
adapter that reads
_runtimeDataModel.modelson the Prisma client and adapts it to active-grid'sDmmfModelMetashape. Falls back gracefully (no defaults generated) if the runtime structure is unavailable.
Migration#
See docs/migration/v2-to-v3.md (removed in 2026-09) for the
mechanical recipe. Highlights:
- Replace
filterFieldConfig: {...}oncreateServerFetchFnwith acolumnsprop pointing at the column definitions. - For each server-mode column, add a
serverfield with the right helper.scalarField()covers ~80% (sort + filter on column-id); nested 1:1 paths usescalarField("user.profile.displayName"); one-to-many relations use{sort: byCount(...), filter: someByField(...)}. - The
userRoles-style v2.x demping (enableSorting: false+enableColumnFilter: falseon a relation column) is replaced by a proper contract —{sort: byCount("userRoles"), filter: someByField({...})}.
[2.2.0] — 2026-05-21 — R-14 + R-21 batch (ADR-005 Pattern 3 retired)#
Retires ADR-005 Pattern 3 (render-time imperative cache) by rewriting
BlockCache + GroupCache around useSyncExternalStore. 5 inline
react-hooks/refs disables eliminated (4 in use-data-orchestrator.ts,
1 in use-server-grouping.ts). Expands the benchmark suite from 4 to 9
scenarios with N=5 variance reporting and a frozen pre-rewrite baseline
(R-21). No public API changes — fully internal. Closes R-14 + R-21.
Changed (internal)#
-
BlockCache+GroupCachepeek/touch split + setMaxSize. Reads are now purepeekBlock/peekPage/peekAllData/peekGroupmethods that don't touch LRU; freshness is signalled only by writes and explicittouchBlock/touchGroup. Each cache addssetMaxSize(newSize): booleanreturning true on eviction so the store-wrapper can decide whether to notify subscribers. Effective policy shifts from "evict least-recently-read" to "evict least-recently-written" — acceptable for fetch-block caching since writes are the strongest freshness signal anyway. -
useDataOrchestratorrewritten aroundBlockCacheStore+useSyncExternalStore. Eliminates 4 inlinereact-hooks/refsdisables inuse-data-orchestrator.ts(former lines 104, 167, 610, 640).-
Defensive option B: cache is gated by
isDatasetStale. Filter/sort change forces a brief loading flash instead of stale-while-revalidate; intentional design to guarantee no stale frame can reach the DOM.WITHDRAWN per v3.1.5 / ADR-016 (2026-05-22). The "no stale frame" rationale assumed a missing loading-indicator made stale rows misleading. v3.1.5's progress-indicator (2-pixel animated bar over the top edge) makes the loading state visually unambiguous, so stale-while-revalidate is now safe and is the new default. See the v3.1.5 CHANGELOG-entry and ADR-016 for the full rationale and trade-off analysis.
-
refetch()andretry()callfetchData()synchronously (imperative consumer-trigger, not a render-time mutation). A token-awareinFlightTokenRefguard in the fetch effect prevents double-fire when isCacheHit transitions during the same refetch cycle. -
controllerRefsemantics unchanged — previous controllers stay abort-able after their fetch resolves (preserves theGRID-TEST-NEW-02 prefetch abortcontract).
-
-
useServerGroupingrewritten aroundGroupCacheStore+useSyncExternalStore. Eliminates 1 inlinereact-hooks/refsdisable inuse-server-grouping.ts(the Pattern-3 GroupCache cacheRef init). Fetches go throughsetGroupIfToken(token, ...)for stale-fetch protection — group-by switch mid-flight rejects the old fetch's result via token-mismatch.- Explicitly preserved (different patterns, outside R-14 scope):
the
set-state-in-effectdisable on thefetchTopLeveluseEffect (Pattern 5) and the inlinereact-hooks/refsdisable on therowsRef.current = rowsshadow-ref mirror (Pattern 2).
- Explicitly preserved (different patterns, outside R-14 scope):
the
Added (internal)#
BlockCacheStore<TData>class (src/hooks/block-cache-store.ts). WrapsBlockCachewith theuseSyncExternalStorecontract (subscribe/getSnapshot/getServerSnapshot), monotonicinvalidationTokencounter for stale-fetch protection, andsetBlockIfTokenas the token-gated write path. Token-only — never key-comparison, since two invalidations can produce the same dataset key. Internal-only, not exported via the package barrel.GroupCacheStore<TData>class (src/hooks/group-cache-store.ts). Same contract asBlockCacheStore, keyed on group-key-path (unknown[] + blockIndex). AddsinvalidateGroup(groupKeys)for partial subtree invalidation without token-bump (used to refetch one group without disturbing siblings). Internal-only.- 24 new tests:
- 8 unit tests in
block-cache-store.test.tsincl. the invalidate→invalidate→invalidate token-protection scenario (review-1's explicit ask: token-comparison must distinguish where key-comparison cannot) and a StrictMode-style double-subscribe dedup test. - 7 unit tests in
group-cache-store.test.ts(parallel structure plusinvalidateGrouppartial-invalidation case). - 5 additional
BlockCachetests inpage-cache.test.ts(peek/touch/setMaxSize) and 4 additionalGroupCachetests ingroup-cache.test.ts. - 2 integration tests: page-navigation cache-hit invariant in
use-data-orchestrator.test.ts(guards the server-mode UX promise: intra-block pagination shows cached data synchronously in the same render-pass — no extra fetch, no ghost-data, no loading flash, not even for one frame), and groupBy-switch stale-prevention inuse-server-grouping.test.ts(verifies that a slow fetch for the old groupBy is silently dropped via token-mismatch when groupBy changes mid-flight).
- 8 unit tests in
- 5 new benchmark scenarios + N=5 variance infrastructure (R-21):
- server-mode-block-cache — 10K rows, 50 ms simulated latency,
50 refetches via
gridRef.refetch(). Exercises the BlockCache invalidate + token + setBlock pipeline directly. - grouped-3level (collapsed + expand sub-trials) — 10K rows × 3 group-levels (department × active × ageBucket).
- column-virt (on / off sub-trials) — 1K × 200.
- editing-100k (idle / active sub-trials) — 100K rows in
editMode="cell". - infinite-scroll — 100 blocks × 50 rows with
blockSize=50/cacheSize=10. - Helper
benchmarks/bench-utils.tsprovidesrunWithVariance(N=5 + warmup → median + p95 + stddev) andcreateBenchFetchFn(deterministic mock server-fetch). Output JSON includesruns[],median,p95,stddevper metric. Frozen pre-rewrite baseline atbenchmarks/results-before-r14.json; post-rewrite output atbenchmarks/results-after-r14.json; partial S2 snapshot atbenchmarks/results-after-block-cache.jsonfor traceability.
- server-mode-block-cache — 10K rows, 50 ms simulated latency,
50 refetches via
BENCHMARK_OUTPUT_PATHenv-var — explicit output-path so the baseline file cannot be silently overwritten by a debug-filtered vitest run. Default-path preserved when the env-var is unset.BENCHMARK_NAVIGATIONSenv-var — overrides the refetch-loop count for scaling tests (default 50; used to verify constant-cost vs accumulating overhead in the S2 regression analysis).- Profiling instrumentation in
BlockCacheStore+GroupCacheStore, gated byBLOCK_CACHE_STORE_PROFILE/GROUP_CACHE_STORE_PROFILEenv-vars respectively. Counters: notify-count (with per-reason breakdown), getSnapshot count, subscribe/unsubscribe, peak listener count, listener invocations,setBlockIfToken/setGroupIfTokenaccepted/rejected, invalidate count. Zero overhead when env-vars unset. Used to diagnose and validate the accepted regression below.
Documented#
- Documentation restructured to the canonical 7-doc layout used by
other platform packages (
active-state,active-charts,active-errors,active-auth). Root-levelREADME.mdandLICENSEremain; everything else — includingCHANGELOG.md— now lives underdocs/. New layout:docs/INDEX.md,docs/ARCHITECTURE.md,docs/FUNCTIONEEL-OVERZICHT.md,docs/ROADMAP.md,docs/DONE.md,docs/DECISIONS.md(canonical NL files);docs/api/(5 files, split of the former 1276-linedocumentation/api-reference.md);docs/guides/(7 files:columns,editing,export,filters,keyboard,server-mode,styling);docs/migration/(2 files:from-pre-1.0.md,from-aggrid.md);docs/e2e/(1 file, Playwright setup). Engine-helper section moved todocs/ARCHITECTURE.mdas contributor-only material. LICENSEcopyright holder corrected to "Active Development, an Active Monkeys BV company" (matches other platform packages). TanStack Table v9 portion-clause unchanged.README.mdtrimmed from 559 → ~280 lines. Server-mode deep-dive, dev-warnings table, selector-performance notes, focus-restoration pattern, and row-selection reference moved todocs/guides/server-mode.mdanddocs/api/. Code examples scrubbed of Geneax-internal symbols (dbCore,PERMISSIONS,performAuthCheck) — examples now use generic types so external adopters can compile them as-is.docs/DECISIONS.mdexpanded from 6 to 12 ADRs. Original ADR-001 through ADR-006 preserved verbatim. New: ADR-007 (engine fork rationale), ADR-008 (mode discriminated union), ADR-009 (useGridMutationas required consumer API), ADR-010 (1.0.0pageSize→fetchSizerename), ADR-011 (wrapper-validator contract runtime guard), ADR-012 (headless-first + CSS-var theming).docs/PERFORMANCE.mdrewritten for the 9-scenario / N=5 layout with measured before/after table, jsdom-caveats section, and an explicit measurement-noise discussion (see Performance section below).docs/adrs/ADR-005-active-grid-library-style-react-19-patterns.md— Pattern 3 marked DONE — retired 2026-05-21.
Performance#
Baseline benchmarks/results-before-r14.json (pre-R-14, N=5 medians)
vs benchmarks/results-after-r14.json (post-R-14, full S3 state,
N=5 medians). Per-scenario gate: median-after ≤ median-before × 1.05
OR explicit acceptance in this CHANGELOG.
| Scenario / metric | Baseline | After | Ratio | Status |
|---|---|---|---|---|
| 1K×20 / initialRenderMs | 53.0 | 52.3 | 0.99 | ✅ |
| 10K×20 / initialRenderMs | 55.9 | 57.1 | 1.02 | ✅ |
| 100K×20 / initialRenderMs | 173.1 | 232.1 | 1.34 | ⚠ N=5 noise |
| 1K×100 / initialRenderMs | 209.9 | 251.0 | 1.20 | ⚠ N=5 noise |
| grouped-3level-collapsed / initialRenderMs | 134.1 | 133.7 | 1.00 | ✅ |
| grouped-3level-expand / initialRenderMs | 131.1 | 163.0 | 1.24 | ⚠ N=5 noise |
| column-virt-on / initialRenderMs | 555.6 | 551.6 | 0.99 | ✅ |
| column-virt-off / initialRenderMs | 559.5 | 559.9 | 1.00 | ✅ |
| editing-100k-idle / initialRenderMs | 291.1 | 226.7 | 0.78 | ✅ (faster) |
| editing-100k-active / initialRenderMs | 193.1 | 147.6 | 0.76 | ✅ (faster) |
| server-mode-block-cache / timeToFirstRowMs | 148.3 | 105.9 | 0.71 | ✅ (faster) |
| server-mode-block-cache / avgNavLatencyMs | 89.9 | 102.5 | 1.14 (+14 %) | ❌ accepted |
| server-mode-block-cache / p95NavLatencyMs | 116.5 | 169.5 | 1.46 (+46 %, +53 ms tail) | ❌ accepted |
| infinite-scroll / timeToFirstBatchMs | 56.4 | 98.8 | 1.75 | ⚠ N=5 noise (see below) |
All invariants exactly preserved: domNodes constant across scenarios,
refetchCount = 50 in server-mode, fetchCallCount = 51 in
infinite-scroll, domNodesAfterMount = 6 403 in column-virt — no
double-fetches, no skipped-fetches.
Two categories of gate breach:
-
Accepted intrinsic regression (
server-mode-block-cacheavgNavLatency + p95NavLatency). Measured in S2 with profile-mode instrumentation: 102 notifies per 50-refetch trial (51invalidate+ 51setBlockIfToken— exact), 1 listener through the whole loop, 0 token-rejected fetches. Scaling test N=50 vs N=100: avgNavLatencyMs 104→100 ms, p95 172→165 ms — constant-cost per refetch, not accumulating. Root cause is intrinsic to theuseSyncExternalStoretwo-notify pattern: cache-invalidation (pre-await) and cache-fill (post-await) cannot batch into a single React scheduling event because a 50 ms fetch separates them. Pre-rewrite collapsed both into one batchedsetAsyncData + setIsLoading + setIsRefetchingafter the await — only possible because the cache was a render-mutable ref (which is exactly what ADR-005 Pattern 3 was, now retired). Real-world impact: production fetch latency dwarfs the ~13 ms constant (Geneax Prisma queries 100–500 ms+); mount-path latency (more user-noticed) improved 30%+ in both server-mode scenarios. -
N=5 measurement noise (
1K×100,100K×20,grouped-3level-expandinitialRenderMs;infinite-scrolltimeToFirstBatchMs). High baseline stddev (40–75 ms in client-baseline scenarios), min/max ranges overlap fully with after-ranges. Theinfinite-scrollbimodal pattern (3 slow + 2 fast trials in the full suite) does not reproduce in an isolated run (pnpm exec vitest run -t "infinite-scroll"median 53.6 ms, matches baseline 56.4 ms within variance) — cross-scenario heap pressure from the additionalGroupCacheStoreallocations shifts one scenario's GC pattern in the full suite, not algorithmic regression. N=5 is too small to discriminate at the 5% gate threshold for high-variance metrics; future tightening would require N ≥ 20 or a robust statistic (trimmed mean).
The accepted trade-off is +14 % avg / +46 % p95 / +53 ms tail on
server-mode refetch latency (read directly from
benchmarks/results-after-r14.json vs results-before-r14.json —
canonical numbers), in exchange for full Pattern 3 retirement,
defensive option B (no stale frames — see WITHDRAWN-note above; v3.1.5
replaces this with stale-while-revalidate), cache-hit invariant
integration-tested, and 5 inline disables eliminated. Mount-path
latency improved 30 %+ in the affected scenarios.
Removed disables (R-14)#
use-data-orchestrator.ts: 4 inlinereact-hooks/refsdisables removed (former lines 104, 167, 610, 640) — Pattern 3 BlockCache.use-server-grouping.ts: 1 inlinereact-hooks/refsdisable removed (former line 171) — Pattern 3 GroupCache.- Total: 5 disables eliminated. ADR-005 Pattern 3 fully retired.
Notes for consumers#
- No public API surface changes.
<ActiveGrid>, all exported hooks (useGridMutation,useGridImperative), helpers (createServerFetchFn,createGridServerAction,createColumnHelper), and types are identical to 2.1.0. This is an internal cache-management refactor. - New internal classes (
BlockCacheStore,GroupCacheStore) are NOT exported via the package barrel — drift-detector clean (222 exports / 265 documented symbols, unchanged from 2.1.0). External adopters cannot reach into them and shouldn't need to. - Tests: 1 221 unit + integration tests green (up from 1 194 at
2.1.0; +27 R-14 + R-21 tests). All existing tests pass unmodified
except for the BlockCache + GroupCache test files which were updated
to use the new
peekX/touchXAPI. - Lint: 0 warnings (
react-hooks/refscount: 0 inuse-data-orchestrator.ts, 0 inuse-server-grouping.tsfor the Pattern-3 occurrences; Pattern-2 shadow-ref + Pattern-5 set-state-in-effect disables intentionally preserved inuse-server-grouping.ts). - Migration: none required. Internal refactor only.
- Release report: see
R14-R21-RELEASE.mdin the repo root for the full before/after numbers, profile-mode data, and disable-elimination audit trail.
[2.1.0] — 2026-05-21 (later same day) — R-12 + R-4 calendar-locale#
Removed#
./clientsubpath export removed frompackage.json. Zero internal consumers used it (all imports went via the root barrel); thesrc/client/directory stays as an internal module for code-organization. Closes R-12. Migration: any external adopter using@workspace/active-grid/clientswitches to@workspace/active-grid— all symbols are identical (createServerFetchFn,useGridImperative,useGridMutation+ their types).
Notes#
- This is technically a public-API removal, hence the minor bump. However,
consumer-impact for Geneax: zero. The repo-wide grep confirmed zero
@workspace/active-grid/clientimports. - See
docs/api/server-mode.md— all import-paths updated to the root barrel.
[2.0.0] — 2026-05-21 (third release same day)#
BREAKING — API stable major release. This release locks in the public API surface following the Traycer audit cleanup. All known compat-cruft has been removed and undocumented features documented.
Breaking changes#
-
createServerFetchFn: thetanderrorKeyconfig-options have been removed. Replace with the newonError?: (message: string) => voidcallback. The factory no longer importssonnerand no longer triggers toasts automatically.Migration (see also
docs/api/server-mode.md):diff+ import { toast } from "sonner"; createServerFetchFn({ serverAction, model, permission,- t,- errorKey: "my.error.key",+ onError: (msg) => toast.error(t("my.error.key", { message: msg })), }); -
<ActiveGrid>row-context-menu copy-actions no longer show a Sonner toast on copy-success/error automatically. Passmeta.onCopySuccessandmeta.onCopyErrorvia themetaprop to restore the behaviour:diff+ import { toast } from "sonner"; <ActiveGrid gridId="users"+ meta={{+ onCopySuccess: (count) => toast.success(t("grid.copy.success", { count })),+ onCopyError: () => toast.error(t("grid.copy.error")),+ }} // ... />Without these callbacks, copy still works — but no UX feedback is shown.
-
sonnerremoved fromdependencies. Consumers using sonner-toast feedback now declare their ownsonnerdependency. Geneax already hadsonneras a workspace-wide dependency, so no change there. -
GridPaginationOptions— four@deprecatedfields removed:showInfo→ useshowRowCountshowPageSize→ useshowPageSizeSelectorshowButtons→ useshowFirstLastButtons+showPrevNextButtonsclasses→ no replacement; drop usage
Added — features#
- New optional
onErrorcallback inCreateServerFetchFnConfig— see Breaking changes above. - New optional
onCopySuccess/onCopyErrorcallbacks inGridMeta<TData>— see Breaking changes above.
Added — documentation#
- 20+ previously undocumented
GridBasePropsprops now indocs/api/grid-props.md:onSelectionModelChange,enableUndoRedo,maxUndoDepth,enableCellSelection,onCellSelectionChange,enableFilterBuilder,initialFilterExpression,onFilterExpressionChange,enableResponsiveHiding,responsiveBreakpoints,externalFilter,postSortRows,enableGroupFooter,enableServerGrouping,serverGroupBy,serverGroupAggregations,loadingDisplay,enableColumnVirtualization,onSortingChange,onFilterChange,enableMultiRowSelection. - New guides:
docs/guides/filter-builder.md— AND/OR composite filter expressionsdocs/guides/server-grouping.md—ServerGroupRow, lazy children, aggregation cells,ServerLoadMoreRow
- Editing guide expanded with Undo/Redo section (
enableUndoRedo,maxUndoDepth, history scope, keyboard shortcuts). - Keyboard guide expanded with Cell-selection section
(
enableCellSelection,CellRange,CellSelectionState). docs/api/filters.mdexpanded withFilterExpression-tree types,evaluateFilterExpression,queryBuilderPrisma helpers (toPrismaWhereFromFilters,toPrismaWhereFromExpression,toPrismaOrderBy,toPrismaGroupBy).docs/api/cells-utils.mdexpanded with editing types:EditMode,EditingCommitPayload,BatchCommitPayload,DirtyCellEntry,CellPosition,CellRange,CellSelectionState.docs/contributing/drift-detector.md— new file documenting thelint:api-driftworkflow for contributors.
Added — CI#
lint:api-driftis now a hard CI gate via theapi-driftjob in.github/workflows/ci.yml. A failing drift-check blocks the PR. Closes R-20.
Changed — internal#
createLocaleSortFnre-export path: now via the engine barrel (./engine) instead of the direct./engine/fns/sortFnspath.@internalJSDoc tags added toFlexLayoutContext,FlexLayoutContextType,GridProvider,SortIndicator,resolveColumnBorderMode, andgetDefaultColumnTypes— these remain exported (for advanced/escape-hatch consumers) but the JSDoc signals to IDE hover-docs that they are not part of the documented public surface.serializeDecimalsdoc-comment updated — removed misleading "backward compatibility" language. The re-export is a deliberate convenience, not a transitional shim.- Stale
MIGRATION-NOTES.mdreferences in source updated todocs/migration/from-pre-1.0.md(3 occurrences across factory + test). - README Quick Start updated to use the canonical
createColumnHelper<TData>()pattern per GENEAX-WERKWIJZE §7.17. - README peer-dependency
next: ^15.0.0 || ^16.0.0→^16.0.0(matchespackage.jsonalready updated in v1.0.5). docs/api/grid-props.md:meta: any→Partial<GridMeta<TData>>.docs/api/columns.md:getGroupingValuereturn typeany→unknown.
Added — i18n for date filters (R-4 follow-up)#
calendarLocale?: Localeprop on<ActiveGrid>and<GridTranslationProvider>. Accepts anydate-fns/localeobject so external adopters can use the date-filter calendar in languages beyond the built-in 5 (en,nl,de,fr,es). Built-in map is kept as fallback — no breaking change.useGridCalendarLocale()hook — returns the consumer-supplied locale orundefined. Used internally by<DateFilter>; consumers building custom date-filter components can use the same pattern.docs/guides/i18n-date-filters.md— new guide documenting the three i18n concerns: time-zone-aware presets (consumer-side, via customdateFilterPresets), operator-label translation (consumer- side, via thegetXOperators(t)factories), and calendar locale (thecalendarLocaleprop).- Tests — 6 new unit tests in
i18n/grid-translation-context.test.tsxforuseGridCalendarLocale,- 7 tests in
utils/date-locale.test.tsfor the built-in fallback map. 1194/1194 tests green.
- 7 tests in
Closes#
- R-4 (Filter-operator-DSL i18n / time-zone — scope reduced; only calendar-locale is library work, the rest is documented as consumer pattern)
- R-17 (§7.14 compat-cruft sweep)
- R-18 (Documented undocumented
GridBaseProps+ 4 missing guides) - R-19 (Documentation polish-batch)
- R-20 (Wire
lint:api-driftin CI) - R-22 (
sonner-import removal +onError-callback)
Notes for consumers#
- Migration is mechanical for the two breaking changes. Geneax-internal
consumers (18
createServerFetchFncall-sites) were migrated in the same release. - Copy-toast feedback for the row-context-menu is opt-in now — pass
the
meta.onCopySuccess/meta.onCopyErrorcallbacks where you want toast behaviour. Geneax-internal grids do not currently wire these (a per-consumer follow-up can re-add them as needed). lint:api-driftruns on every PR; new exports require docs indocs/api/ordocs/guides/or an explicitINTERNAL_ENGINEfilter in the script. Seedocs/contributing/drift-detector.md.- 1181/1181 unit tests green; consumer apps (admin, archives) typecheck + lint clean.
[1.1.0] — 2026-05-21 (later same day)#
Added#
- 9 type-exports newly added to the main barrel (
src/index.ts):GridFooterConfig,GridHeaderConfig,GridHeaderMenuConfig,GridPaginationStyleConfig,GridRowMenuConfig,GridToolbarConfig,ServerOptions,NoValueFilter,SingleValueFilter,RangeFilter. These were used in source and documented indocs/api/*.md, but the main barrel did not expose them — consumers wanting to type a function signature against the same shape had to reach into internal subpaths. GroupingToolbarcomponent re-exported from main barrel. Previously reachable only via the deep import path.- API drift-detector improved: now scans
docs/guides/*.mdin addition todocs/api/*.md; recognizes identifiers inside code-fenced import/export braces (CRLF-aware) and camelCase factory/getter/hook functions. Drift exit-code is now usable as a CI gate. lint:api-driftwired into the turbo pipeline (turbo.json), enablingpnpm turbo run lint:api-driftrepo-wide.
Changed#
docs/api/grid-props.md— removed three section headings that claimed standalone type-exports (GridBaseProps,GridToolbarSearchConfig,GridHeaderMenuFeatures,GridRowMenuFeatures) that did not exist. Replaced with descriptions of the inline-shape these props accept.docs/api/filters.md— removed theFilterTriggersection (component never existed as an export; class namegrid-filter-triggeris used internally but no React component matches the name).docs/api/columns.md— added a "createColumnHelper" section documenting the public column-helper API +ColumnHelper<TData>type-alias with TS-inference grenzen-tabel.docs/guides/editing.md— corrected the row-mode editing claim. Previous text claimed "a save/cancel toolbar appears on the row"; in reality there is no inline toolbar — commit/cancel happens via Enter/Escape keyboard shortcuts or via the row's context-menu items (which switch from "Edit row" to "Save"/"Cancel" while the row is being edited). This closes R-8 (row-mode editing audit).docs/api/server-mode.md+docs/api/cells-utils.md— documentation-claims aligned with actual exports. Phantom-symbols cleaned up.
Notes for consumers#
- 1.1.0 (minor bump) because the added type-exports are additive public API.
- No runtime behaviour changes; no breaking changes.
- API drift-detector status:
pnpm --filter @workspace/active-grid lint:api-drift→✓ API docs in sync with source. 221 exports, 247 documented symbols.Closes R-16. - R-8 (row-mode editing audit) docs-correction included.
[1.0.5] — 2026-05-21#
Added#
docs/PERFORMANCE.md— published benchmark-resultaten + performance-targets per scenario (1K×20, 10K×20, 1K×100, 100K×20). Indexed in INDEX.md. Closes R-10.docs/guides/selection.md— consumer-API for row-selection: inclusive vs exclusive modes, server-side select-all, server-action recipe (PrismanotIn+ filter-context). Indexed in INDEX.md. Closes R-9.scripts/verify-api-docs.ts+lint:api-drifttask — drift-detector that compares actual exports vsdocs/api/*.md-mentions. Initial run found 151 undocumented + 15 phantom entries (tracked under R-16). Closes R-5; R-16 follows up.
Changed#
peerDependencies.next:"^15.0.0 || ^16.0.0"→"^16.0.0". Geneax-monorepo runs Next 16 only; no CI-matrix tested Next 15. Closes R-13.docs/e2e/playwright-setup.md— route-tabel uitgebreid van 3 naar 7 routes. Per route: grid-id, mode, doel-beschrijving. Closes R-7.docs/api/server-mode.md— new "Decimal serialization" sectie documenteertserializeDecimals(PrismaDecimal→numberconversie aan server-action boundary). Closes R-11.
Notes for consumers#
- All 7 roadmap-items in this batch are docs / tooling only. No runtime behaviour changes. No breaking changes.
- Existing consumers can call the new
lint:api-drifttask locally to verify documentation parity for their own active-grid usage; the script is not in CI yet (R-16 follow-up). - R-2 (test-counts in README) was found to be already resolved in an earlier commit — no current README contains the stale "692 tests" / "13 E2E"-claims.
[1.0.4] — 2026-05-20#
Added#
createColumnHelper<TData>()geherexporteerd via main barrel als React-layer-flavored wrapper rondomengine/helpers/columnHelper.ts.TFeaturesis hidden via de standaardReactLayerFeatures = any-escape-hatch (§9 escape-hatch-patterns). Stelt admin/archives/atlas/active-auth-ui columns-configs in staat per-column TValue te infereren voor cell-callbacks zonder(info: any)-annotatie. Canonieke usage gedocumenteerd inGENEAX-WERKWIJZE.md§7.17.ColumnHelper<TData>type-alias voor de helper return-type (TFeatures-hidden).
Notes for consumers#
- Bestaande columns-configs blijven werken —
createColumnHelperis een additie, geen breaking change. - Nieuwe grid-columns-configs moeten
createColumnHelpergebruiken per de huidige column-config-discipline. - 28 bestaande consumer-files (26 admin + 1 archives + 1 active-auth-ui) gemigreerd in dezelfde sessie als deze release.
Bumped from 1.0.3.#
[1.0.3] — 2026-05-20#
R-15 polish completed. Reduces @typescript-eslint/no-explicit-any warnings from 21 → 0 (R-1 closed the bulk earlier; this finishes the residuals).
Changed (internal)#
components/cells/row-numbering-context.tsx—Grid<any>→Grid(defaultDefaultRowData);(grid as any).getPrePaginatedRowModel?.()and(row as any).getIsGrouped?.()replaced with two typed extension-interfaces (EnginePaginationMixin,EngineGroupingMixin) cast onto Grid/Row. The runtime guards (?.()) keep the graceful no-op when a feature isn't mounted.components/sections/{footer,filter-row,group-footer-row,header-cell,server-group-row}.tsx— 6×GridColumnMeta<TData, any>→GridColumnMeta<TData, unknown>(consumers ofmetaonly read fields whose type doesn't depend on TValue).components/sections/header-filter.tsx—Column<any, any>→Column<DefaultRowData, unknown>.components/rows/editable-cell-wrapper.tsx— dropped explicit(r: any)annotation (TS infers fromrows);onChange: (val: any)→onChange: (val: unknown).components/grid.tsx— 2×(prev: any)/(r: any)retained with motivatedeslint-disabledirectives pointing to §9 ofescape-hatch-patterns.md(TanStack-engine bivariance; React-layer hides TFeatures).components/rows/editable-cell-wrapper.tsx— same motivated disable for onefindIndexcallback (same root cause).components/cells/selection-cell.tsx—row: any, grid: any→Row<DefaultRowData>, Grid<DefaultRowData>(typed using existing React-layer aliases).components/lookup-grid.tsx—GridColumnDef<TData, any>[]→GridColumnDef<TData, unknown>[].
Not changed#
- Public API surface: no changes to
<ActiveGrid>,GridColumnDef, hooks, or any exported function/type shape. - Runtime behaviour: no changes.
- Lint cap stays at 1000 (per
eslint-config DECISIONS § D-2package-specific platform-floor for active-grid).
Tests#
- All 1181 tests remain green (no behaviour changes, no test additions).
Roadmap#
- Closes
docs/ROADMAP.mdR-15. Lint now 0no-explicit-anywarnings (R-1 target was <10, stretch 0; stretch achieved).
[Unreleased]#
Empty — the R-14 + R-21 work and the prior docs-restructure work that formerly lived under this anchor have been promoted to
[2.2.0](above). The original Unreleased text contained S2-only intermediate measurements (BlockCache rewrite vs rebuild-baseline:+12 % avg / +44 % p95 / +52 ms tail) that are now stale; the v2.2.0 release reports the full S3 measurement against the same baseline (+14 % avg / +46 % p95 / +53 ms tail). For the S2-only intermediate state seebenchmarks/results-after-block-cache.jsonon disk and the git history of this file.
[1.0.2] — 2026-05-19 — Wrapper-validator contract runtime-guard (T-GX-AG-10)#
A patch release that closes a structural drift-channel discovered the day
after the 1.0.0 pageSize → fetchSize rename: per-database wrapper
Zod schemas in two Geneax consuming packages still validated the legacy
field name, silently stripping the canonical fetchSize so every grid
fetch failed with an unhelpful ActiveGrid fetch error log. The runtime
behaviour is now defended at the factory boundary so future drift of the
same shape cannot recur.
Added#
assertFactoryContractruntime guard increateGridServerAction(src/server/create-grid-server-action.ts). After everyvalidateRequest-success path and after the defaultlightweightValidatepath, the factory checks that the validated request still contains the six canonical fields:model,permission,pageIndex,fetchSize,filters,sorting. A missing field returns{success: false, error: {code: "WRAPPER_VALIDATION_CONTRACT_VIOLATION", field: "<missing-field>", message: "...see MIGRATION-NOTES.md §0.7.x → 1.0.0"}}. The guard runs in every environment — not dev-only — because the failure mode is a silent data-load break.FACTORY_CONTRACT_FIELDSconstant exported only at module scope; the single source of truth for the field-set the factory requires. Future field renames or additions update this list and theMIGRATION-NOTES.mdmigration recipe in the same commit.- Unit tests in
src/server/create-grid-server-action.test.ts— validator-strips-fetchSize, validator-strips-pageIndex, happy-path, and thelightweightValidate-path-still-works case. 4 new tests, total suite at 1181 passing.
Documented#
- README §Wrapper-validator discipline — new subsection under
§Server Mode →
createGridServerActionexplaining the canonical field contract, the guard's error shape, and the discipline expected when upgrading the package across factory-contract changes. - README §Dev-time warnings — added a paragraph noting the
WRAPPER_VALIDATION_CONTRACT_VIOLATIONcode is always returned, not dev-gated. - MIGRATION-NOTES §0.7.x → 1.0.0 §3b — new step describing how to
update per-database wrapper Zod schemas, with before/after code
examples and a verification grep. Inserted between the existing
step-3 (
createServerFetchFn-consumers, no change) and step-4 (wrapper-actions-caller's-choice) entries. docs/adrs/ADR-006-active-grid-wrapper-validator-contract.md— platform-level ADR documenting the wrapper-validator contract, motivation for runtime-enforcement (vs. type-level), and the consequences for external adopters.
Fixed (consumer-side)#
Both Geneax wrapper-action Zod schemas brought back in line with the 1.0.0 canonical field-set. No active-grid source change; documented here for traceability.
packages/active-actions/src/grid/get-active-grid-data-action.ts—pageSize→fetchSizeinbuildSchema's Zod object.packages/archives-actions/src/lib/get-archives-grid-data-action.ts— same rename.
Not changed#
- No public API surface change.
GridServerActionRequest,GridActionResult, the factory signature, and the response shape are identical to 1.0.1. The new error code is additive — existing consumer error-handlers fall through to the genericresult.error.messagepath unchanged. - No client-side changes.
createServerFetchFncontinues to forwardfetchSizeexactly as in 1.0.0.
[1.0.1] — 2026-05-18 — React-19-strictheids-opvolging (T-GX-AG-09)#
Lint-discipline pass on src/components/** and src/hooks/** against the
stricter React 19 / React Compiler rule-set introduced in
eslint-plugin-react-hooks. The library's external API is unchanged; runtime
behavior is unchanged.
Changed#
- Lint cap:
eslint --max-warnings 611→--max-warnings 545. 66 React-19-class warnings cleared from the components and hooks layers; the remaining 545 warnings are all@typescript-eslint/no-explicit-anyinsrc/engine/**,src/utils/**, andsrc/components/cells/**— those belong to the engine-internal type-pass tracked under T-GX-AG-08. grid.tsx—gridHeightVarOverridesmemo: dependency-array members hoisted to local variables (headerHeight,groupHeaderHeight,filterBarHeight) so React Compiler's inferred dep matches the source dep at the same granularity. This fixes thereact-hooks/preserve-manual-memoizationskip-warning and lets React Compiler re-engage on the component.row-numbering-context.tsx— useMemo dep: function-call dependency[(grid as any).getPrePaginatedRowModel?.()]hoisted to a localrowModelconstant so the dep array is a simple expression. Suppressesreact-hooks/use-memo; behavior unchanged.
Documented#
The pass produced one structural finding worth surfacing: a small number of
library-stylepatterns trigger React-19 warnings even though they are the
intended pattern. Each occurrence is now suppressed with an inline
/* eslint-disable react-hooks/<rule> */ ... /* eslint-enable */ block
with motivation. The four patterns are:
- Ref-as-event-memory — refs that store anchor-state for the next
event handler invocation (e.g. shift-click range anchor in
selection-cell.tsx). Mutation is event-time, not render-time, but the rule cannot tell the difference. - Shadow-ref mirror — refs kept in sync with state at render-time so
callbacks that must not see stale closures (
commit,updateValuein the editing-state hooks) can read the latest value synchronously. SeeeditingRef.current = editinginuse-batch-editing-state.tsanduse-editing-state.ts;rowsRef.current = rowsinuse-server-grouping.ts. - Render-time imperative cache —
BlockCache/GroupCacheinstances initialized and read during render so cache hits surface in the same render cycle as the pagination change (zero re-render delay). Affectsuse-data-orchestrator.tsanduse-server-grouping.ts. AuseSyncExternalStorerewrite is the right long-term fix and is out of scope here; tracked under T-GX-AG-08. - Ref-forwarding API — consumer passes a
RefObjectand the component writes into it from inside auseEffect(props.gridRef,props.restoreFocusRefingrid.tsx). Thereact-hooks/immutabilityrule reads "props mutation"; the actual semantics are "ref-forwarding". - External→local mirror in effect — an external source-of-truth value
(
globalFilter,open,isLoading) is reflected into a local mirror state via a guardeduseEffectthat calls setState. Occurs in toolbar search-input, lookup-grid open-flag reset, infinite-scroll loading indicator, and persistence hydration (use-lock-state,use-pinning). Each instance is annotated.
Per-rule treatment summary#
react-hooks/refs(44): all suppressed with motivated/* eslint-disable */blocks inuse-data-orchestrator.ts,use-server-grouping.ts,use-batch-editing-state.ts,use-editing-state.ts, andgrid.tsx(virtualization-latch + flex-layout integration). Patterns are render-time imperative cache, shadow-ref mirror, and one-way latch.react-hooks/set-state-in-effect(11): all suppressed with motivated block disables. Patterns are external→local mirror, hydration-safe persistence load, and guarded one-shot cleanup.react-hooks/exhaustive-deps(5): the two depless-effect cases inuse-batch-editing-state.tsanduse-editing-state.ts(auto-cancel edit on row-disappearance) suppressed with motivated block disables; the three pre-existing inline// eslint-disable-next-linedirectives that the new rule version reported as unused were resolved by the block disables now bracketing the relevant effects.react-hooks/immutability(5): suppressed with motivated block disables for the ref-forwarding-API instances and the ref-as-event-memory instance inselection-cell.tsx. The scroll-correction layout-effect ingrid.tsxand DOM-side-effect ref-clear there are wrapped in the same block.react-hooks/use-memo(1): fixed (hoist function-call dep to a local).react-hooks/preserve-manual-memoization(1): fixed (hoist nested-property deps to locals).
No echte-fix-or-disable pattern was downgraded to "behouden zoals het is" (cap-only); every warning got an explicit decision.
Not changed#
- No public API change. No runtime behavior change. Consumers do not need to migrate.
src/engine/**,src/utils/**, and the 545 remainingno-explicit-any-warnings are untouched — they are T-GX-AG-08-scope.
[1.0.0] — 2026-05-18 — Milestone release#
First stable release of @workspace/active-grid. The API is now considered
externally usable: it can be adopted from outside Geneax with the
ordinary semver guarantees, and the consumer-facing vocabulary is finally
free of the historical pageSize-but-actually-fetch-block-size knot.
What 1.0.0 commits to:
- API-stability promise — exported symbols (
ActiveGrid,useGridMutation,useGridImperative,createServerFetchFn,createGridServerAction,ServerFetchParams,GridServerActionRequest,GridImperativeHandle, theGridColumnDef/ column-type / filter surface) will not be renamed or removed without a major-version bump. - No silent removals — features that exist in 1.0.0 stay until a major release explicitly deprecates them with a migration recipe.
MIGRATION-NOTES.mdcovers every breaking change — including this one (pageSize→fetchSize, see §0.7.x → 1.0.0).
BREAKING CHANGE#
ServerFetchParams.pageSize→ServerFetchParams.fetchSize— the field that lands onfetchFnis now namedfetchSize, because its value is the engine's fetch-block size (rows requested per server call), not the UI page size. The two are independent; the UI page size lives onpagination.pageSizeandpagination.pageSizeOptionsand is unchanged. ThepageSizefield is removed — there is no alias, no deprecation pass, no@deprecatedmarker. Consumers must rename references in the same release per the package-evolution rule (GENEAX-WERKWIJZE §7.14 (source repository:DATYX-WERKWIJZE.md)).GridServerActionRequest.pageSize→GridServerActionRequest.fetchSize— same rename on the server-side action request shape produced bycreateGridServerAction. The factory's validator now returnscode: "INVALID_FETCH_SIZE"(wasINVALID_PAGE_SIZE) and the error message referencesfetchSize. Hand-written consumers that callgetActiveGridDataAction/getArchivesGridDataActiondirectly with a literal request object must use the new field name.- No backwards-compat alias —
pageSizeis gone from the package's public surface.package.json'spageSizefield onPaginationState/pagination.pageSize(the UI page size) is unchanged.
Changed#
- README §Quick Start, §Server Mode, §Top valkuilen — examples and
guidance updated to use
params.fetchSizeand to make the "fetch-block-size, not UI page size" distinction the first thing the reader meets. The historical CHANGELOG 0.6.0 note about the naming knot is replaced by the in-line conceptual explanation now that the name reflects the concept. documentation/server-mode.md,documentation/api-reference.md,documentation/migration-from-aggrid.md— allparams.pageSizereferences and theServerFetchParamstable entries updated. TheComplete Example with Prismablock destructuresfetchSizeand uses it as the Prismatake.createGridServerAction'smaxFetchSizejsdoc — now referencesfetchSizeend-to-end and cross-references the 1.0.0 rename.- All Geneax consumer-code migrated in the same release per
GENEAX-WERKWIJZE §7.14 (source repository:
DATYX-WERKWIJZE.md):apps/admin/.../messages/_components/message-grid.tsx— readsparams.fetchSizeand forwards togetMessagesAction.apps/admin/.../sessions/grid/session-grid.tsx— computesstartRow/endRowfromparams.fetchSize.apps/admin/.../settings/_components/api-key-limits-manager.tsx— inlinegetActiveGridDataActioncall usesfetchSize: 10on theGridServerActionRequest.apps/admin/.../test-server-grouping/grid/demo-fetch.tsandapps/admin/.../test-server-grid/grid/grid-utils.ts— demo fetchers readparams.fetchSizefor their slicing math.apps/archives/.../records/_components/grid/records-grid.tsx,apps/archives/.../expectations/_components/grid/expectations-grid.tsx,apps/archives/.../administrative-places/_components/grid/administrative-places-grid.tsx,apps/archives/.../source-type-vocabulary/_components/grid/source-type-vocabulary-grid.tsxand the twelveapps/adminserver-mode grids that usecreateServerFetchFn— no source-line change required becausecreateServerFetchFnforwards the field internally; they pick up the rename transparently.
Internal#
- Engine internals (
use-data-orchestrator.ts,use-server-grouping.ts,export-utils.ts) updated to sendfetchSizeon everyfetchFncall. Local variables that previously aliased the orchestrator's UIpagination.pageSizeare untouched; only the field name on the outgoingServerFetchParamschanged. __test-utils__/mock-fetch.tsand the integration test's local fetchFn mock readparams.fetchSize. Twouse-data-orchestrator.test.tsassertions (fetchSize: 40) were updated.queryBuilder.buildRequeststill accepts apageSizefield on its own param shape — it's a generic wire-format helper for arbitrary backends, not bound to active-grid's server protocol. The docs example now passespageSize: params.fetchSizeto it explicitly.
Notes for consumers#
- Mechanical migration: in your fetch callbacks, replace
params.pageSizewithparams.fetchSize. If you hand-roll aGridServerActionRequest, replace the literal field. No other consumer surface changes. - See
MIGRATION-NOTES.md§0.7.x → 1.0.0 for the full recipe and the before/after examples for the common consumer patterns. - The rename is preventive: external adopters reading the API for the
first time should never see "the field is called
pageSizebut it actually represents fetch-block size" — that knot is gone.
[0.8.0] — 2026-05-18#
Adds a dev-only detector for the <ActiveGrid key={...}> remount anti-
pattern. After 0.7.0's useGridMutation migration the consumer-side
remount-anti-pattern is no longer reachable from production grids, but a
preventive guard makes the wrong path noisy if a future contributor
reintroduces it.
Added#
<ActiveGrid key={...}>remount detector — a same-gridIdmount that lands outside the React-Strict-Mode noise window (100ms) emits a warn-once log pergridIdand points the consumer atuseGridMutation/gridRef.current?.removeRow(id)/gridRef.current?.refetch(). The detector lives insrc/utils/key-remount-detector.ts; the whole module collapses to a no-op underprocess.env.NODE_ENV === "production"so production bundles tree-shake it out.- README §Dev-time warnings — new section that documents the three
in-package dev warnings side by side:
maxFetchSizebelow block size, client-modeaddRow/removeRowno-op, and the remount detector. The pre-existing "Top valkuilen" entry for the remount anti-pattern was rewritten to point atuseGridMutationfirst and to cross-reference the new diagnostics section.
Internal#
- 8 unit tests in
src/utils/key-remount-detector.test.tscover the single-mount happy path, the missing-gridIdno-op, the Strict-Mode noise window, the warn-once invariant across many remounts, gridId isolation, the test-only__resetKeyRemountTrackerhelper, and the production-mode no-op. ActiveGrid(src/components/grid.tsx) callsdetectKeyRemountonce per mount through a dedicateduseEffect([]).
Notes for consumers#
- No API changes; no migration needed. The detector only adds a console warning — it does not throw, retry, or alter grid behavior.
- The pre-existing T-GX-FIX-01 remount-anti-pattern fix in Records-grid
(replaced by
useGridMutationin 0.7.0) remains the canonical example of what the detector is trying to prevent for new contributors.
[0.7.0] — 2026-05-18#
This release introduces useGridMutation as the recommended consumer API
for the "run async mutation, then patch the grid" pattern. The imperative
API (gridRef.removeRow / .addRow / .refetch / .resetSelection) is
demoted to a package-internal primitive — production consumer-code must
now go through useGridMutation so the strategy choice is named,
reviewable, and testable.
All nine Geneax production server-mode grid consumers (Records,
Expectations, AdministrativePlaces, SourceTypeVocabulary, Sessions,
Users, DeletedUsers, BulkRoleAssignment, Messages) were migrated in this
release. Two admin-internal demo pages (test-showcase-grid,
test-infinite-scroll) continue to use the imperative primitive
directly, documented as legitimate exceptions in the README.
See MIGRATION-NOTES.md for the consumer-side
migration recipe.
Added#
useGridMutation(gridRef)(new export from main index and/client) — single-hook API for the mutation-plus-grid-patch flow. Returns{mutate, isPending}.mutate(input, {action, onSuccess})runs the action and applies the chosen strategy whenactionresolves to{success: true}.- Strategies:
removeRow(static rowId or function),addRow,refetch,resetSelection,none.onSuccessaccepts an array to compose strategies (typical after bulk delete:[refetch, resetSelection]). - Return type preserves the action's full result-shape via generic
inference — typed errors like
ActionErrorfrom@workspace/active-typessurviveresult.success === falsenarrowing. - 11 unit tests cover the happy-path, error-path, strategy variants, array-of-strategies, isPending toggling, thrown-error propagation, and detached-ref no-op.
GridMutationStrategy<TInput, TData>,GridMutationOptions<TInput, TActionResult>,GridMutationResult<TData>,UseGridMutationResult— supporting types exported alongside the hook.
Changed#
- Consumer-API guidance:
gridRef.current?.removeRow()and friends are now considered the package-internal primitive. README§Server mode / Mutating server-mode gridsdocumentsuseGridMutationas the recommended path and lists three explicit exception classes (library-internal tests, demo/showcase pages, pure UI commands outside a mutation flow). - All nine production Geneax server-mode grid consumers migrated to
useGridMutation. The migrated grids no longer hold localisLoadingstate for their mutation flow — they readmutation.isPendingdirectly. Three archives grids (Records, Expectations, AdministrativePlaces, SourceTypeVocabulary) had a staleeslint-disable react-hooks/refscomment on their restore handler; the comment was removed because the migrated handler no longer readsgridRef.currentduring render.
Internal#
- Em-dash sweep on three user-visible strings in
src/i18n/defaults.tsandsrc/components/filter-builder/. Pre- existingno-restricted-syntaxwarnings flagged via PAGE_STANDARDS. - Em-dash removed from a logger.debug call in
src/engine/core/grid/ constructGrid.ts. Pre-existingno-restricted-syntaxwarning. - Unused
eslint-disable no-consoledirectives inbenchmarks/grid-benchmark.test.tsx,e2e/global-setup.ts, andsrc/engine/utils.tsremoved. lookup-grid.tsxcast'sTData-generic renamed to_TDatato silence@typescript-eslint/no-unused-varsoutside the engine config override.src/utils/logger.tsadds an explicit eslint-disable onLoggerOptions.data?: anybecauseLogMetais not re-exported on@workspace/active-logging/client.- Lint warning baseline lowered from 1000 to 611 (the current actual
level). Engine-internal
@typescript-eslint/no-explicit-anywarnings remain as deliberate architectural escape hatches consistent with AG-Grid / TanStack-Table-style engines; tightening them would require a deeper structural-typing pass and is out of scope.
Notes for consumers#
- The imperative API is still exported and remains type-stable — there is no breaking change. The shift is in consumer-side discipline. The README documents which exception classes may continue to call the primitive directly.
- Multi-strategy
onSuccess(array form) is canonical for the "refetch + resetSelection" pairing that appears after bulk actions. Geneax's message-grid uses this pattern across nine handlers.
[0.6.1] — 2026-05-17#
Follow-up to 0.6.0. All Geneax internal apps/admin server-mode grid
consumers have been migrated to the direct active-grid API. The thin
per-app wrapper around createServerFetchFn (previously
apps/admin/lib/grid/server-fetch-factory.ts) has been removed —
consumers now import createServerFetchFn directly from
@workspace/active-grid and pass serverAction: getActiveGridDataAction
inline.
Changed#
- Twelve
apps/adminserver-mode grids (audit-log, error-log, access-log, app-log, email-log, consent-record, user-grid, deleted-user-grid, anonymized-user-grid, user-access-log, user-audit-log, user-error-log) now construct theirfetchFnviacreateServerFetchFnfrom@workspace/active-griddirectly. Each grid passes thettranslation function tocreateServerFetchFnso the standard error-toast path picks up the consumer's locale. apps/admin/app/(main)/users/grid/user-grid.tsxhad an inline custom fetchFn callinggetActiveGridDataActiondirectly with a manualtransformstep (addedhasLocalPassword); it now usescreateServerFetchFn'stransformcallback for consistency with the rest of admin and with the canonical archives Records-grid.
Notes for consumers#
- The wrapper-removal is internal — external consumer-side API surface
is unchanged.
createServerFetchFn's signature is identical to what was already documented in 0.6.0. - With this release, all production Geneax server-mode grids (admin +
archives Records) use the direct active-grid API. The 0.6.0 factory +
dynamic
extraWhere+gridRefpatterns are now exercised across 12+ consumers, providing a meaningful showcase for the package's long-term AG-Grid-parity goal.
[0.6.0] — 2026-05-17#
This release lifts the server-mode action factory into active-grid itself and adds first-class support for dynamic scope-filtering, eliminating copy-paste between database-specific grid actions.
See MIGRATION-NOTES.md for the consumer
migration recipe.
Added#
createGridServerAction(new/serversubpath export) — generic factory that produces a{success,data}-shaped server-action for any Prisma client. Each consuming database (dbCore,dbArchives, etc.) creates one instance; the factory is Prisma- and platform-agnostic (structural Prisma-delegate typing, injectedperformAuthCheck, injectedhandleError). ThemaxFetchSizeparameter replaces the previous hardcodedz.number().max(500)cap. Validation can be delegated to a consumer-suppliedvalidateRequestcallback so existing Zod +formatZodErrortranslation paths stay intact.createServerFetchFn(new client helper, exported from main index and from/client) — builds aServerFetchFunction<T>on top of a server-action with support for:- Dynamic
extraWhere— pass a function(ServerFetchParams) => Record<string, unknown>to compute scope filters per fetch. Closures over React state (view tabs, scope ids) eliminate the need for per-domain wrapper actions. - Row
transform— optional(row) => TRowcallback for mapping raw Prisma rows to a flat grid-row shape inline.
- Dynamic
useGridImperative<TData>(gridRef)— typed wrapper returning stable{refetch, removeRow, addRow, resetSelection}callbacks. Avoids thereact-hooks/refslint warning that fires when a ref-reading function is passed to a context-menu builder during render.- Dev-time warning for low
maxFetchSize—createGridServerActionwarns whenmaxFetchSizeis below the engine's default block size (200). Surfaces the conceptual mismatch between fetch-block size and UI page size that previously produced silent "Too big" Zod errors. - Dev-time warning on client-mode
addRow/removeRow— the imperative API now emits a[active-grid] gridRef.X() is a no-op in mode="client"warning instead of silently doing nothing. The fix: mutate thedataprop yourself (e.g.setData(prev => prev.filter(...))).
Internal#
- Factory + client helper live in
packages/active-grid/src/server/andpackages/active-grid/src/client/, exported under new/serverand/clientsubpaths inpackage.jsonexports.
Notes for consumers#
- The
pageSizefield onServerFetchParamsis the engine's block size (rows fetched per server call, default 200), not the UI page size. The two values are independent — the UI'spagination.pageSizeOptionscontrols visible-row chunks, while the engine groups multiple UI pages into a single fetch block for cache efficiency. This release keeps the namepageSizefor backward compatibility (Option B in the T-GX-AG-01 design) but documents the distinction in README §Server-mode and surfaces the conceptual gap via the factory'smaxFetchSizewarning.
[0.5.0] — 2026-05-17#
Added#
- Column-meta
emptyValueKey— per-column i18n-key for null/undefined values, resolved viat()at render time. Defaults tocommon.placeholder.notAvailablewhen not specified. Use this for column-specific empty-state semantics (e.g."archives.route.columns.lastVerifiedAtEmpty"→ "Nog niet geverifieerd"). The column'svalueFormatteris not invoked for null/undefined values — body-cell handles them directly. - Auto-label for badge cells — when a column uses
type: "badge"(filterType: "select") and providesmeta.filterOptions: [{label, value}], the cell renderer automatically derives alabelMapfrom those options and shows the human-readable label. Removes the need to duplicate the value→label mapping in a hand-writtencellrenderer. Single source of truth for enum-style columns.
Changed#
-
getRowIdbecomes required at the type level whenTDatalacks anidfield. The new conditional typeRequireGetRowIdIfMissingId<U, TData>inGridProps<TData>flipsgetRowIdfrom optional to required whenkeyof TDatais a concrete key-set that does not include"id". Generic /Record<string, any>positions remain permissive (bail-outs forstring extends keyof TDataand"id" extends keyof TData). Fixes a runtime-only crash that used to surface asValidationError: [ActiveGrid] No "id" field found in row data. Provide a custom getRowId prop.Previously only caught at runtime; now caught bytsc --noEmit.Migration: any consumer that passes data with an alternate key (e.g.
Provider.code) must now supplygetRowId={(row) => row.code}explicitly. Consumers using entities withid: stringare unaffected. -
Null-value rendering moved upstream of
valueFormatterinbody-cell.tsx. Before this release, avalueFormatterthat returned an i18n-key string (the date-formatter pattern) leaked raw keys into the DOM because nobody resolved them viat(). Body-cell now catches null/undefined explicitly and renders the resolvedemptyValueKey— the formatter never sees null.Migration: none for consumers. Authors of custom
valueFormatters that intentionally handled null themselves should switch tometa.emptyValueKeyfor that column.
Internal#
lookup-grid.tsxgets a local cast aroundActiveGridto bypass the conditionalgetRowIdrequirement at a generic-passthrough boundary. LookupGrid already exposes its owngetRowId?prop to callers, so user- facing safety remains intact.
[0.4.22] and earlier#
See git log and DECISIONS.md (ADR-001 through ADR-N)
for prior changes and the broader architectural arc — engine store
single-source-of-truth, useFlexLayout re-anchoring to the engine store,
column virtualization design, server-mode block-cache, and the
filtering / sorting / grouping subsystems.
This CHANGELOG starts at 0.5.0; earlier versions are not retroactively itemized — the DECISIONS document covers the conceptual evolution and git is authoritative for the line-by-line history.