Search documentation

Search all pages, headings and text, in the current package or topic or in all documentation.

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

Release state

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-helpers replaces @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 the portable-helpers selection of @workspace/active-utils. Same functions, same behaviour; the grid's public API is unchanged. A host that receives the grid receives active-helpers with it (the transfer closure names it).
  • Fixed: a wide grid no longer widens the page. .grid-root is now position: 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's repo-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 the formatters entry of a translation package. The manifest declares @workspace/active-format instead 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 the ValuePresentation union, 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/react and @types/react-dom as optional peers beside the react peers). Nothing changes for an existing consumer.
  • tsconfig.json is 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 children inside children is a submenu of the submenu.
  • Added (G-2): a disabled item's disabledReason is exposed as aria-description (it was a title only), on submenu triggers too.
  • Changed (G-3): the resolve pipeline is resolveMenuItems from @workspace/active-utils/menu-items, shared with Active Charts; same semantics, the permission check runs as its keep step.
  • 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 permission is no longer shown when no hasPermission checker answered it. Before, a menu built without a checker (or passed directly as a ContextMenuBuilder) ignored permission and showed the item to every viewer. Now createContextMenu without a checker leaves such an item out, the render boundary keeps an item with permission only when the factory approved it, and it reports a dropped item through the <GridDefaults diagnostics> sink (warn, source ActiveGrid.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 GridDefaults sink. The thirty configuration warnings that wrote to console.warn directly now go through <GridDefaults diagnostics> (source ActiveGrid), 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 createResourceServerFetchFn are reported once per ground; the drift carries both capability maps as structured data.
  • 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/tz is 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 locale prop 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.md states 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 GridExportError EXPORT_FONT_REQUIRED instead of drawing broken glyphs; complex and right-to-left scripts, residual combining marks, joiners and variation selectors, emoji and supplementary-plane characters fail with EXPORT_TEXT_UNSUPPORTED whatever the fonts. WinAnsi-only PDFs without a set are unchanged.
  • xlsx, jspdf and jspdf-autotable are optional dependencies. The package root no longer reaches them; a missing one fails its export with EXPORT_DEPENDENCY_MISSING (missingModule).

Added (portable runtime P8C, ADR-054)#

  • Export fonts for the PDF. GridExportFonts (with GridExportFontFamily, GridExportFontFace, GridExportFontSource): host families in fallback order, faces as bytes or lazy loaders. Accepted by GridPdfExportOptions.fonts, the grid's export.fonts and GridDefaults' export.fonts, in that precedence; 'inherit' uses no set. Each cell draws in the first family that covers all of it (EXPORT_GLYPH_COVERAGE otherwise); 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.direction puts the first column on the right; GridExcelExportOptions.direction opens 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 global window.jsPDF that the ES build of jsPDF never sets (measured: TypeError: doc.autoTable is not a function). It now calls autoTable(doc, options).

Changed (portable runtime P6, BW-3, ADR-053)#

  • BREAKING: pinning is logical. ColumnPinningPosition is false | 'start' | 'end', ColumnPinningState is {start, end}, column.pin('start' | 'end'); the readers are getStart*/getEnd* (getStartVisibleLeafColumns, getEndHeaderGroups, …) and header group names start_*/end_*. No alias for left/right.
  • BREAKING: columnResizeDirection is removed. The grid's one resolved direction reaches the options as direction.
  • BREAKING: persisted state version 3. A version 2 payload is cleared on load; a physical left/right pinning 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)#

  • dir prop. A grid resolves one UI direction: the explicit dir, else the page's direction (the nearest dir attribute, followed when it changes), else ltr. Never the locale. Every popup the grid opens carries it, and Base UI's keyboard semantics follow it. Until P6 a grid that resolves rtl throws GridProjectionFault('unsupported-direction'); an invalid dir throws GridConfigurationError (GRID_DIRECTION_INVALID).
  • meta.textDirection (GridTextDirection: auto, ltr, rtl). Cell text, headers, filter options and the cell tooltip render as isolated runs: free text auto, formatted values inheriting, codes as declared. A right-to-left column name in an aria-label or announcement is isolated with FSI/PDI; Latin labels in an ltr grid 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 with UnsupportedLocaleError from @workspace/active-i18n/formatters instead 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.Locale week info (Monday without it). A calendarLocale object that carries its own labels keeps 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.css once, 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) for meta.statusTones and <StatusCell tone>. The grid no longer exposes any type from @workspace/active-ui; FilterPopoverContent takes the grid's own popover props.
  • BREAKING: LinkCell renders 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.contextMenu and ui.headerMenu default to the package classes grid-menu-popup--context / grid-menu-item--context and grid-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-ui and tailwind-merge removed, react-day-picker added; the next peer removed. Peers are react and react-dom only.

Changed (portable runtime P1, ADR-047)#

  • Diagnostics go to a host sink. <GridDefaults diagnostics> takes a GridDiagnosticSink ({level: 'debug' | 'warn' | 'error', source, message, data?}, a caught error under data.error); nearest boundary wins. Without one, warn and error go to the console and debug is dropped. New public types GridDiagnostic, GridDiagnosticLevel, GridDiagnosticSink. copyToClipboard options take diagnostics too.
  • BREAKING (internal consumers only): a misconfiguration throws the new, public GridConfigurationError (code GRID_ROW_ID_MISSING or GRID_SERVER_OPTIONS_INVALID, field, constraint, value) instead of ValidationError from @workspace/active-errors. Messages are unchanged.
  • CellErrorBoundary is a function component over an internal class boundary; its props and rendering are unchanged.
  • Dependencies: @workspace/active-logging and @workspace/active-errors removed. The CSV/decimal helpers come from @workspace/active-utils/serialization instead 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. Without onExportError the failure is still logged, never swallowed. New type GridExportScope ('page' | 'loaded' | 'selection'). Additive; the programmatic export functions keep reporting by throw, rejection and return.

Breaking (FAM-V2-T4, ADR-045)#

  • GridDefaults replaces GridConfigProvider. One public defaults boundary carries the presentation (locale), the translator (t) and the PartialGridConfig sections 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.operatorPresets per name.
  • No implicit English. A grid without a t prop and without an inherited <GridDefaults t> throws a configuration error. The English table is the exported englishGridTranslator; pass it on purpose for standalone use, fixtures and examples. The operator factories (getTextOperators, …, getOperatorsForType) take a required translator, and the English-baked TEXT_OPERATORS, NUMBER_OPERATORS, DATE_OPERATORS and BOOLEAN_OPERATORS are removed.
  • Built-in column types are the package default. Every grid applies them without a prop; columnTypes extends or overrides per type name. getDefaultColumnTypes leaves the public API: remove columnTypes={getDefaultColumnTypes()} and spread nothing into a custom map.
  • Provider surface narrowed. GridTranslationProvider, useGridCalendarLocale, useGridConfig, DEFAULT_GRID_CONFIG, GridProvider, FlexLayoutContext and FlexLayoutContextType are internal. useGridTranslation and useGridLocale stay public and return the effective values of the enclosing grid.
  • JavaScript theme layer removed (FAM-W6): the theme prop, createGridTheme, GridTheme, gridThemes, GridThemePreset, compactTheme, comfortableTheme and financialTheme. The grid CSS stays in active-ui; a scoped override sets a --grid-* value on .grid-root through className. Row height has one authority (settings.rowHeight, then layout.rowHeight). The per-grid scrollbar thickness setting went with it; the lane is 10px.

Changed#

  • locale is an optional instance override on ActiveGrid and LookupGrid: the prop, else the nearest <GridDefaults locale>, else a configuration error. t on LookupGrid follows the same rule.

  • GridPresentation is an alias of the active-i18n ValuePresentation (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 number and date inputs. 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 as 0). 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-virtual 2.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. evaluateFilterExpression ignores 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. 0 and false still filter, a range needs both ends, and isEmpty / isNotEmpty stay 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 / isNotEmpty count 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 unchanged scrollTop showed 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 onDataCommit finishes after the grid was unmounted no longer patches the old grid's rows, calls the consumer's getRowId or 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 a wrapText column: 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 as title (client and server), and the item count no longer wraps below it.

  • wrapText with the default text renderer now actually wraps. The renderer truncated by default whatever the column said, so a wrapText column, including one with autoHeight, still showed one line with an ellipsis and its row never grew. With truncate unset, a wrapText column now renders untruncated text on every default text path (plain, valueFormatter text, number, currency, percent and date semantics, and the empty-value placeholder), so the text breaks over lines and autoHeight rows grow to hold it. An explicit truncate: true still truncates, an explicit truncate: false still does not, columns without wrapText are unchanged, and custom cell renderers 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-rowindex keep 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: enableUndoRedo defaults to true when onDataCommit is 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 native type="number"/type="date" fields; gridRef.removeRow is a post-commit patch, not an optimistic update; the exclusive-selection recipe states that the consumer translates the SelectionQuery itself; the header checkbox is three-state for assistive technology and two-state on screen. The calendarLocale docblocks 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 onBatchCommit returned 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 onDataCommit call, 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 commitOnBlur switch 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-rowcount and the body's aria-rowindex no 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: onDataCommit is 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. With commitOnBlur, 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 enableCellSelection off 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-rowindex like every other body row (IR-05), so group, member and load-more rows form one unbroken sequence, also in a virtualized body.

  • createResourceServerFetchFn's onError also fires for a read that fails by a rejecting or throwing action or a throwing transform (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 is aria-busy while 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 focusout a 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-rowindex counts 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's onError fires 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: false is 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.
  • 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.onCopySuccess and meta.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 dragover had written to React state, which a drop in the same frame could see before it rendered (TD-50).

  • unpinRow refuses a missing row identity, as the kernel's unpinRow does, 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 critical aria-required-children violation (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-primary and a ring-2 halo; 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-hidden input 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.
  • ContextMenuBuilder receives an optional second argument, ContextMenuContext (new export): kind (range, cell or row), columnId, selection (rangeCount, cellCount) for a range, and selectedRowCount. 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 declares aria-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 contextmenu for 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 carry onMouseDown: a primary-button press makes the cell active on the press itself. onClick stays 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) and data-body-focus (the focus is in the grid's body or in its own body menu); on a cell data-cell-cue (the cell that carries the selection's cue). Two tokens on .grid-root: --grid-range-outline and --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 enableColumnReordering the 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() and Grid.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, with original: TData), GeneratedRow (a row grouping generated, with original: undefined, groupingColumnId and groupingValue), SourceCell, GeneratedCell and GeneratedCellContext (ADR-037).
  • The grid options type declares collationLocale, the language the localeText comparator 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.
    • onCellSelectionChange reports 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% --ring fill, 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. See guides/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 with tabIndex={-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 critical aria-required-children violation.

  • Controls that exist once per column are named after that column's label (meta.label, else a string header) 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 expose aria-expanded, and the group toggle is named by aria-label instead of only a title. 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, proprietary LICENSE, 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 in LICENSE. The README License section now states this, keeps the removed fork as history, and notes that the runtime dependency @workspace/active-virtual carries 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.global column no longer names a removed grid-side resolver. It now says the term is sent to the fetch adapter as globalFilter and 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.cellClassName as a function, meta.cellClassRules, meta.cellStyle, meta.tooltipValueGetter, meta.tooltipField, meta.colSpan, onRowClick, onRowDoubleClick, onCellClick and the row contextMenu — is no longer called for a row grouping generated, which has no record; it was handed undefined. 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 its aggregatedCell template and no longer pass through valueFormatter, which received an empty object in place of a record. Ratified as delta DC-1 (2026-09-22): a column whose aggregate relied on valueFormatter for its notation declares semanticType instead.

  • The median aggregate 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 implicit auto; 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 example aggregationFn: 'sum'; auto stays 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.

  • onSelectionChange hands over the records of the selected source rows only.

  • Breaking (types). Row<TData> is SourceRow<TData> | GeneratedRow<TData>, and row.getIsGrouped() narrows it. Code that reads row.original where a generated row can arrive — a comparator, an eligibility predicate such as enableRowSelection, renderDetailPanel, getRowCanExpand, a row model — narrows first: (row) => !row.getIsGrouped() && row.original.active. Callbacks that only meet rows standing for records keep original: TData: a filter function receives a SourceRow, an aggregation's leafRows are SourceRows, the parent getRowId receives is a SourceRow, and a body-cell renderer's CellContext carries a SourceRow. An aggregatedCell renderer receives a GeneratedCellContext. Cell is SourceCell | 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 a TypeError instead 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 over row.getValue(columnId); the parameters are unchanged.

  • createColumnHelper and createLocaleSortFn are 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 and DeepKeys are 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 typed readonly; the value type of a column now reaches Column, Cell, Header and both renderer contexts, and every slice reset declares its optional defaultState flag.

  • createLocaleSortFn and createColumnHelper are 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 example valueFormatter, 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 1 was never shown, so a multi-sort read as 2 without a first. With one sorted column no number shows. The header reads the priority from Column.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 misspelled setcolumnResizing.

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.label and grid.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 fields baseStore and store (grid and options), the debug* options and mergeOptions, and five declared grid members that do not exist at runtime (getFacetedRowModel, getFacetedUniqueValues, getFacetedMinMaxValues, getPinnedLeafColumns, getPinnedVisibleLeafColumns). Header.rowSpan, which is always 0, 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.md is merged into guides/filters.md and DATA-ACCESS.md into ARCHITECTURE.md § Datatoegang en eigendom.
  • The README quick starts now pass the required locale, and api/filters.md names 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 -- and benchmarks/baseline.json now 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 under benchmarks/; everything under benchmarks/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 in PERFORMANCE.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. ReactLayerFeatures was any; it is now StockFeatures, in one named package-private type. Measured: CoreFeatures & StockFeatures produces 16 errors that StockFeatures does not and removes none, all of them an alias claiming a wider set than the engine builds.
  • GridColumnDef is 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.
  • accessorKey must name a field of the row, dotted paths included, computed from the unwidened row type.
  • A group's columns is recursively this same definition, so a nested child carries type, server and meta exactly as its parent does. It was the engine's own shape and carried none of them.
  • createColumnHelper declares 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 read undefined and 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.statusTones is declared partial. A total Record<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 is ROADMAP.md TD-45.

Added#

  • hasAccessorKey / hasAccessorFn — the two narrowings for reading a definition back. accessorKey is not reachable on a union, and a local column 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 name GridColumnDef<TData>.
  • Three (r: any) annotations in component code that existed only because the React layer hid TFeatures.

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/ and scripts/ -- more than double the 242 the previous re-measurement reported, because a comment-only scan cannot by construction see describe/it/it.each descriptions, assertion messages or Dutch throw new Error(...) prose. Seven English comments that quote a Dutch UI label remain, as CODING_STANDARDS § 2 permits, along with the Dutch UI expectations and locale fixtures themselves.
  • Benchmark scenario descriptions are English. scenario-manifest.ts carried Dutch measures prose 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) and src/react/** (4 files, 392 lines) are a fork with a rename of TanStack Table v9 table-core, not an independent implementation -- established on directory layout, per-feature file names, the complete identifier set, and two character-for-character identical helpers in utils.ts. 27% of the package is derived; the remaining 73% is independently authored active-grid code. LICENSE was not touched, no attribution text was added, and no existing provenance reference was removed. The evidence and the seven-tranche replacement plan are in docs/research/ACTIVE-GRID-TANSTACK-PROVENANCE-ASSESSMENT-2026-09-20.md and in ROADMAP.md TD-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.json declares UNLICENSED, LICENSE carries the proprietary Active Development text and private: true is set. The README also claimed the TanStack attribution was "preserved in LICENSE"; it is not there, and no file under src/engine/ carries an upstream copyright header. ADR-007 requires that clause, so the gap is now stated in the README and registered as ROADMAP.md TD-42.
  • next is no longer described as optional. ARCHITECTURE.md said "optioneel Next.js 16" and FUNCTIONEEL-OVERZICHT.md said "optioneel Next.js 15 of 16", both left over from before AGRID-F04 made it a required ^16.0.0 peer.
  • docs/api/columns.md typed a column group's columns as GridColumnDef[]. It is the engine shape, Array<ColumnDef<…, unknown>>, and has never been otherwise: a group child carries no type, server or meta, and its TValue is pinned to unknown. 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.md described withEmpty against a symbol that does not exist. toPrismaWhereFromContract has no occurrence anywhere in the repository, and the claimed "silently dropped with a dev-warning" does not happen: in @workspace/active-resources a non-nullable text column matches the empty string, and number, date and boolean have isEmpty rejected by the operator allowlist before any condition is built. The same wrong sentence is corrected in the operator-presets.ts docblock.
  • docs/FUNCTIONEEL-OVERZICHT.md rewritten 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 545 cap 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.md already 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 in docs/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 --fix running curly alone, 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 in LICENSE is 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.
  • curly is enabled in the package's own eslint.config.mjs so a regression shows up in pnpm 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/test descriptions, and two developer-facing throw messages. 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 in column-chooser.spec.ts, column-resize.spec.ts and context-menu.spec.ts stay: those specs run against apps/admin, which hands the grid its own t, 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 in useDataOrchestrator, and the cell flush in useBatchEditingState. A Datyx host memoizes t per 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) and grid.edit.validationFailed (a column validator that throws). translate is now in all three dependency arrays; the fetch trigger already calls through fetchDataRef, so nothing refires.

Changed#

  • next is a REQUIRED peer dependency (AGRID-F04). peerDependenciesMeta marked it optional while LinkCell imports next/link at module scope and the single entry point exports LinkCell -- so importing the package has always had to resolve next, and @workspace/active-ui requires 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 type in 30 modules (CODING_STANDARDS § 4). Mechanical; no runtime or type-contract change.
  • Five production as any casts and their suppressions are gone from export-utils.ts. jspdf-autotable v5 publishes UserOptions, RowInput and CellHookData; the prototype-installed autoTable is now reached through one named view type instead of any, and the two didParseCell hooks 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 and copyToClipboard. 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-deps suppressions carry an English reason (CODING_STANDARDS § 10). Each was probed by removal first: all ten are still necessary. Two redundant useMemo dependencies that the rule flagged as unnecessary were removed from grid.tsx and use-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.ts called the module-scope operator lists "NL defaults" -- they carry the package's own standalone English table. i18n/grid-translation-context.tsx and types/props.ts both claimed the built-in calendar mapping "covers en/nl/de/fr/es"; since ADR-034 utils/date-locale.ts defers 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. useKeyboardNavigation derived rowCount, colCount and pinnedTopCount in a useMemo keyed on the grid instance, which never changes identity (react/useGrid.ts builds it in a useState initializer). The counts therefore stayed on whatever the first render saw. Consequences, all gone:
    • mode="server": the first render has zero rows by contract, so rowCount was 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+End and PageDown could 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/End stopped short or aimed at a column that had been hidden.
    • pinnedTopCount: pinning a row to the top while the grid was mounted left scrollToIndex converting to the wrong center index.
  • meta.autoHeight now takes effect when its column becomes visible. Both bodies (sections/body.tsx, sections/body-virtual.tsx) derived hasAutoHeight from the same frozen memo. A column with autoHeight that 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 DateEditor and the date filter pass today to active-ui's Calendar, 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 same todayInPresentation the 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)#

  • locale on ActiveGrid and LookupGrid is 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): useKeyboardNavigation read the presentation context from outside the provider the grid renders. The grid now passes its locale to 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 ScrollArea bar: padding: 1px, a transparent 1px border on the side facing the content (border-left vertical, border-top horizontal) and a flex: 1 thumb, replacing the symmetric 1.5px inset (--grid-scrollbar-inset is 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 Lab scroll-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.ts pins the construction.

The scrollbar reads the platform contract (UI-SCROLLBAR-01, active-ui ADR-026)#

Changed#

  • --grid-scrollbar-thumb, --grid-scrollbar-thumb-hover and --grid-scrollbar-track default to the platform tokens --scrollbar-thumb, --scrollbar-thumb-hover and --scrollbar-track instead 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 ScrollArea did not react either; both now do. theme.scrollbarThumbHover still overrides it per grid.
  • Under forced-colors the thumb is CanvasText; 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.ts pins 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) dropped focus:border-primary focus:ring-2 focus:ring-primary/20; the date popover's text inputs dropped the same with ring-1; the filter-builder fields dropped focus-visible:ring-1 focus-visible:ring-ring. All of them take the internal FIELD_FOCUS_CLASS (components/primitives/field-focus.ts), which is bound to active-ui's Input.
  • The inline filter bar is dimmed through active-ui's --grid-filter-trigger-focus-border, now color-mix(in oklab, var(--ring) 50%, transparent) (was var(--ring)). The dead focus:border-primary/50 focus:bg-background on the inline text and number inputs is gone; the grid CSS already won over them.
  • The toolbar search no longer adds focus-visible:ring-1 to Input, and the page-size SelectTrigger no longer carries a no-op focus: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. isAbsentCellValue now treats Infinity and -Infinity like NaN: the cell draws the placeholder instead of ∞, isEmpty matches, the presentation export writes '', and sum/mean/min/max skip the value (median returns 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 internal toNonFiniteNumber pass-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.Date or …T00:00:00.000Z value for 9 March matched 8 March on an America/Curacao surface. A semanticType: 'date' column (and a filterType: 'date' column without semantics, as DateCell shows 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 dateTime column filter and the relative date presets require a time zone in the grid presentation and throw a TypeError without one, instead of reading the browser's day. DateFilterPreset.getValue documents this.
  • evaluateDateFilter, evaluateFilter, advancedFilterFn and evaluateFilterExpression take 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 an svg, so the state existed only for someone looking at it. Each option now carries aria-pressed, and Select all carries the three-valued form (true / "mixed" / false) that matches the check, the dash and the empty box it already drew. aria-pressed and not role="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 find Zürich for zuruck-style input and applies a case rule that differs per language. It now runs over an Intl.Collator with sensitivity: 'base' and usage: 'search' on the language the host already supplies through GridPresentation (languageLocaleOf). No coupling to SearchCombobox was introduced; the collator belongs to this module.

Unchanged, and now guarded#

  • Empty is NO FILTER, Select all writes the explicit set rather than a wildcard, Clear returns the state to undefined, and the stored identity remains option.value and never the label.
  • The declared label map keeps winning over the derived one. A column that declares meta.filterOptions shows those words; a column that declares none shows the value itself. The grid invents no human label -- no startCase, no underscore replacement, no capitalisation rule. Re-measured for DATYX-FINAL-013: the String(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.tsx now 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 all was removed from the row menu, in every mode, and Export view now means the CURRENT paginated page. It previously read getFilteredRowModel(): in client mode every filtered row across all pages (Export view (152) beside a paginator reading 1 - 10 of 152), in server mode only the current page, because manualFiltering falls that model back onto the page data. One label, two meanings. Export all promised "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 label Export 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*Grouped and exportTo*ServerGrouped remain public API for programmatic use.

Fixed#

  • ContextMenuItem.visible was 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 ContextMenuBuilder gets the same treatment as one built by createContextMenu. 32 of the 139 consumer declarations never met the factory.
  • meta.exportConfig set by a consumer was silently overwritten by the grid's own value; test-server-grouping had carried a dead filename that way. The export prop is the contract.

Added#

  • ContextMenuItem.separatorBefore -- the explicit group boundary, same word and meaning as GridSelectionAction.separatorBefore on the bulk bar.
  • GridExportRowScope ('page' | 'loaded'), declared by the grid on meta.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#

  • enableColumnVirtualization did 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. useColumnVirtualizer derived 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-virtual keeps 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.tsx and exactly as active-virtual documents. Referential stability — which three React.memo boundaries 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.

  • scrollToColumnIndex was always undefined, 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. measureCellsWidth clones 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 a finally so no state can be left behind. Grids without column virtualization are untouched: no runner, no extra render.

  • enableColumnVirtualization with height="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 === off while TD-36 stood; it is now on < off on columns, cells and DOM nodes.

  • censusGrid reports mountedHeaderColumnIds next to the body window, so header/body drift is observable at all. Placeholder headers are excluded.

  • The Playwright-side census helpers are shared. waitForStableCensus, nextFrame and setScroll moved from the benchmark file to e2e/helpers/grid-census.ts; the benchmark and the new e2e/column-virtualization.spec.ts now 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-colcount staying the declared total while aria-colindex stays 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 of ColumnVirtualizationResult (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 benchmark ran no scenario at all. vitest.benchmark.ts lacked the server-only alias that vitest.config.ts had, so the import chain of src/components/grid.tsx failed 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/, and benchmarks/baseline.json has 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 silent if published expandLatencyMs: 0. Editing looked for data-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 no if (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 for domNodes. A timing difference never produces a non-zero exit code.
    • benchmarks/ was never typechecked. It sat outside tsconfig.json. Adding it surfaced that every client scenario passed an enableVirtualization prop that does not exist on ActiveGrid and was silently ignored: row virtualization is an automatic decision above virtualizationThreshold, 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 resolves gc() through v8.setFlagsFromString plus vm.runInNewContext, verifies it by calling it, and records memoryPolicy: "trend" or "omitted" accordingly.

Added#

  • A browser benchmark round (pnpm benchmark:browser) under the existing Playwright harness, in its own configuration with retries: 0 and its own **/*.bench.ts discovery, 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.ts no longer uses the poolOptions shape Vitest 4 removed.

Known issue found by this work#

  • enableColumnVirtualization does 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#

  • waitForGridLayoutSettled accepteerde een layout die nog niet BEGONNEN was te bewegen. De wacht toetste één ding: de geometriesignature ongewijzigd gedurende stableForMs (250 ms). Dat onderscheidt "beweegt nog niet" niet van "beweegt niet meer", en op /test-showcase-grid liggen die twee milliseconden uit elkaar. Gemeten over zeven mounts, elke keer identiek:

    fasevensterstiltoestand
    pre-meting~7-265 ms247-265 msheader 8px breder dan het lichaam, --grid-edge-clearance op de stylesheetstandaard van 8px, inline vars leeg
    tussenstand~350-430 ms61-78 msvars gepubliceerd, totaal nog niet definitief
    definitiefvanaf ~465 msstabielheader 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.ts een header die 8px breder was dan zijn lichaam (gemeten 1876 tegen 1868, twee keer identiek) en drukte column-resize.spec.ts waar 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-width en --grid-edge-clearance als 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.ts had een eigen settleGrid() -- 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.ts droeg 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 in scrollToEnd en zijn een poll op de UITKOMST geworden (de scroller staat aan zijn einde en de header is gevolgd). Er staat geen page.waitForTimeout meer in de suite.

  • horizontal-scroll-sync.spec.ts wacht op het gedeelde contract in beforeEach. 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. De toPass-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.ts is verklaard en behouden. TD-34 vergelijkt 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-35 vergelijkt 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 in virtualization-scroll.spec.ts zochten die tekst overal onder de gridwrapper, en de grid draagt daar ook een aria-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 browser undefined is; 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.now onder 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.ts draait ze mee; tsconfig.json typecheckt 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.pageSize is de globale startpaginagrootte. De keten is nu URL / 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 levend GridConfig-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 buiten pageSizeOptions blijft toegestaan -- die lijst valideert URL-invoer, niet de configuratie van de ontwikkelaar.

  • export.csvSeparator bepaalt werkelijk het scheidingsteken. Het type is aangescherpt van string naar CsvDelimiter: 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 met a als 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.operatorPresets is een werkend configuratiecontract. filter-utils las 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.operatorPreset is BuiltInOperatorPreset | (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).

  • withEmpty compileert. De docblock in types/filters.ts noemde meta.operatorPreset: 'withEmpty' als de gedocumenteerde opt-in, terwijl de union van vijf namen hem niet toestond. De union wordt nu AFGELEID uit OPERATOR_PRESETS, dus de twee kunnen niet meer uiteenlopen.

  • Vier verouderde standaarden en een ontbrekende sectie in docs/api/columns.md: layout.rowHeight 40 → 42, layout.headerHeight 36 → 48, layout.filterBarHeight 32 → 36, columns.minWidth 100 → 120, en de ui-sectie ontbrak volledig. Alle overige standaarden zijn tegen DEFAULT_GRID_CONFIG gecontroleerd en kloppen.

Changed#

  • Een geneste GridConfigProvider legt 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.csvSeparator is versmald van string naar CsvDelimiter. 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. GridPaginationDefaults stond als "internal engine" op de uitsluitingslijst en de zeven zustervormen onder een regex als "implementation detail", terwijl ze uit src/index.ts worden geëxporteerd. Die uitsluiting was precies de reden dat de enige controle die naar deze vorm keek, er juist niet naar keek. ActiveGridUIConfig, ActiveGridMenuConfig, CsvDelimiter en BuiltInOperatorPreset zijn erbij geëxporteerd en gedocumenteerd.

Added#

  • Een wachter op dode publieke configvelden (config/__tests__/config-liveness.test.ts). Hij inventariseert elk blad van DEFAULT_GRID_CONFIG en 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?.pageSize is 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 mergeConfig en GridConfigProvider, die tot nu toe volledig ontbraken: gedeeltelijke secties, mergediepte, de per-sleutel-uitzondering, nesting, isolatie tussen bomen, en dat een provider DEFAULT_GRID_CONFIG niet muteert.

  • Een documentatiecontract-test die elke gedocumenteerde standaard in docs/api/columns.md tegen DEFAULT_GRID_CONFIG legt 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-error vastlegt dat de vrije presetnaam zijn buurvelden niet verbreedt.

  • FILTER_OPERATORS en isFilterOperator in types/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:

  1. Wie pagination: {pageSize: N} of export: {csvSeparator: X} al in een GridConfigProvider zette, 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.
  2. Wie een geneste GridConfigProvider gebruikt 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-utils kreeg een optionele delimiterparameter op csvRow (standaard komma) plus DEFAULT_CSV_DELIMITER en een eigen testsuite. De helper blijft dom: welke scheidingstekens zijn toegestaan is een productvraag, en die woont in active-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 op GridExportRunOptions: 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} -- en initialPageSize bereikte de state uitsluitend via de pagineringstak van loadInitialGridState. 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 de useState-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 setTimeout of 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), dan initialPageSize, 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 initialPageSize wordt geweigerd, niet gerepareerd. Nul, negatief, gebroken of NaN levert 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 tegen pageSizeOptions: die lijst valideert URL-invoer, en een maat buiten de lijst toont de paginator al bij.

Internal#

  • Vier losse literals 10 zijn één autoriteit geworden. DEFAULT_PAGE_SIZE in engine/features/row-pagination/rowPaginationFeature.utils.ts is 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 en DEFAULT_GRID_CONFIG.pagination lezen haar. Geen gedragswijziging -- vier defaults die onafhankelijk konden verlopen zijn er nu één.

  • Nieuwe module state/initial-pagination.ts met resolveInitialPaginationState(), resolveInitialPageSize() en isValidInitialPageSize(). 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 vier statePriority-standen) en components/__tests__/initial-page-size-contract.test.tsx (6, op de gemonteerde grid, met een render-opnemer voor het flikkerbewijs). state/load-initial-state.test.ts kreeg 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.onKeyDown bestaat voor één ding: staat de focus op de gridcontainer en draagt nog geen cel focus, dan brengt ArrowDown, ArrowRight of Enter de focus naar de eerste cel. Die voorwaarde stond in guides/keyboard.md maar niet in de code. Zonder haar annuleert de handler Enter voor é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. onFocus op diezelfde container trok die grens al (AG-06); onKeyDown deed het niet.

  • Enter en Spatie op een besturingselement binnen een BODYcel zijn van dat besturingselement. Het tweede pad, in handleKeyDown: zodra de action mode een knop of link binnen de cel focus had gegeven, annuleerde de cel de volgende Enter en Spatie alsnog. Gemeten op /test-showcase-grid met 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, Tab en 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 focusedCell op (0,0), waarna de volgende Enter wel doorkwam. Zij verplaatste bovendien de focus, dus een toetsenbordgebruiker verloor zijn plek in de tabvolgorde. ArrowRight stond 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-navigation modelleert target en currentTarget voortaan 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 nu FilterCellShell (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 een truncate-span is dat white-space: nowrap, dus de VOLLE tekstbreedte -- en truncate kwam nooit aan bod. Gemeten: een waardegebied met 2 geselecteerd weigerde 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-1 had 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 van right hard 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 dan 2 geselecteerd en 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-brede minSize blijft ongemoeid, en met settings.showFilterRow: false geldt 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, heet Filter <kolom> wissen (nieuwe sleutel grid.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 handleKeyDown kwam een vroege return voor toetsen uit .grid-header-wrapper. Correctie in 10.1.2: die wachter is ONBEREIKBAAR. getCellProps wordt 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:

  1. "Een IDENTIEKE gekloonde DOM-knop in dezelfde filtercel activeert wél op Enter." Vals negatief. cloneNode kopieert 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.
  2. "Met de kolomnavigatie volledig uitgeschakeld blijft Enter onderdrukt." Kan niet kloppen. enableKeyboardNavigation: false zet zowel enabled als het spreiden van containerProps uit; dan kan deze handler niet draaien.
  3. "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-onKeyDown draait 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 plus enableRowSelection makes 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 ReactNode slot: 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 (default true). Whether an action can execute an exclusive selection. 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.

  • SelectionQuery on 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 own filters and search, plus the consumer's serverQueryKey as externalKey. Carried, never interpreted. A mutation must still re-derive and re-authorize all of it server-side and must never compute its effect from totalCount, which is a display number that arrived from the client. See guides/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 selectionActions are 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. debouncedPageIndex is 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's finally set no state and therefore scheduled no further render. The grid sat on isLoading: true with 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 that refetch() / 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 explicit serverOptions.blockSize, and meta: {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.filterType alone. In server mode enableColumnFilter is already forced to false for every column without a server.filter mapping, so a column type preset that injects a filterType produced a fully interactive input that did nothing — and, since server mode is manualFiltering, did not filter the loaded page either. Both now also require the resolved capability (column.getCanFilter()). settings.showFilterRow remains 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.filterType but declare no server.filter mapping loses its filter row. If the server does accept those filters, declare them — fieldFilter() on column.server, or the field in the resource capability map. The /test-server-grid harness in apps/admin was 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 useGridSetup adds the two consequences the engine already applies to its own filters: pageIndex back 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.buildDatasetKey takes the key as an optional externalKey, and JSON.stringify omits 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 their onChange writes pageIndex: 0 in 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. useDebouncedPageIndex gives it back.

  • serverOptions validation. blockSize and cacheSize must be a finite integer of at least 1; anything else throws a ValidationError with code GRID_SERVER_OPTIONS_INVALID naming the field. Previously 0 and negatives quietly collapsed pagesPerBlock to 1 and NaN reached the server as fetchSize: NaN, both without a message. prefetch is 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-width was summed from getFlatHeaders(), 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 (useColumnSizingVars and writeCssVarsToContainers). 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 partial widths map, 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 inline min-width on the scroll content that omitted --grid-edge-clearance (and the 100% 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-container IS 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 reaching scrollLeft 4812 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 minGridWidth adds a floor rather than a second geometry. Only grids that set minGridWidth were 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 offers scrollbar-width and scrollbar-color and 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, so CSS.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. GridScrollArea wraps 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's Viewport IS .grid-scroll-container, not a wrapper around it, so scrollTop/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: none on 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-thickness carries the declared lane, with GRID_SCROLLBAR_LANE (css-var-utils.ts) as its one source; the :root value only covers the frame before the theme applies. --grid-scrollbar-width keeps its TD-20 meaning — the gutter the platform reserved — and now measures 0, which is why the header needs no padding to stay aligned. trailingClearanceFor takes the lane as a second term.

    theme.scrollbarWidth keeps 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 inline scrollbar-width, which would change what a container reserves while saying nothing about the scrollbar a reader sees.

    Removed: the @supports engine gate, every ::-webkit-scrollbar* rule (including the -button suppression, which is moot once nothing paints a platform scrollbar), the standardized-property fallback, and the grid-menu-scrollbar hook 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-scroll keeps no scrollbar declarations: it is overflow: hidden and follows the body by scrollLeft.

    Dependency: @base-ui/react is now declared by active-grid because 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() and column.getAutoSortDir() were declared but never attached (2026-08-26). assignPrototypeAPIs derives the prototype name with getFunctionNameInfo, which splits on an underscore. Two entries in rowSortingFeature.ts used a dot ('column.getAutoSortFn'), so fnKey resolved to undefined and both landed on prototype['undefined'] instead of on the column. Column_RowSorting declares both, so a call compiled and then threw is not a function at runtime; the package never noticed because it calls the underlying utilities directly. Fixed by using the same parentName_fnKey form as the ten sibling registrations in that block — no change to assignPrototypeAPIs, no new registration mechanism, no type change. These were the only dotted keys in src/engine.

  • NaN is 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 read NaN as a number: isEmpty reported it present, the PRESENTATION export wrote the literal text NaN, and sum/mean/min/max/median let a single calculation error wipe an otherwise valid group aggregate. All three now treat NaN exactly as they already treated null — the aggregates skip it (median returns nothing, which is what it already did for null), the filter calls it empty, and the presentation export writes ''. 0, -0, false and 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 a sortFn — sampled flatRows.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 to basic. And a detected alphanumeric comparator was overwritten unconditionally by text further down, so item10 sorted before item2. 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 Infinity as raw JavaScript text (REVIEW-AG-004, 2026-08-26). The screen hands a semanticType number straight to formatNumber, which renders the infinity sign; the presentation export fell through to String(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 or null; all three compose their readable form in a valueFormatter, 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: formatNumber was missing from its formatter list — exactly the function the pivot used — and has been added.

Documentation#

  • api/columns.md described emptyValueKey as 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 ±Infinity is not absence. guides/export.md separates 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 valueFormatter decides 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 measured 0 rendered 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, costBps in mdm, was latent rather than measured: its formatter was annotated (value: number) over a value that is a string at runtime, and '0' is truthy.

    The screen was not the only surface: resolvePresentationCell and resolvePresentationAggregate read 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 formatSemanticCurrency line for line, and its absence branch has been dead since TD-28. costBps keeps a formatter, because bps is 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, isEmpty and numeric sort) and by lint-numeric-zero-presence.mjs in repo-tools, which aborts with exit 2 if isAbsentCellValue disappears 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 (and Not available under an English session, so the same table exports different bytes per language), isEmpty matches 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, so filterOptions can 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 by lint-accessor-presentation.mjs in repo-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 needs principalType rather 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 BadgeCell discarded false, 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 isDeclare
    a state carrying a judgementtype: 'badge' + filterOptions + statusTones
    a property whose values are peerstype: 'badge' + filterOptions
    a binary answer with no domain vocabularytype: 'boolean'

    Three rules come with it: the key space of a boolean column is the string 'true'/'false' (raw booleans in filterOptions still 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 no valueFormatter, because the formatter branch runs first and turns type: 'badge' into a dead word.

    See api/columns.md § Boolean columns. The families this repository aligned are guarded by lint-boolean-column-convergence.mjs in repo-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. GridBodyCell tested emptiness with value === null || value === undefined while the primitives tested with isAbsentCellValue — two answers to one question, differing exactly on ''. The accessor therefore decided presentation: (row) => row.field rendered Not available and (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, '' and NaN are absent; false, true, 0, -0 and 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('') === 0 produced a fabricated zero on a number, currency or percent column, while toFiniteNumber in 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 : no produced a fabricated No on 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. isEmpty already counted '' as empty, the select filter already keyed null and '' 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.

    TextCell is unchanged: as a primitive it still renders '' as a valid empty string. Deliberate blankness keeps two explicit carriers — a valueFormatter returning '' (it runs after the absence branch and its result is never re-tested) and emptyValueKey: 'common.placeholder.empty'. emptyValueKey selects 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 as ROADMAP.md TD-30.

Fixed#

  • false and 0 no longer disappear from categorical cells (TD-27 phase 1, 2026-08-25). BadgeCell, StatusCell, DateCell and LinkCell all opened with if (!value) return null. That guard is meant for an empty cell, but it rejects every falsy value — including false and 0, which are data. Since body-cell.tsx already catches null/undefined first, 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 where Gefaald was invisible and an entitlement column headed Status whose ordinary value (Opgebouwd) never showed. All six became correct without a single line of consumer code.

    Narrowing the guard alone is not the fix. displayValue fell back to the raw value, and React ignores a boolean child — so a false without a labelMap hit would have rendered an empty badge outline instead of nothing: the same silent defect in another shape. BadgeCell therefore 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, '' and NaN are absent; false, 0, -0 and every other finite number are values. DateCell accepts epoch 0 as 1 January 1970 — its declared type always allowed a numeric epoch, and the active-i18n boundary it delegates to already accepted it.

    GridBodyCell keeps sole ownership of the placeholder: primitives return nothing for an absent value, and meta.emptyValueKey is unchanged. A caller who uses a primitive directly in a custom cell renderer has bypassed the orchestration and therefore gets no placeholder — see api/columns.md § Empty and falsy cell values.

    BadgeCell accepts string | number | boolean | null | undefined (additive; it replaces an as string cast in body-cell.tsx that was never true). StatusCell stays deliberately string-typed: the status key belongs to meta.statusTones and 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 the GridBodyCell placeholder), shipped separately under Changed above, and ROADMAP.md TD-29 (four idioms for one boolean badge column across consumers).

  • enableUrlSync no longer deletes the page's own query parameters (ADR-023, 2026-08-23). The write-back built pathname + 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. enableUrlSync and 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) and mergeGridQuery merges 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. replaceState is 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-driver is internal and is not exported from src/index.ts. New documentation: URL state, including the pre-existing limitation that the five keys are route-global and not namespaced per gridId, so at most one grid per route should enable URL sync.

  • docs/guides/server-grouping.md claimed expanded-group state syncs to the URL (2026-08-23). It never did: there is no URL key for it and expanded is 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 date column-type preset formatted short, DateCell defaulted to numeric, and the presentation export fell back to numeric as well. A column that declared only filterType: 'date' therefore rendered differently from one that declared its semantics. All three now resolve through formatSemanticDate, 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). resolveDataCell converted with a bare Number(), and JavaScript turns '', whitespace, null and [] into a perfectly finite 0. 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 in active-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 a valueFormatter closure 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 the locale prop, 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 a semanticType: 'date' | 'dateTime' column (WS-13, PP-038), alongside meta.numberFormat and meta.percentPreset. Closes the known asymmetry registered as OP-39; its first concrete consumer is a calculation result column carrying its own formatOptions.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.percentPreset accepts {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 as meta.numberFormat; notation still comes from the presentation context.
  • locale accepts a presentation-context object besides a locale code (WS-07). The new public type GridPresentation (string | (DatePresentationContext & NumberPresentationContext)) applies to the locale prop of ActiveGrid/LookupGrid, getDefaultColumnTypes, the presentation-export options (GridExportRunOptions, GridPdfExportOptions, GridClipboardOptions) and the date-filter helpers. The value is forwarded untouched to the central @workspace/active-i18n formatters, 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 for semanticType: '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 shared formatSemanticCurrency helper, 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 valueFormatter string literally ("€ 1.234,56" as a text cell, locale-dependent dates, translated column headers as JSON keys), while docs/guides/export.md claimed the opposite — the export bytes differed per viewer language. The export functions now take a GridExportRunOptions argument: the default {mode: 'data'} serializes column ids as headers/keys, raw semantic values, machine dates (yyyy-MM-dd for 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>_currency companion column (skipped when the currencyKey field 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 a yyyy-mm-dd format, 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) and semanticType: 'date' | 'dateTime' (declared by the date/dateTime presets). 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.md ADR-022.

  • The number, threshold, count and percent column types carry declarative semantics; body and footer format from one source (WS-02, BREAKING for percent columns without their own valueFormatter). The bare number column body used to render String(value) — no grouping, no locale — while footer aggregations of the same column were locale-formatted (presentation-audit §5.5). The presets now declare meta.semanticType: 'number' (with optional meta.numberFormat, default 'decimal'; count declares 'integer') and the cell orchestration, footer, group-footer and server-group aggregation paths all format through the same formatSemanticNumber. The percent preset no longer sets a fraction-assuming valueFormatter; it declares semanticType: '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 optional percentPreset precision. 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-level valueFormatter still outranks all of this. Aggregation fallbacks that previously skipped formatting for filterType: '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 getDefaultColumnDef no longer injects a getValue()?.toString?.() aggregatedCell template 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-supplied aggregatedCell still wins; without one, aggregates now render through the column's valueFormatter or declared currency/number/percent semantics, with String(value) as the unchanged final fallback.

  • The currency column 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 a valueFormatter that called formatCurrency(value, locale) — a path that could only ever render the language-derived currency (EUR) regardless of what the amount was. It now declares meta.semanticType: 'currency'; the cell orchestrator resolves the identity from meta.currencyKey (a row field carrying the ISO 4217 code) or meta.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 a currencyKey column 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 fixed currencyCode contract. 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 by src/config/__tests__/column-type-presets-currency.test.ts.

  • DateCell renders a calendar day in the reader's language instead of a fixed notation. Its formatString default moves from 'dd-MM-yyyy' to date-fns' P, and when no locale object 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's calendarLocale on <ActiveGrid>, then the mapping of the grid's locale string, and enGB without 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 read 09-03-2026 in the column while the filter in that same column header and the date field in the form beside it both said 09.03.2026. In Dutch nothing changes — P renders 09-03-2026 there.

    A column that passes its own formatString or locale keeps it; only the default moved. The fallback without a provider is enGB and deliberately not en-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: the numeric preset in @workspace/active-i18n moved 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.tsx renders 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.noResults is removed from all five locales; grid.empty.noResults is unaffected.

    Two other shapes were tried and dropped. flex-wrap on 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 below sm regardless of content threw away a useful count on mobile. The bar stays one horizontally scrollable line.

Fixed#

  • autoSizeOnMount no longer skips an empty client-mode grid. The effect required hasData (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: autoSizeOnMount reaches most consumers as an object literal, so the effect's dependencies change every render.

    Not covered by a test. jsdom performs no layout, so scrollWidth is 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 to neutral. It sits next to filterOptions and mirrors it: that one maps value to label, this one maps value to tone, and each has exactly one home. See api/columns.md § Status columns.

  • StatusCell — the imperative counterpart, for a custom cell renderer whose value is not a stable key (a derived or already-translated value, where a value→tone map would break per locale). Wraps StatusBadge and adds the grid's label resolution and badge typography.

Changed#

  • BadgeCell is the categorical badge and has no colour axis. Type, category, department, source: one fixed presentation. A custom cell that genuinely needs a coloured categorical badge renders <Badge> from @workspace/active-ui directly, so the choice is visibly outside the grid's badge contract.

  • Booleans no longer carry implicit status (visible change, five columns repo-wide). true and false render the same categorical badge; the yes/no text is the information. A consumer who wants true to read as success says so: statusTones: {true: 'success', false: 'neutral'}.

  • select columns are unchanged unless they declare statusTones. The roughly 150 type: '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 in apps/admin and seven local status → variant helpers. Five status columns and two priority scales moved to statusTones or StatusCell; 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 on data-tone / data-appearance — the attributes StatusBadge sets — 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 in active-ui and 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 its ResizeObservers, and this suite pins that instead of assuming it. The tracker matches on listener identity, so a cleanup that hands removeEventListener a fresh closure would be caught. Proven by mutation: reverting the visibilitychange cleanup in use-grid-state or an observer.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, and use-flex-layout's window resize listener, which jsdom never registers because measureElement stays null.

  • Byproduct, not fixed here: a paginated grid leaves nine window listeners behind on unmount, traced to active-ui's Select (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 behind Element.matches(), registers them on window to track :focus-visible/:hover/:active, lazily on the first matches() call in a document and cached from then on. Select was 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,927 addEventListener calls, 40 open/close cycles and 10 mount/unmount cycles, resize is the only window listener type ever registered and none of the nine appears. The suite now runs paginated variants as well; see unmount-cleanup.test.tsx.

Documentation#

  • api/columns.md gains a § Status columns; api/cells-utils.md explains the split between status and categorical; guides/columns.md no longer teaches a hand-rolled bg-green-100 / bg-red-800 status cell as its custom-renderer example — that snippet was the exact pattern this release removes.
  • LoadingDisplayValue is now named in docs/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 ### Type block 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.


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#

  • loadingDisplay accepts an options object.

    TypeScript
    type 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 ReactNode may be passed instead of an indicator name, rendered inside the grid box.

  • delayMs holds 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 to 0; 200-300 ms is the recommended companion to any visible indicator. Not defaulted to a non-zero value on purpose.

  • prefers-reduced-motion is respected. The progress bar and the skeleton stop animating; they stay visible, they just hold still.

  • Exported types: LoadingIndicator, LoadingDisplayValue, LoadingDisplayOptions.

Changed#

  • loadingDisplay defaults 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 pageSize instead 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). GridPagination receives isLoading / isRefetching — the same signals body.tsx has 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.

  • skeletonColumns was never a consumer concern. grid.tsx derives 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, and delayMs under 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. isLoading already means "first load" and isRefetching "reload"; no new state was needed and isInitialized stays 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#

  • enableKeyboardNavigation defaults to true. The grid declares role="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 in apps/ rendering <ActiveGrid> plus 7 elsewhere in packages/, 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, PageUp and PageDown become active on the same terms. See guides/keyboard.md.

  • onRowClick, onRowDoubleClick and onCellClick fire while keyboard navigation is on. They were suppressed whenever navigation was enabled (base-row.tsx for the row callbacks, body-cell.tsx for the cell one), together with the cursor-pointer affordance 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-container to the role="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 inside role="grid" is an aria-required-children violation, rated critical — it appeared in three of the five gate configurations the moment navigation was switched on.

Added#

  • Enter starts 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 were onClick and onDoubleClick on the cell, and a <div role="gridcell"> does not synthesise a click from Enter. Verified for all three editing modes — cell, row and batch.

    onCellActivate is 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-title class, 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 no tabIndex; 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 the role="grid" element while the tab stop was the inner scroll container, so :focus-visible could 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):

keystatus
grid.skipLinkremoved
grid.skipPastRowsadded (replaces grid.skipLink)
grid.endOfRowsadded (skip-link landing point)
grid.grouping.removeGroupingForadded (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.skipLink is no longer read.
  • Consumers that set enableKeyboardNavigation and relied on onRowClick, onRowDoubleClick or onCellClick not 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-label and no title, which axe reports as button-name at 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-number and expander declare header: () => null, producing columnheader elements with no text — announced as a blank column, and reported as empty-table-header. They now carry visually hidden text rather than only an aria-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, not columnheader. Measured cause: with columnheader there were two of them at every aria-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 tabindex PASS (16 nodes) and nested-interactive PASS (15 nodes) while primitives/resize-handle.tsx has no tabIndex, no role, no onKeyDown and no aria-* — 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 with scrollbar-width: thin: 10 CSS px at 100% zoom, 8 at 125%. The grid now measures offsetWidth − clientWidth on the body container and writes it back, so the two edges meet wherever the zoom sits.

    configurationgutterheader pad (before)delta (before)delta (after)
    thin, 100% zoom108-20
    thin, 125% zoom8800
    auto158-70

    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-clearance is declared on :root as var(--grid-scrollbar-width), so it resolved there. A :root override 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 at 12px and 8px at 16px. 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 :root substitution. The breaking route was the :root snippet in docs/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 with scrollbar-width: none, fit state: clearance 0px before, 8px after, with delta and phantom scroll 0 in both.

Changed#

  • theme.scrollbarWidth: string → 'thin' | 'auto'. A length could never work: scrollbar-width overrides ::-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.

  • scrollbarWidth is no longer in THEME_TO_CSS_VAR. scrollbar-width is a real CSS property and is not inherited (measured: set on an ancestor, the container's computed value stays auto), so it cannot travel in the wrapper's style object. use-flex-layout.ts applies it to the scroll containers.

  • EDGE_RESERVE is gone. The flex fill and the CSS clearance both come from trailingClearanceFor(), so INV-18 holds by construction rather than by two constants agreeing. The one remaining constant is MIN_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 :root override 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.ts gained a token matrix — :root overrides 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.

TSX
// 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 entirely

The 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#

  • externalFilter dev-warns in server mode instead of being ignored. The predicate is applied in exactly one place -- use-grid-setup.ts, under mode === 'client' -- so on a server-mode grid it was built and never called, with no signal of any kind. It now warns once per gridId.

    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.

  • enableFilterBuilder is 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 as externalFilter and then dropped. ServerFetchParams has no expression field, so nothing reached fetchFn either. Button and panel are now withheld, with a dev-warn per gridId explaining the absence and pointing at F-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 initialFilterExpression on a grid that does not enable the builder.

Changed#

  • docs/guides/filter-builder.md claimed the composed expression was forwarded in ServerFetchParams.filterExpression and 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 at F-12 for the planned server route.

  • The same guide claimed the expression was persisted -- serialised to a ?filterExpression= URL parameter under enableUrlSync, and stored under the grid's namespaced key under enablePersistence. Neither driver carries it: url-driver.ts handles page, pageSize, sort, filters and search; validate-persisted-state.ts accepts sorting, columnFilters, columnOrder, columnSizing and globalFilter. Documented as consumer-owned, with the round-trip via onFilterExpressionChange + initialFilterExpression.

  • docs/api/grid-props.md gained a "Filter axis: client mode only" section and per-prop mode notes; the JSDoc on externalFilter, enableFilterBuilder, initialFilterExpression and onFilterExpressionChange carries 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 per gridId explaining that infinite scroll needs fetchFn.

    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. editMode and its callback are a pair: 'cell'/'row' commit through onDataCommit, 'batch' through onBatchCommit. A mismatch used to leave the cells editable while the commit path dead-ended -- batch with only onDataCommit showed a "Save all" button that returned false and discarded the buffer, and cell mode with only onBatchCommit swallowed 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 per gridId naming the missing callback. A read-only grid with no editing callbacks at all is unaffected and never warned about.

Changed#

  • docs/api/grid-props.md documented editMode as 'cell' | 'row'; the type has carried 'batch' since batch editing landed, and onBatchCommit was 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 error state 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 with GROUP_CARDINALITY_EXCEEDED when 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: 10000 for non-leaf group fetches. Measured, it reached nothing: the server ignores fetchSize on the group-listing path entirely (a listing returns every group or is refused, it does not page), and the value was clamped to maxFetchSize on 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.validate now runs, before the consumer is told anything. It was declared on GridColumnMeta and documented in guides/editing.md and api/columns.md, but nothing read it. Measured before the fix: the validator ran 0 times, the commit succeeded, and the rejected value was delivered to onDataCommit.

    It now runs on all three commit paths:

    • cell mode and row mode — in useEditingState.commit(), before onDataCommit. 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.

  • A valueParser returning undefined no longer commits an empty value. Such a parser is saying it cannot parse the input. That undefined used to be stored and committed, so the consumer received the column key with undefined as its value -- which a Prisma update reads as "leave this field alone" and JSON.stringify drops entirely, failing silently on every layer. The input is now rejected visibly with grid.edit.invalidValue and the commit is blocked until the value parses.

Added#

  • grid.edit.validationFailed and grid.edit.invalidValue translation keys (English defaults; override through your own t).
  • data-slot="editing-error" and aria-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.md corrected: 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.left als 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.desc als string, pagination als array, globalFilter als 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.ts met per-namespace validator-maps (MAIN_STATE_VALIDATORS, LOCK_STATE_VALIDATORS, PINNING_STATE_VALIDATORS). Intern; geen publieke API.
  • Dev-only console.warn per 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.load accepteert 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 loadInitialGridState draait onvoorwaardelijk. Het hing aan options.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.
  • exportToCSVGrouped en exportToCSVServerGrouped deelden 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 + hun Raw*Mapping-types), en de contract-resolver (resolveColumnContract/resolveAllColumnContracts, ResolveAllArgs, ResolvedColumnContract/Map, ServerColumnOverride).
  • Verwijderd (root-export): de createServerFetchFn-client-adapter (+ CreateServerFetchFnConfig) en queryBuilder (+ PrismaWhereClause/ PrismaFilterCondition). Server-mode loopt uitsluitend via createResourceServerFetchFn + @workspace/active-resources.
  • Behouden (client-/UI-materiaal): de per-kolom-column.server-declaratie met scalarField/byField/… en het ServerColumnContract-type — nu uitsluitend client-side hydration-materiaal (extractWireContract in use-grid-setup, TD-3-snoeiing van een verouderde sort/filter). Er is geen server-side resolver meer.
  • Migratie: een grid dat nog createServerFetchFn gebruikte migreert naar een resource-view (active-resources) + createResourceServerFetchFn; de enige niet-resource-uitzondering is de Redis-sessie-grid (ADR-004), een hand-rolled ActionResult-action zonder wire-contract.
  • ADR-013/017-statussen bijgewerkt (server-resolutie nu volledig weg); de docs onder docs/api en docs/guides chirurgisch bijgewerkt (drift-detector groen), DATA-ACCESS.md op 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 publieke warnReserved/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, extractWireContract en de createServerFetchFn-client-adapter — levend gehouden door de Redis-sessie-grid (ADR-004) en de geparkeerde insurance-grids. Volledige verwijdering in Deel B; voorwaarden in docs/DATA-ACCESS.md (merged into ARCHITECTURE.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#

  • LookupGrid accepteert een optionele serverCapabilities-prop en geeft die door aan de interne ActiveGrid. 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/operatorPreset blijven 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-filterType dan 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: false onderdrukt + dev-warn; useToolbarSearchWarning checkt op het resource-pad capabilities.search (de legacy server.global-check vuurde daar onterecht).
  • De dev-only drift-check in createResourceServerFetchFn vergelijkt 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:

  1. Sync active-ui én active-grid in één werkstroom (node scripts/sync-package.mjs <naam> <repo> na het opheffen van de SYNC_BLOCKED-entries).
  2. package.json's daar: verwijder alle @radix-ui/*-deps en radix-ui uit 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.
  3. 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.
  4. 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 (met asChild), is nu active-ui's PopoverTrigger (Base UI) — compositie via render={<El/>}. Alle zes ingebouwde filters zijn mee-gemigreerd; host-apps die zelf filter-popovers bouwen migreren hun asChild-sites.
  • FilterPopoverContent: props zijn nu active-ui PopoverContent-props; onOpenAutoFocus bestaat niet meer (gebruik initialFocus={false}).
  • GridMenuItem/GridContextMenuItem en verwanten: props volgen de active-ui menu-items (Base UI): onSelect → onClick, keep-open via closeOnClick={false}. GridContextMenuSubTrigger/GridMenuSubTrigger renderen geen eigen chevron meer (de active-ui sub-trigger brengt die mee).
  • GridContextMenu (context-menu-wrapper): trigger-compositie via render; het children-element moet een enkel React-element zijn.

Removed#

  • Dependencies @radix-ui/react-context-menu, @radix-ui/react-dropdown-menu en @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: false is 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 internal hydrated signal from use-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 and statePriority machinery (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#

  • statePriority prop — 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 only sorting still lets localStorage supply the columnFilters; 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 while enablePersistence is on. The default 'url-first' reproduces the pre-4.4.0 merge behaviour exactly, so existing consumers are unaffected. New pure resolver in state/resolve-initial-state.ts; GridStatePriority is 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 — a select/badge filter holds a bare array value (string[] / number[]), but the URL driver serialized it through the scalar contains fallback, so it came back as an operator-object and the select filter (which reads Array.isArray) silently dropped it when both enableUrlSync and enablePersistence were on (e.g. on back-navigation). The URL driver now round-trips array values losslessly via an in transport 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 the contains:a,b form 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. useFilterSync only reset local state on an external clear, never adopting an externally-SET value. For text/number the debounce apply-effect then pushed setFilterValue(undefined) with the stale empty value and cleared the freshly-hydrated filter (the URL lost its filters= param a moment after load); date showed a blank picker while the filter stayed active. useFilterSync is now a forward-sync hook: it adopts the engine value into local state on every external change (set and clear) via a syncFromExternal callback, 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/boolean were 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-value select filter on a numeric column (filterType: 'number', inherited from the DMMF for Int/Float/Decimal/BigInt fields) 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; string select filters are unchanged. Covered by two new unit tests.

[4.3.0] — 2026-06-17#

Added#

  • Generic interval filter — a new ColumnFilterType for matching a row's interval (two numeric boundary fields) against a numeric query range, with a within (containment) / overlap mode 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'}) and IntervalMode in types/filters.ts. The filter is inactive when both bounds are null.
    • IntervalFilter component (components/filters/interval-filter.tsx), wired into FilterWrapper for filterType: '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 via meta.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}) in utils/build-interval-where.ts — a pure, framework-neutral helper that turns an IntervalFilterValue into a Prisma-shaped where fragment. No Prisma import, no domain knowledge; a server resolver composes the result. Semantics: within emits lo >= from AND hi <= to per given bound (a gte/lte excludes a null row bound by construction, so open-ended rows never match a closed query side); overlap is null-tolerant on both bounds.
    • i18n keys grid.filter.intervalWithin / grid.filter.intervalOverlap (host-translated; English defaults provided).

Notes#

  • The declarative column.server query-builder path skips interval columns with a dev-warning (they carry a value, not an operator); resolve them server-side via buildIntervalWhere. A declarative filter:interval mapping 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) in use-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 + header padding-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 = 0 waar 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-clearance trailing-breedte bovenop de kolom-total, en de flex-fill reserveert exact hetzelfde (EDGE_RESERVE). Beide MOETEN gelijk blijven — lopen ze uiteen, dan breekt óf fit-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.tsx die 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 uit state/load-initial-state.ts (initialPageSize || 10) en de selector-opties uit controls/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 in onSelect (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?: boolean op ContextMenuItem — 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 met isLoading houden het menu eveneens open (defensief vangnet; merk op dat isLoading-items in de praktijk disabled zijn en dus niet selecteerbaar — keepOpen is 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 een keepOpen-item het menu open houdt én de actie vuurt. De /test-client-grid-harnas heeft een keepOpen-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 én enableKeyboardNavigation aanstonden. De keyboard-nav Escape-tak (hooks/use-keyboard-navigation.ts) riep target.blur() aan om de focus terug naar de cel te brengen. Een programmatische blur() vuurt een focusout met relatedTarget = null; handleGridBlur (components/grid.tsx) leest dat als "focus verliet het grid" en committe — met commitOnBlur aan — de waarde vóórdat de cancelEdit() van de editor was doorgewerkt (de blur-commit won van de annulering). De handler herfocust nu de cel zonder target.blur(): focus verplaatst naar een element ín het grid (relatedTarget = de cel), zodat handleGridBlur een no-op is en de annulering wint. Escape annuleert nu ongeacht commitOnBlur. Raakt o.a. de pension glide-path-editor (de enige productie-grid met commitOnBlur).

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 de onClick van de cel, die met editTrigger="click" opnieuw handleEditStart aanriep. startEdit() her-initialiseert de editing-state vanaf de originele rij-waarde, waardoor de net-gekozen waarde werd weggegooid (components/sections/body-cell.tsx). handleEditStart doet 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. Bij editTrigger="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 oudere focusout-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; handleGridBlur zag een relatedTarget buiten het grid en committe — met de oude waarde, want de onChange van de kalender (de nieuwe datum) draait pas op de click ná de focusout. De editor sloot, de keuze ging verloren. Beide commit-paden delen nu één isWithinEditorPortal-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 het focusout-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" én enableKeyboardNavigation kreeg 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"-useEffect in hooks/use-keyboard-navigation.ts de 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.ts polyfillt nu Element.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 commitOnBlur aan staat. Voorheen was het enige commit-op-blur-mechanisme de wrapper-onBlur (handleGridBlur, components/grid.tsx), die alleen vuurt bij een echte focusout — 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 geen focusout en de edit bleef stil openstaan ondanks commitOnBlur; 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 wanneer commitOnBlur aan staat. Hij spiegelt de commit-beslissing van handleGridBlur en hergebruikt dezelfde commit() (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 daaropvolgende focusout in dezelfde tick triggeren; zonder guard zouden beide onCommit aanroepen (dubbele server-write). Een committingRef-vlag dedupliceert de in-flight commit en wordt in een finally weer 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 wanneer commitOnBlur uit staat (Escape blijft cancelen). De bestaande use-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 de useGridSetup-return en geconsumeerd door een useLayoutEffect in components/grid.tsx (vlak naast — en in dezelfde pre-paint-scrollTop-familie als — de bestaande group-collapse scroll-correctie, L7). Omdat datasetKey op de gedebouncede filter/search-waarden gebouwd is, vuurt de reset één keer per toegepaste query, nooit per toetsaanslag. datasetKey bevat geen pageIndex en 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 test scroll-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#

  • exportToJSONServerGrouped past nu de valueFormatter toe op leaf-data, consistent met zijn sibling-formats. De server-grouped JSON-leaf-export schreef leaf-waarden rauw via item[col.id], terwijl alle siblings de formatter wél toepassen: de server-grouped CSV/Excel/PDF-varianten via buildServerGroupedExportRows (utils/export-utils.ts:713) en de client-exportToJSON via formatCellValue. 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 rauwe val en '' voor null/undefined. Een grid met bijv. een currency-valueFormatter levert in server-grouped JSON-export voortaan "€ 1234.50" in plaats van 1234.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 platte TData[] van fetchFn, geen Row<TData>-objecten, dus getValue/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-useRef naar de BlockCacheStore verhuisd. De "last good page"-snapshot die de vorige rijen zichtbaar houdt tussen invalidate(...) en de fresh-fetch-resolve (ADR-016 stale-while-revalidate) lag in een lastGoodPageRef in use-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 methode BlockCacheStore.peekPageWithFallback(...) — een render-time write in dezelfde categorie als het bestaande pageSliceCache-render-write-precedent (geen notify(), 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 publieke OrchestratorResult-shape is ongewijzigd. BlockCacheStore is internal-only — geen publieke API-wijziging.
  • Regressie-anker: de geharde GRID-ARCH-16-tests in use-data-orchestrator.test.ts leggen de fallback-zichtbaarheid in het invalidatie-venster nu met harde asserts vast (de oude GRID-ARCH-06 liet dat achter een conditionele if (... && 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 evaluateFilter deed voor equals/notEquals een string-vergelijking, ook op number- en date-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 filter 5 en 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> zonder tabIndex/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 bestaande aria-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 threshold column-type-preset in getDefaultColumnTypes (een number- variant met defaultFilterOperator: 'greaterThanOrEqual'). Het number- preset blijft ongewijzigd op exact-match. Patch, geen minor: getDefaultColumnTypes is een @internal Geneax-helper buiten de externe semver-surface, en defaultFilterOperator bestond 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:

  1. 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 in docs/ROADMAP.md for the systematic value-shape-audit that catches the remaining members of variant 3's class.
  2. 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-width was being computed from Object.values(widths).reduce(sum) inside writeCssVarsToContainers, 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-width from the input widths-map and reads it from grid.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) in utils/control-position.ts — pure function that returns 'left' or 'right' based on headerAlign. Single source of truth for every control-positioning decision in the header and filter row.
  • header-cell.tsx uses 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 via flex-row-reverse on 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 — new resolveFilterSubKey helper with a targeted property-merge: when both wire and DMMF declare a filter:field mapping, properties that DMMF supplies but wire does not are inherited from DMMF onto wire. Currently inherited: filterType, nullable. The merge fires only when the _kind matches — cross-_kind merges (e.g. wire filter:multiField
    • DMMF filter:field) are rejected to avoid incoherent objects. Consumer-explicit values still win.
  • components/filters/number-filter.tsx — strict Number()-cast on the inline-input's debouncedValue before setFilterValue. Number() was chosen over parseFloat() because the former rejects "5abc" (yields NaN) while the latter would silently produce 5. The cast plus a NaN-guard ensures the filter payload's value is always a real number, not a string-that-looks-like-one. Range mode's valueTo gets 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/isNotEmpty removed from the default operator-lists for getNumberOperators (9→7), getDateOperators (6→4), and getBooleanOperators (3→1). Kept in getTextOperators (text columns do have a meaningful empty state — null and "" — and the server-shape handles both since this release). Module-level JSDoc documents the rationale.

New API surface and infrastructure:

  • config/operator-presets.ts — new withEmpty preset for the rare nullable number/date column where isEmpty IS meaningful. Consumers opt-in per-column via meta.operatorPreset: 'withEmpty'.
  • types/server-contract.ts — FilterFieldMapping.nullable?: boolean field added. Used by the server-side condition builder to pick the right Prisma shape per nullability.
  • server/dmmf-defaults.ts — DmmfFieldMeta.isRequired?: boolean field added. generateDmmfDefaults populates FilterFieldMapping.nullable = !isRequired automatically.
  • server/resolve-column-contract.ts — resolveFilterSubKey (added in Variant 3 above) also inherits nullable from DMMF to wire.

Server-side condition shapes per (filterType × nullable):

CasePrisma shape for isEmpty
nullable texttop-level AND: [{OR: [{path: {equals: null}}, {path: {equals: ''}}]}]
nullable text + isNotEmptytop-level AND: [{AND: [{path: {not: null}}, {path: {not: ''}}]}]
non-nullable textflat {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 + warnStripped (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):

  1. Mousedown → engine sets columnResizing.isResizingColumn = X
  2. React commit → useFlexLayout's live-resize-subscriber (use-flex-layout.ts:296-355) registers a store-subscribe
  3. That subscriber writes {[X]: width} (single-entry map) to writeCssVarsToContainers on every store-change
  4. Inside writeCssVarsToContainers, --grid-total-width was computed as Object.values(widths).reduce(sum) = the resizing column's width alone (~186px for an Ontvangen-column)
  5. CSS-cascade min-width: max(100%, calc(var(--grid-total-width) * 1px)) evaluates to max(viewport, 186px) = viewport, the body's content wrapper shrinks to viewport-width, the row gets clipped, browser sees content fits viewport, clamps scrollLeft to 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's writeWidths-filter partial-map case
  • types/filters.test.ts — 4 length-counts adjusted (number 9→7, date 6→4, boolean 3→1); 4 new tests assert isEmpty/isNotEmpty absent from non-text defaults, present in text defaults
  • config/operator-presets.test.ts — new test for the withEmpty preset
  • server/dmmf-defaults.test.ts — 2 new tests cover nullable propagation from isRequired and the undefined-fallback for older adapters
  • server/resolve-column-contract.test.ts — 5 new tests cover the filterType and nullable inheritance from DMMF, consumer-override precedence, and cross-_kind rejection
  • utils/query-builder-contract.test.ts — 7 new tests cover all (filterType × nullable) combinations for isEmpty/isNotEmpty, plus combination with value-based filters on other columns, plus the end-to-end value-shape-safety net (numeric equals doesn't fall back to text)
  • 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-width invariant 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/isNotEmpty removed from default number/date/boolean operator-lists — no consumer in this monorepo used those defaults directly (verified via grep NUMBER_OPERATORS|getNumberOperators across apps/). External consumers relying on these defaults can opt back in via meta.operatorPreset: 'withEmpty'.
  • meta.nullable on FilterFieldMapping is optional and consumers who omit it get pre-1.4.0 behavior (treated as nullable for shape-building).
  • getFlatHeaders added to CssVarGrid interface — this is the minimal structural type used by writeCssVarsToContainers, 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 — NumberFilter default-operator UX (drempel-operator overwegen); UX-keuze per kolom of preset
  • TD-9 — Client-side evaluateFilter doet tekstvergelijking op numerieke kolommen voor equals/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 — t prop 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 the FilterBuilderPanel call-site in grid.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:

  1. Display & alignment
  2. Filtering (filterType, customFilter, operator config, filterOptions, date-filter config, suppressFilterIcon — previously scattered across three locations)
  3. Editing
  4. Aggregation
  5. Cell formatting & tooltips
  6. Layout
  7. Header customization
  8. 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:

  • ### toPrismaWhere section 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 + aggregate helpers — 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 + aggregate on a numeric column (salary).
    • Fail-closed + reserved policy summary: groupBy instruction 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 private Set<string> warning-memo registers).
  • server/resolve-column-contract.ts — warning section deleted; replaced with a short comment noting the move. JSDoc cross-link to warnStripped repointed 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 /server API surface is unchanged — consumers importing from @workspace/active-grid/server see no difference.
  • Test imports — server/resolve-column-contract.test.ts and utils/query-builder-contract.test.ts updated 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's groupBy raw output into the {data, groups} shape that useServerGrouping consumes. Handles Prisma's three _count shapes (number, {_all}, {field}), flattens _sum/_avg/_min/_max blocks into camelCase aggregation keys, and computes hasSubGroups from drill-down depth.
  • create-grid-server-action.ts — the inline 173-line branch is replaced by a single handleGroupByRequest({...}) call wrapped in the existing success: true envelope. Imports handleGroupByRequest from the new module.
  • New tests — src/server/group-by-handler.test.ts (27 unit tests) pinning the current correct behavior of translateGroupResults and buildGroupKeyWhere. Three describe-blocks: count-extraction (5 cases), aggregations-flatten (6 cases), hasSubGroups invariant (4 cases), data-and-key preservation (4 cases), and buildGroupKeyWhere end-to-end (8 cases). Pinning-purpose: lock the current correct behavior before the upcoming request touches this area. The factory-level integration tests in create-grid-server-action.test.ts are 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.

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 ServerColumnContract now states "all runtime- active since v4.0.0" with the phase-→version mapping.
  • Per-field JSDocs of global / groupBy / aggregate now state "Runtime-active since v3.1.0" / "v4.0.0" instead of "Reserved for phase 2/3".
  • Section headers in server-contract.ts say "runtime-active since vX.X.X (ADR-013/017)" instead of "phase 1 active" / "reserved for phase 2/3".
  • GridColumnDef.server JSDoc in columns.ts updated symmetrically with the same phase-→version mapping plus cross-link to ARCHITECTURE.md's server-side query-contract section.

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.md to docs/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.ts twice) repointed to docs/DECISIONS.md ADR-013 / docs/ARCHITECTURE.md server-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 to docs/contributing/.
  • Cross-package dead links in R14-R21-RELEASE.md (repo root) and docs/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 into DONE.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#

  • sonner removed from the external runtime-dependencies list (sonner was 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. Move warnStripped / warnReserved to utils/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 🟢 — createGridServerAction group-by-branch extractie (~170-regel branch naar handleGroupByRequest-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.

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).
  • /server subpath: 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 Prisma groupBy.by: string[] from active runtime selection
    • resolved contract. Supports GroupByFieldMapping and RawGroupByMapping with {type: "field", path}. GroupByRelationMapping is reserved (see "T3 correction" below). RawGroupByMapping with {type: "expr", expr} is also reserved.
  • queryBuilder.toPrismaAggregateFromContract(contractMap, options) — walks every column with a non-false aggregate mapping and builds _count / _sum / _avg / _min / _max blocks. Under single- source-of-truth (B-keuze), no separate runtime selection — every declared aggregate is included.

Changed — createGridServerAction (factory)#

  • GridServerActionRequest additively extended with groupBy?: GroupByColumn[] and groupKeys?: 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 added columnContract and fase 2 routed globalFilter.
  • GridServerActionResponse additively extended with isGrouped?: boolean and groups?: GroupInfo[]. Pre-v4.0 flat-mode responses keep both absent.
  • PrismaModelDelegate structural type extended with optional groupBy?: (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 via prismaModel.groupBy({by, where, orderBy?, _count, _sum, ...}); groupKeys.length >= groupBy.length → leaf-level findMany with group-key WHERE applied alongside the column-filter / globalFilter conditions. Sort-within-group uses the existing toPrismaOrderByFromContract-translation of state.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 by createGridServerAction's internal flow + toPrismaGroupByFromContract + toPrismaAggregateFromContract.
  • queryBuilder.toPrismaWhereFromFilters — replaced by toPrismaWhereFromContract.
  • queryBuilder.toPrismaOrderBy — replaced by toPrismaOrderByFromContract.
  • queryBuilder.toPrismaWhere — internal helper that only the three legacy functions used.
  • handleSelectFilter — internal helper that only toPrismaWhereFromFilters used.

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.ts and filter-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:

  • isServerGroupingActive gesplitst in twee predicates in components/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 lokale serverGroupBy) 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 optionele canAcceptDrop?: (columnId: string) => boolean prop. Default fall-back op column.getCanGroup(). <ActiveGrid> wires automatisch een server-grouping-aware predicate in server-grouping mode dat column.columnDef.server?.groupBy checkt i.p.v. het engine- enableGrouping flag (dat in mode altijd false is).
  • B-2 in use-server-grouping.ts dataset-key-effect: vlag isLoading/isRefetching niet wanneer groupBy.length === 0 (geen fetch firet, geen resolve om te resetten). Bij transitie naar empty: expliciet beide flags op false als 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:

  • useColumnReorder API split enabled → 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.tsx rendert dragProps wanneer reorder OR grouping aan staat. Per-header beslissing voor isGrabbable: reorder ON → alle leaf-headers grabbable; grouping-only ON → alleen kolommen met column.server.groupBy grabbable (symmetrisch met de drop- acceptatie-fix; geen silentieel-rejected slepen op niet-groepbare kolommen).
  • header-cell.tsx cursor-affordance gated op isGrabbable (per-header), niet op enableColumnReordering (per-grid). Grouping- only grids tonen nu ook cursor-grab op 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 van grid.<key> in productie-code; geen dynamische template-aanroepen):

    • serverGrouping.noGroups
    • serverGrouping.expandGroup
    • serverGrouping.collapseGroup
    • serverGrouping.exportProgress
    • batch.confirmDiscard
    • batch.saveFailed
    • lookup.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.ts die 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 in use-server-grouping.test.ts; −2 voor de twee dode-key-tests in batch-translations.test.ts die 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 (zonder server.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) als isRefetching (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:

TypeScript
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=false in 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 via setGroupIfToken-token bij resolve, en dat expanded-state synchroon clearet bij groupBy-switch. Token-pad blijft ongewijzigd onder voorstel B; expanded-clearing gebeurt in useServerGrouping's dataset-key-effect (los van useDataOrchestrator). 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.

TypeScript
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 deps

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

TypeScript
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, __diagEffectFires refs and [DIAG …] console logs in use-data-orchestrator.ts; __fetchCounter / __activeFetchCounter / [orders fetch …] logs in apps/admin/.../test-server-grid/grid/grid-utils.ts; __orderGridMountCounter / mount-tracker useEffect in apps/admin/.../test-server-grid/grid/grid.tsx; the instrumented-fetch.ts wrapper in apps/admin/.../test-minimal-grouping/ (re-pointed to the regular createGroupingFetchFn).

Follow-up TD#

  • TD-4 (was TD-6 vóór de documentatie-audit op 2026-05-22, zie hernummerings-noot in ROADMAP.md): the debouncedColumnFilters ref-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 with useDebounce's useState(value) initialiser, or an upstream state-mutation in state.columnFilters that 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-358 has the exact same action-in-deps anti-pattern that Fix A solved on the fetch-trigger effect. prefetchAdjacentBlock is 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[] in ActiveGrid is the sole bron. Initialised from props.serverGroupBy ?? []. Toolbar mutations call setServerGroupBy via an intercepted engine-onGroupingChange callback (handleEngineGroupingChange); they no longer write into the engine store directly. The previous engine→effective sync useEffect (grid.tsx:257-277) and its useStore(grid.baseStore, selectGrouping) subscription are deleted.
  • One-way mirror useEffect writes serverGroupBy.map(g => g.field) into grid.baseStore.state.grouping via direct store.setState (not grid.setGrouping(...), which would re-call the intercept). No-op-detection: returns prev when the engine grouping content already matches, so subscribers are not notified on inert renders.
  • Engine onGroupingChange override wired via new interceptEngineGroupingChange prop on useGridSetup and onGroupingChangeOverride on useGridOptions. Only set when isServerGroupingActive is true — client-grouping consumers keep the engine's default makeStateUpdater("grouping", grid).
  • Consumer-facing props.onGroupingChange observation callback is unaffected. The use-grid-setup.ts:393-401 store-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.ts that 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:

  • isSameServerGroupBy value-equal comparator (3.1.2) — still guards the props.serverGroupBy → serverGroupBy mirror edge against inline array literals.
  • recordKeyRemountUnmount cleanup (3.1.2) — still blocks the Next.js navigation false-positive in detectKeyRemount.
  • Badge-preset enableSorting: true removal (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-grouping stable (no flicker, no console abort/refire noise); /test-server-grid to 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 no server.sort/server.filter mapping. 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 derives enableSorting: false cleanly 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-grid main 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/server subpath — server-only rawGlobal(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 one OR-clause; returns undefined for empty queries or when no column contributes.
  • useToolbarSearchWarning hook — internal. Fires a one-time-per- gridId dev-warning when a server-mode grid sets toolbar.search.enabled = true but no column declares server.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#

  • createGridServerAction folds globalFilter into the where-clause via top-level AND, not via spread. This preserves any OR-clause that a FilterMultiFieldMapping column-filter may have produced; the two OR-clauses stay semantically separate. When globalFilter is 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 / FilterFieldConfigMap removed from the public exports. Per-column filter mappings now live on column.server.filter via the declarative helpers (scalarField, fieldFilter, multiField, someByField, everyByField, noneByField).
  • CreateServerFetchFnConfig.filterFieldConfig removed; replaced by a new columns field that lets createServerFetchFn extract each column's server contract into a serializable columnContract map transmitted per request.
  • GridServerActionRequest.filterFieldConfig removed; replaced by GridServerActionRequest.columnContract: Record<string, ServerColumnContract>.
  • column.enableSorting / column.enableColumnFilter semantics hardened. When the column has no server.sort / server.filter mapping (and no DMMF default exists for the column-id), the header no longer offers sort/filter UI even when these flags are unset/true. Explicit enableSorting: true without a mapping logs a dev-warning and is forced off to avoid silently-stripped instructions. Explicit enableSorting: false always wins (consumer override). The policy is applied only for mode="server" grids — client-mode defaults are untouched.

Added#

  • @workspace/active-grid main 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/server subpath — server-only: rawSort / rawFilter (closure-based escape-hatches for the serverColumnOverrides factory parameter), generateDmmfDefaults, resolveColumnContract, resolveAllColumnContracts, warnStripped. Plus types: DmmfModelMeta, DmmfFieldMeta, and the four Raw* 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. serverColumnOverrides is a per-column override map that may contain rawSort / rawFilter closures and has the highest authority in conflict-resolution.
  • queryBuilder.toPrismaOrderByFromContract and toPrismaWhereFromContract — contract-aware variants used by the factory. The legacy toPrismaOrderBy and toPrismaWhereFromFilters remain 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-key false overrules 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 new columns field (GridColumnDef<TRow>[]) used to extract per-request column-contract. Migration is mechanical: pass the same columns array you already give to <ActiveGrid>.
  • useColumnManager / enhanceColumns thread the grid's mode through so server-mode-only capability resolution can run.
  • dbCore + dbArchives action wrappers wired with a structural DMMF adapter that reads _runtimeDataModel.models on the Prisma client and adapts it to active-grid's DmmfModelMeta shape. 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: {...} on createServerFetchFn with a columns prop pointing at the column definitions.
  • For each server-mode column, add a server field with the right helper. scalarField() covers ~80% (sort + filter on column-id); nested 1:1 paths use scalarField("user.profile.displayName"); one-to-many relations use {sort: byCount(...), filter: someByField(...)}.
  • The userRoles-style v2.x demping (enableSorting: false + enableColumnFilter: false on 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 + GroupCache peek/touch split + setMaxSize. Reads are now pure peekBlock / peekPage / peekAllData / peekGroup methods that don't touch LRU; freshness is signalled only by writes and explicit touchBlock / touchGroup. Each cache adds setMaxSize(newSize): boolean returning 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.

  • useDataOrchestrator rewritten around BlockCacheStore + useSyncExternalStore. Eliminates 4 inline react-hooks/refs disables in use-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() and retry() call fetchData() synchronously (imperative consumer-trigger, not a render-time mutation). A token-aware inFlightTokenRef guard in the fetch effect prevents double-fire when isCacheHit transitions during the same refetch cycle.

    • controllerRef semantics unchanged — previous controllers stay abort-able after their fetch resolves (preserves the GRID-TEST-NEW-02 prefetch abort contract).

  • useServerGrouping rewritten around GroupCacheStore + useSyncExternalStore. Eliminates 1 inline react-hooks/refs disable in use-server-grouping.ts (the Pattern-3 GroupCache cacheRef init). Fetches go through setGroupIfToken(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-effect disable on the fetchTopLevel useEffect (Pattern 5) and the inline react-hooks/refs disable on the rowsRef.current = rows shadow-ref mirror (Pattern 2).

Added (internal)#

  • BlockCacheStore<TData> class (src/hooks/block-cache-store.ts). Wraps BlockCache with the useSyncExternalStore contract (subscribe / getSnapshot / getServerSnapshot), monotonic invalidationToken counter for stale-fetch protection, and setBlockIfToken as 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 as BlockCacheStore, keyed on group-key-path (unknown[] + blockIndex). Adds invalidateGroup(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.ts incl. 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 plus invalidateGroup partial-invalidation case).
    • 5 additional BlockCache tests in page-cache.test.ts (peek/touch/setMaxSize) and 4 additional GroupCache tests in group-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 in use-server-grouping.test.ts (verifies that a slow fetch for the old groupBy is silently dropped via token-mismatch when groupBy changes mid-flight).
  • 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.ts provides runWithVariance (N=5 + warmup → median + p95 + stddev) and createBenchFetchFn (deterministic mock server-fetch). Output JSON includes runs[], median, p95, stddev per metric. Frozen pre-rewrite baseline at benchmarks/results-before-r14.json; post-rewrite output at benchmarks/results-after-r14.json; partial S2 snapshot at benchmarks/results-after-block-cache.json for traceability.
  • BENCHMARK_OUTPUT_PATH env-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_NAVIGATIONS env-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 by BLOCK_CACHE_STORE_PROFILE / GROUP_CACHE_STORE_PROFILE env-vars respectively. Counters: notify-count (with per-reason breakdown), getSnapshot count, subscribe/unsubscribe, peak listener count, listener invocations, setBlockIfToken / setGroupIfToken accepted/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-level README.md and LICENSE remain; everything else — including CHANGELOG.md — now lives under docs/. 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-line documentation/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 to docs/ARCHITECTURE.md as contributor-only material.
  • LICENSE copyright holder corrected to "Active Development, an Active Monkeys BV company" (matches other platform packages). TanStack Table v9 portion-clause unchanged.
  • README.md trimmed from 559 → ~280 lines. Server-mode deep-dive, dev-warnings table, selector-performance notes, focus-restoration pattern, and row-selection reference moved to docs/guides/server-mode.md and docs/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.md expanded 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 (useGridMutation as required consumer API), ADR-010 (1.0.0 pageSize → fetchSize rename), ADR-011 (wrapper-validator contract runtime guard), ADR-012 (headless-first + CSS-var theming).
  • docs/PERFORMANCE.md rewritten 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 / metricBaselineAfterRatioStatus
1K×20 / initialRenderMs53.052.30.99✅
10K×20 / initialRenderMs55.957.11.02✅
100K×20 / initialRenderMs173.1232.11.34⚠ N=5 noise
1K×100 / initialRenderMs209.9251.01.20⚠ N=5 noise
grouped-3level-collapsed / initialRenderMs134.1133.71.00✅
grouped-3level-expand / initialRenderMs131.1163.01.24⚠ N=5 noise
column-virt-on / initialRenderMs555.6551.60.99✅
column-virt-off / initialRenderMs559.5559.91.00✅
editing-100k-idle / initialRenderMs291.1226.70.78✅ (faster)
editing-100k-active / initialRenderMs193.1147.60.76✅ (faster)
server-mode-block-cache / timeToFirstRowMs148.3105.90.71✅ (faster)
server-mode-block-cache / avgNavLatencyMs89.9102.51.14 (+14 %)❌ accepted
server-mode-block-cache / p95NavLatencyMs116.5169.51.46 (+46 %, +53 ms tail)❌ accepted
infinite-scroll / timeToFirstBatchMs56.498.81.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:

  1. Accepted intrinsic regression (server-mode-block-cache avgNavLatency + p95NavLatency). Measured in S2 with profile-mode instrumentation: 102 notifies per 50-refetch trial (51 invalidate + 51 setBlockIfToken — 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 the useSyncExternalStore two-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 batched setAsyncData + setIsLoading + setIsRefetching after 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.

  2. N=5 measurement noise (1K×100, 100K×20, grouped-3level-expand initialRenderMs; infinite-scroll timeToFirstBatchMs). High baseline stddev (40–75 ms in client-baseline scenarios), min/max ranges overlap fully with after-ranges. The infinite-scroll bimodal 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 additional GroupCacheStore allocations 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 inline react-hooks/refs disables removed (former lines 104, 167, 610, 640) — Pattern 3 BlockCache.
  • use-server-grouping.ts: 1 inline react-hooks/refs disable 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 / touchX API.
  • Lint: 0 warnings (react-hooks/refs count: 0 in use-data-orchestrator.ts, 0 in use-server-grouping.ts for the Pattern-3 occurrences; Pattern-2 shadow-ref + Pattern-5 set-state-in-effect disables intentionally preserved in use-server-grouping.ts).
  • Migration: none required. Internal refactor only.
  • Release report: see R14-R21-RELEASE.md in 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#

  • ./client subpath export removed from package.json. Zero internal consumers used it (all imports went via the root barrel); the src/client/ directory stays as an internal module for code-organization. Closes R-12. Migration: any external adopter using @workspace/active-grid/client switches 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/client imports.
  • 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: the t and errorKey config-options have been removed. Replace with the new onError?: (message: string) => void callback. The factory no longer imports sonner and 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. Pass meta.onCopySuccess and meta.onCopyError via the meta prop 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.

  • sonner removed from dependencies. Consumers using sonner-toast feedback now declare their own sonner dependency. Geneax already had sonner as a workspace-wide dependency, so no change there.

  • GridPaginationOptions — four @deprecated fields removed:

    • showInfo → use showRowCount
    • showPageSize → use showPageSizeSelector
    • showButtons → use showFirstLastButtons + showPrevNextButtons
    • classes → no replacement; drop usage

Added — features#

  • New optional onError callback in CreateServerFetchFnConfig — see Breaking changes above.
  • New optional onCopySuccess / onCopyError callbacks in GridMeta<TData> — see Breaking changes above.

Added — documentation#

  • 20+ previously undocumented GridBaseProps props now in docs/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 expressions
    • docs/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.md expanded with FilterExpression-tree types, evaluateFilterExpression, queryBuilder Prisma helpers (toPrismaWhereFromFilters, toPrismaWhereFromExpression, toPrismaOrderBy, toPrismaGroupBy).
  • docs/api/cells-utils.md expanded with editing types: EditMode, EditingCommitPayload, BatchCommitPayload, DirtyCellEntry, CellPosition, CellRange, CellSelectionState.
  • docs/contributing/drift-detector.md — new file documenting the lint:api-drift workflow for contributors.

Added — CI#

  • lint:api-drift is now a hard CI gate via the api-drift job in .github/workflows/ci.yml. A failing drift-check blocks the PR. Closes R-20.

Changed — internal#

  • createLocaleSortFn re-export path: now via the engine barrel (./engine) instead of the direct ./engine/fns/sortFns path.
  • @internal JSDoc tags added to FlexLayoutContext, FlexLayoutContextType, GridProvider, SortIndicator, resolveColumnBorderMode, and getDefaultColumnTypes — 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.
  • serializeDecimals doc-comment updated — removed misleading "backward compatibility" language. The re-export is a deliberate convenience, not a transitional shim.
  • Stale MIGRATION-NOTES.md references in source updated to docs/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 (matches package.json already updated in v1.0.5).
  • docs/api/grid-props.md: meta: any → Partial<GridMeta<TData>>.
  • docs/api/columns.md: getGroupingValue return type any → unknown.

Added — i18n for date filters (R-4 follow-up)#

  • calendarLocale?: Locale prop on <ActiveGrid> and <GridTranslationProvider>. Accepts any date-fns/locale object 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 or undefined. 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 custom dateFilterPresets), operator-label translation (consumer- side, via the getXOperators(t) factories), and calendar locale (the calendarLocale prop).
  • Tests — 6 new unit tests in i18n/grid-translation-context.test.tsx for useGridCalendarLocale,
    • 7 tests in utils/date-locale.test.ts for the built-in fallback map. 1194/1194 tests green.

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-drift in CI)
  • R-22 (sonner-import removal + onError-callback)

Notes for consumers#

  • Migration is mechanical for the two breaking changes. Geneax-internal consumers (18 createServerFetchFn call-sites) were migrated in the same release.
  • Copy-toast feedback for the row-context-menu is opt-in now — pass the meta.onCopySuccess / meta.onCopyError callbacks 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-drift runs on every PR; new exports require docs in docs/api/ or docs/guides/ or an explicit INTERNAL_ENGINE filter in the script. See docs/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 in docs/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.
  • GroupingToolbar component re-exported from main barrel. Previously reachable only via the deep import path.
  • API drift-detector improved: now scans docs/guides/*.md in addition to docs/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-drift wired into the turbo pipeline (turbo.json), enabling pnpm turbo run lint:api-drift repo-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 the FilterTrigger section (component never existed as an export; class name grid-filter-trigger is 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 (Prisma notIn + filter-context). Indexed in INDEX.md. Closes R-9.
  • scripts/verify-api-docs.ts + lint:api-drift task — drift-detector that compares actual exports vs docs/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 documenteert serializeDecimals (Prisma Decimal → number conversie 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-drift task 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 rondom engine/helpers/columnHelper.ts. TFeatures is hidden via de standaard ReactLayerFeatures = 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 in GENEAX-WERKWIJZE.md §7.17.
  • ColumnHelper<TData> type-alias voor de helper return-type (TFeatures-hidden).

Notes for consumers#

  • Bestaande columns-configs blijven werken — createColumnHelper is een additie, geen breaking change.
  • Nieuwe grid-columns-configs moeten createColumnHelper gebruiken 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 (default DefaultRowData); (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 of meta only 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 from rows); onChange: (val: any) → onChange: (val: unknown).
  • components/grid.tsx — 2× (prev: any) / (r: any) retained with motivated eslint-disable directives pointing to §9 of escape-hatch-patterns.md (TanStack-engine bivariance; React-layer hides TFeatures).
  • components/rows/editable-cell-wrapper.tsx — same motivated disable for one findIndex callback (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-2 package-specific platform-floor for active-grid).

Tests#

  • All 1181 tests remain green (no behaviour changes, no test additions).

Roadmap#

  • Closes docs/ROADMAP.md R-15. Lint now 0 no-explicit-any warnings (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 see benchmarks/results-after-block-cache.json on 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#

  • assertFactoryContract runtime guard in createGridServerAction (src/server/create-grid-server-action.ts). After every validateRequest-success path and after the default lightweightValidate path, 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_FIELDS constant 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 the MIGRATION-NOTES.md migration 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 the lightweightValidate-path-still-works case. 4 new tests, total suite at 1181 passing.

Documented#

  • README §Wrapper-validator discipline — new subsection under §Server Mode → createGridServerAction explaining 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_VIOLATION code 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 → fetchSize in buildSchema'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 generic result.error.message path unchanged.
  • No client-side changes. createServerFetchFn continues to forward fetchSize exactly 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-any in src/engine/**, src/utils/**, and src/components/cells/** — those belong to the engine-internal type-pass tracked under T-GX-AG-08.
  • grid.tsx — gridHeightVarOverrides memo: 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 the react-hooks/preserve-manual-memoization skip-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 local rowModel constant so the dep array is a simple expression. Suppresses react-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, updateValue in the editing-state hooks) can read the latest value synchronously. See editingRef.current = editing in use-batch-editing-state.ts and use-editing-state.ts; rowsRef.current = rows in use-server-grouping.ts.
  • Render-time imperative cache — BlockCache/GroupCache instances initialized and read during render so cache hits surface in the same render cycle as the pagination change (zero re-render delay). Affects use-data-orchestrator.ts and use-server-grouping.ts. A useSyncExternalStore rewrite is the right long-term fix and is out of scope here; tracked under T-GX-AG-08.
  • Ref-forwarding API — consumer passes a RefObject and the component writes into it from inside a useEffect (props.gridRef, props.restoreFocusRef in grid.tsx). The react-hooks/immutability rule 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 guarded useEffect that 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 in use-data-orchestrator.ts, use-server-grouping.ts, use-batch-editing-state.ts, use-editing-state.ts, and grid.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 in use-batch-editing-state.ts and use-editing-state.ts (auto-cancel edit on row-disappearance) suppressed with motivated block disables; the three pre-existing inline // eslint-disable-next-line directives 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 in selection-cell.tsx. The scroll-correction layout-effect in grid.tsx and 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 remaining no-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, the GridColumnDef / 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.md covers 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 on fetchFn is now named fetchSize, 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 on pagination.pageSize and pagination.pageSizeOptions and is unchanged. The pageSize field is removed — there is no alias, no deprecation pass, no @deprecated marker. 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 by createGridServerAction. The factory's validator now returns code: "INVALID_FETCH_SIZE" (was INVALID_PAGE_SIZE) and the error message references fetchSize. Hand-written consumers that call getActiveGridDataAction / getArchivesGridDataAction directly with a literal request object must use the new field name.
  • No backwards-compat alias — pageSize is gone from the package's public surface. package.json's pageSize field on PaginationState / pagination.pageSize (the UI page size) is unchanged.

Changed#

  • README §Quick Start, §Server Mode, §Top valkuilen — examples and guidance updated to use params.fetchSize and 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 — all params.pageSize references and the ServerFetchParams table entries updated. The Complete Example with Prisma block destructures fetchSize and uses it as the Prisma take.
  • createGridServerAction's maxFetchSize jsdoc — now references fetchSize end-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 — reads params.fetchSize and forwards to getMessagesAction.
    • apps/admin/.../sessions/grid/session-grid.tsx — computes startRow / endRow from params.fetchSize.
    • apps/admin/.../settings/_components/api-key-limits-manager.tsx — inline getActiveGridDataAction call uses fetchSize: 10 on the GridServerActionRequest.
    • apps/admin/.../test-server-grouping/grid/demo-fetch.ts and apps/admin/.../test-server-grid/grid/grid-utils.ts — demo fetchers read params.fetchSize for 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.tsx and the twelve apps/admin server-mode grids that use createServerFetchFn — no source-line change required because createServerFetchFn forwards 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 send fetchSize on every fetchFn call. Local variables that previously aliased the orchestrator's UI pagination.pageSize are untouched; only the field name on the outgoing ServerFetchParams changed.
  • __test-utils__/mock-fetch.ts and the integration test's local fetchFn mock read params.fetchSize. Two use-data-orchestrator.test.ts assertions (fetchSize: 40) were updated.
  • queryBuilder.buildRequest still accepts a pageSize field 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 passes pageSize: params.fetchSize to it explicitly.

Notes for consumers#

  • Mechanical migration: in your fetch callbacks, replace params.pageSize with params.fetchSize. If you hand-roll a GridServerActionRequest, 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 pageSize but 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-gridId mount that lands outside the React-Strict-Mode noise window (100ms) emits a warn-once log per gridId and points the consumer at useGridMutation / gridRef.current?.removeRow(id) / gridRef.current?.refetch(). The detector lives in src/utils/key-remount-detector.ts; the whole module collapses to a no-op under process.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: maxFetchSize below block size, client-mode addRow/removeRow no-op, and the remount detector. The pre-existing "Top valkuilen" entry for the remount anti-pattern was rewritten to point at useGridMutation first and to cross-reference the new diagnostics section.

Internal#

  • 8 unit tests in src/utils/key-remount-detector.test.ts cover the single-mount happy path, the missing-gridId no-op, the Strict-Mode noise window, the warn-once invariant across many remounts, gridId isolation, the test-only __resetKeyRemountTracker helper, and the production-mode no-op.
  • ActiveGrid (src/components/grid.tsx) calls detectKeyRemount once per mount through a dedicated useEffect([]).

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 useGridMutation in 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 when action resolves to {success: true}.
    • Strategies: removeRow (static rowId or function), addRow, refetch, resetSelection, none. onSuccess accepts 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 ActionError from @workspace/active-types survive result.success === false narrowing.
    • 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 grids documents useGridMutation as 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 local isLoading state for their mutation flow — they read mutation.isPending directly. Three archives grids (Records, Expectations, AdministrativePlaces, SourceTypeVocabulary) had a stale eslint-disable react-hooks/refs comment on their restore handler; the comment was removed because the migrated handler no longer reads gridRef.current during render.

Internal#

  • Em-dash sweep on three user-visible strings in src/i18n/defaults.ts and src/components/filter-builder/. Pre- existing no-restricted-syntax warnings flagged via PAGE_STANDARDS.
  • Em-dash removed from a logger.debug call in src/engine/core/grid/ constructGrid.ts. Pre-existing no-restricted-syntax warning.
  • Unused eslint-disable no-console directives in benchmarks/grid-benchmark.test.tsx, e2e/global-setup.ts, and src/engine/utils.ts removed.
  • lookup-grid.tsx cast's TData-generic renamed to _TData to silence @typescript-eslint/no-unused-vars outside the engine config override.
  • src/utils/logger.ts adds an explicit eslint-disable on LoggerOptions.data?: any because LogMeta is 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-any warnings 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/admin server-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 their fetchFn via createServerFetchFn from @workspace/active-grid directly. Each grid passes the t translation function to createServerFetchFn so the standard error-toast path picks up the consumer's locale.
  • apps/admin/app/(main)/users/grid/user-grid.tsx had an inline custom fetchFn calling getActiveGridDataAction directly with a manual transform step (added hasLocalPassword); it now uses createServerFetchFn's transform callback 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 + gridRef patterns 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 /server subpath 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, injected performAuthCheck, injected handleError). The maxFetchSize parameter replaces the previous hardcoded z.number().max(500) cap. Validation can be delegated to a consumer-supplied validateRequest callback so existing Zod + formatZodError translation paths stay intact.
  • createServerFetchFn (new client helper, exported from main index and from /client) — builds a ServerFetchFunction<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) => TRow callback for mapping raw Prisma rows to a flat grid-row shape inline.
  • useGridImperative<TData>(gridRef) — typed wrapper returning stable {refetch, removeRow, addRow, resetSelection} callbacks. Avoids the react-hooks/refs lint warning that fires when a ref-reading function is passed to a context-menu builder during render.
  • Dev-time warning for low maxFetchSize — createGridServerAction warns when maxFetchSize is 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 the data prop yourself (e.g. setData(prev => prev.filter(...))).

Internal#

  • Factory + client helper live in packages/active-grid/src/server/ and packages/active-grid/src/client/, exported under new /server and /client subpaths in package.json exports.

Notes for consumers#

  • The pageSize field on ServerFetchParams is the engine's block size (rows fetched per server call, default 200), not the UI page size. The two values are independent — the UI's pagination.pageSizeOptions controls visible-row chunks, while the engine groups multiple UI pages into a single fetch block for cache efficiency. This release keeps the name pageSize for 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's maxFetchSize warning.

[0.5.0] — 2026-05-17#

Added#

  • Column-meta emptyValueKey — per-column i18n-key for null/undefined values, resolved via t() at render time. Defaults to common.placeholder.notAvailable when not specified. Use this for column-specific empty-state semantics (e.g. "archives.route.columns.lastVerifiedAtEmpty" → "Nog niet geverifieerd"). The column's valueFormatter is 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 provides meta.filterOptions: [{label, value}], the cell renderer automatically derives a labelMap from those options and shows the human-readable label. Removes the need to duplicate the value→label mapping in a hand-written cell renderer. Single source of truth for enum-style columns.

Changed#

  • getRowId becomes required at the type level when TData lacks an id field. The new conditional type RequireGetRowIdIfMissingId<U, TData> in GridProps<TData> flips getRowId from optional to required when keyof TData is a concrete key-set that does not include "id". Generic / Record<string, any> positions remain permissive (bail-outs for string extends keyof TData and "id" extends keyof TData). Fixes a runtime-only crash that used to surface as ValidationError: [ActiveGrid] No "id" field found in row data. Provide a custom getRowId prop. Previously only caught at runtime; now caught by tsc --noEmit.

    Migration: any consumer that passes data with an alternate key (e.g. Provider.code) must now supply getRowId={(row) => row.code} explicitly. Consumers using entities with id: string are unaffected.

  • Null-value rendering moved upstream of valueFormatter in body-cell.tsx. Before this release, a valueFormatter that returned an i18n-key string (the date-formatter pattern) leaked raw keys into the DOM because nobody resolved them via t(). Body-cell now catches null/undefined explicitly and renders the resolved emptyValueKey — the formatter never sees null.

    Migration: none for consumers. Authors of custom valueFormatters that intentionally handled null themselves should switch to meta.emptyValueKey for that column.

Internal#

  • lookup-grid.tsx gets a local cast around ActiveGrid to bypass the conditional getRowId requirement at a generic-passthrough boundary. LookupGrid already exposes its own getRowId? 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.

This page as Markdown