Skip to content

Commit 6f2f2df

Browse files
bloveclaude
andauthored
perf: synchronous filter fast path on a dense-handle core (2.3× on 50k filter settle) (#487)
* docs: spec + plan for the filter subset rebuild Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(row-model): classify filter-only plan changes * feat(row-model): synchronous subset rebuild for filter-only changes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(row-model): filter-only setQuery completes synchronously on flat queries Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(renderer-dom): scrolling during an active replacement keeps the stale window visible Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: amendment G — renderer membership path + model levers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(row-model): filter-only commits publish a refilter reset reason Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(core,docs): refilter reason through the public surface and guards Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(layout-core): synchronous refilter over existing height entries Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(renderer-dom): filter commits refilter row heights instead of replacing Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(website): the rebuild-progress demo rides a grouping change * docs: spec for membership verdicts (H-cycle) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(row-model): add the filter-verdict membership seam One resolution helper per root shape: a flat root answers from its visible tree, a grouped root from group-index leaf membership. Nothing reads it yet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(row-model): resolve filter verdicts from membership, not metadata Every consumer of `CompiledRowMetadata.filterPasses` now asks the structure that owns the answer: the committed root's membership for OLD verdicts, a freshly computed verdict for NEW ones. The field is still written; nothing reads it. Behavior unchanged. `filterVerdict` gains a memo on the plan's existing evaluation cache entry, so a producer that evaluates a row and then asks for its verdict still costs one accessor pass — the per-row work budgets are pinned exact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(row-model): filter verdicts resolve from root membership `CompiledRowMetadata.filterPasses` and `CompiledAggregateLeaf.filteredLeaf` are deleted, and `refilterRecordMetadata` with them. A row's verdict is its membership in the root's visible structure — the flat visible tree, or the group index's leaf trees — and a NEW verdict is computed by the producer that places the row, never stored. The point of the exercise: a filter-only change now reconstructs NO record. The rows HAMT carries by identity exactly as the sort fast path's does, and a flip is expressed purely by where the row sits in the new visible tree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(row-model): state the plan-coherence precondition at the grouped seam Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(row-model): derive byId and trust proven order in bulk tree builds Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(row-model): derive byId only when removals beat a refill Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(row-model): iterate order-statistic trees without generator delegation `iterateEntries` walked the tree with `yield* iterateEntries(node.left)`, so every element leaving a leaf bubbled out through one generator frame per tree level — ~17 at 50,000 rows, ~850,000 resumptions to walk 50,000 entries. Measured in isolation at 50k: 30.04ms for the delegating shape, 1.77ms for an explicit-stack walk of the same tree. On the real 50k filter-metadata commit that one function body was the largest single frame in the profile. Rewrites both order-statistic-tree walks (persistent and transient) and the HAMT's walk in `persistent-map` as explicit-stack generators. The contract is unchanged: lazy, in-order, same yielded values, and the transient walk still checks the draft's liveness on first resumption and before every element. Each carries a comment with the measurement, because the delegating version is shorter and reads like the obvious simplification. Also reroutes the five callers that walk a tree to completion into an array — `filter-rebuild` (both), `sort-rebuild`, `row-store`, `create-local-row-model` — to the already-shipped `range(0, size)`, which does the same walk without suspending at all (1.05ms at 50k). The lazy callers are left alone deliberately: `transaction-draft`'s visible walk breaks on the first unaffected entry, and the cooperative-transition and distinct-values walks are iterators stepped across scheduler slices. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(row-model): filter-only changes adopt the previous plan's evaluation cache A filter-only plan change leaves every cached per-row field valid: rowId and sourceOrder are guard fields re-checked against the live input, groupPath and aggregateLeaves and sortKeys are functions of facets the classifier holds identical (groups, derivations including accessor identity, sort), and the row object is the map key. So the next plan takes the previous plan's whole evaluation cache BY REFERENCE — one assignment — instead of walking every row to refill a store with value-identical copies. The one filter-dependent field is the cache entry's verdict memo, which H1 left behind when it removed `filterPasses` from the metadata itself. It is now tagged with the plan that wrote it, so an adopting plan never reads a verdict its own filters did not produce and runs exactly the accessor pass it ran before. The tag costs one property write inside a write that already happens; there is no new per-row work anywhere. `sortKeyCarries` stops incrementing on this path — the walk it counted was a 100%-carry walk, i.e. precisely the redundant work removed — so `evaluationCacheAdoptions` pins the replacement instead. Measured at 50k rows (Node, four interleaved A/B rounds, load ~7-8): median settle 100.5ms -> 89.4ms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: dense-handle core design spec (slots, bitsets, columnar evaluation) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: M0 probe implementation plan Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: M0 pricing probe results for the dense-handle core Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: M0 results — record load sensitivity from the review re-run Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: M1+M2 implementation plan (slots + membership bitsets) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: M1+M2 plan — slotCapacity defined in Task 5 where it belongs Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(row-model): membership bitset primitive Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(row-model): per-model slot allocator Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(row-model): chunked copy-on-write slot vector Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(row-model): fail-loud slot-vector capacity, identity-based COW tracking Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: M1+M2 plan tracks the chunksTouched rename Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(row-model): stamp lifetime slots on row records Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(row-model): pin buildRowStore slot carry and abandoned-draft release paths Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(row-model): per-revision recordsBySlot slot vector Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(row-model): per-revision visibleSlots membership bitset Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * perf(row-model): filter-only rebuild walks slots and diffs membership bitsets Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: M1+M2 measured results (slots + membership bitsets) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: Amendment I — dense-identity layout seam design Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: dense layout seam implementation plan (Amendment I) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(layout-core): dense-membership primitive and dense-key type surface Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(layout-core): dense generations — builder ingest, bitset membership, guarded ops Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * perf(layout-core): slot-indexed refilter and reorder for dense generations Dense generations now run refilter and reorder on the slot lane: survivors resolve by denseKey against a slot-indexed array with zero identity strings (only entrants compute an identity, because measurements and tombstones stay string-keyed in both lanes — Amendment I §3), the duplicate check and next membership share one bitset exactly like the builder ingest, and measured leavers retire in OLD-SEQUENCE order so tombstone ticket assignment — which is observable through cap eviction — matches the string lane bit for bit. Both interim Task-2 throws are gone; their tests became real dense-path tests, the only sanctioned existing-test edits. Ride-alongs from the Task 2 review: retainMeasurement rejects malformed dense keys before the bit test (a fractional key's &31 truncation would read a different row's bit), apply's remove/move/update arms reject an operation whose denseKey drifted from the entry's stamped slot, and replace() documents its deliberate lane exit. Pins: a seeded lane-equivalence oracle (200 rows, 30 mixed steps) comparing geometry, retention, and lane-independent work counters after every step; the ticket-order/cap-eviction pin (slots deliberately anti-ordered vs the sequence); and the Amendment §3 slot-reuse trap (new identity on a reused slot ingests at estimate; the old measurement returns only for the old identity). Both pins verified by mutation: slot-ordered leavers fail the ticket pin; a slot-keyed measurement store fails the §3 trap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(row-model): internal dense snapshot reads for the renderer seam Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * perf(renderer-dom): dense-keyed layout sources, bulk visible walk, slot-pooled refs Task 5 of the dense layout seam (Amendment I): the row-layout controller now feeds layout-core's dense lane from the row model's internal slot seam. - replacementSourceOf declares denseCapacity and stamps every entry's denseKey when the snapshot supplies the ɵ slot seam; the visible set is materialized through chunked bulk range walks (one maxUnitsPerSlice-sized read per chunk, lazy) instead of per-row O(log n) rowAt descents. The string lane keeps the per-row rowAt shape verbatim — structural snapshot wrappers (react's bounded-read guards) legitimately refuse wide range spans, and only ɵ-supplying real model snapshots take the bulk walk. - Incremental change operations and prepareWindow estimate updates are slot-stamped (before-first resolution so removes still resolve); staged measurement replay passes the slot to retainMeasurement, and a staged measurement whose row was permanently removed drops that one generation to the string lane (the amendment's wholesale escape hatch) so identity-keyed retention survives — pinned by the existing removed-then-reinserted test. - Data-row refs are pooled by slot: one frozen ref per bound (slot, rowId), reused across sources and window publications. Verified: every ref comparison goes through identityOf/sameRef, none by allocation identity. - Dense-contract refusals keep the honest fallback signals: refilter and reorder dispatch throws land in refilterFallbackCount and reorderFallbackCount; apply-path throws restart via the existing replacementStartCount-observable convention. Ride-along (comments only, from the Task 3 quality review): the string lane-pin note at both dense dispatches, the trust-boundary note in both dense docblocks, and cross-references between the lane-equivalence oracle and the leaver ticket-order pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(react): dense layout seam end-to-end pins An end-to-end react pin drives a 200-row grid through filter-on -> narrow -> widen -> filter-off and asserts the DENSE refilter path ran (refilterPathCount +4, refilterFallbackCount 0), the mounted index is actually dense (layout-core's unkeyed-op refusal as a lane probe), and a measured row's height survives a flip-out/flip-in. Mutation-checked: unstamping denseKey in the dense source fails the probe; a string-lane refilter source fails the counters. Ride-along hardening: a DENSE build whose chunked source read throws (a spread-based snapshot wrapper carrying the seam with its own bounded-read guard) now takes the amendment's string-lane escape hatch for one generation instead of the generic failure path; the next full replacement re-decides dense. Pinned with a bounded-restart test. API reports regenerated: the three optional @internal slot-seam members on PretableRowModelSnapshot, mirrored in core and react — nothing else. Docs api-surface guard green with no table updates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: dense layout seam measured results Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: Amendment J — columnar verdict cache design Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: columnar verdict cache implementation plan Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(row-model): thread slots into compiled-query inputs Add `readonly slot: number` to CompiledRowInput (Amendment J §1, dense handle for columnar cells; unread this task). Thread the field through every production call site: cooperative-transition.ts stamps it from the record, transaction-draft.ts's createRecord already took a slot param, and rebuildRowStoreForQuery (dead code, zero callers) gets it mechanically from the carried record. buildRowStore and replaceFlatRowsDraft need a slot before evaluate() runs (evaluate can throw via a user accessor), but their real slot for a brand-new row is only resolved by input.slots.allocate() AFTER evaluate succeeds — allocating earlier would leak allocator capacity (a monotonic high-water mark; release() cannot undo it) on a throwing accessor. Both keep that exact original ordering/side effects and pass a harmless placeholder (-1, or the carried previous slot when known) into evaluate()'s input only, since nothing reads the field yet. Test fixtures across 6 row-model test files and one react test file needed `slot` added to CompiledRowInput-shaped literals; no assertions changed. CompiledRowInput does not appear in any governed public package (core/react/ui/stream-adapter) or their .api.md reports — row-model is `"private": true` and outside the `pnpm api` gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: Amendment J freshness invariant revised — scan is the only writer Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * perf(row-model): compile filter predicates once per plan evaluateFilter's per-row operator dispatch becomes compileFilterPredicate: one monomorphic (value) => boolean closure per runtime filter, built at plan construction with operand normalization hoisted (between bounds min/maxed once, date operands collapsed to UTC day-ms once, text needles lowercased once, selection operands coerced into a Set once). Predicate semantics now exist exactly once, in the compile step; #filterVerdict walks the construction-time #compiledPredicates array parallel to #runtimeQuery.filters — no #byId lookup per row. Exhaustive pinned-literal sweep over all 31 (column type, operator) pairs in FILTER_OPERATORS, boundary inclusivity included; mutation- checked (exclusive lower between bound fails exactly the boundary cases). Full suite 602 green, zero existing-test edits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(row-model): columnar filter-value cache with commit-side clears Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: Amendment J §2 records the mutable-columnar storage decision Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * perf(row-model): filter rebuild verdicts from columnar scan The filter-only rebuild's O(n) walk now takes each row's verdict from bulkFilterVerdictScan: per filter, in filter order, the columnar cell for (column, slot) answers when present, and a hole falls back to the live accessor through the shared #readColumnValue seam AND writes through — the store's only writer (Amendment J §3 revised). One-pass-per-slot for cell locality; short-circuit exactly like the per-row .every, so a failing row may leave later filters' cells unfilled (holes refill lazily). The per-row filterVerdict stays untouched for k-sized and grouped callers. Also closes the Task 3 review's stale-cell laundering hole: setDerivations' plan-REUSE branch now resets the columnar store, because derivationsEqualForPlan ignores UNREFERENCED columns' accessors and one intermediate filter-only adoption would otherwise put a new-accessor plan on a store still holding the old accessor's cells. Work counters: columnarVerdictScans (one per rebuild) and columnarCellFills (per hole filled); a second filter-only commit on unchanged data is pinned at ZERO fills. New tests: seeded randomized columnar-vs-per-row equivalence oracle (updates, slot reuse, setRows, newly-referenced column mid-script), the laundering sequence, and accessor-failed shape parity between scan and per-row paths. All four covering mutations verified red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: columnar verdict cache measured results Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * perf(row-model): one-call verdict sweep over normalized columnar cells The filter rebuild's verdict pass is now ONE bulkFilterVerdictSweep call per rebuild (walk, plan resolution, predicate/column/vector hoisting all inside the plan; assert-free trusted cell reads over walk-produced slots), and the columnar store caches SCAN-NORMALIZED cells (text lowercased, dates as day-ms, enum/boolean coerced) filled once, with normalized predicate twins compiled per plan. isEmpty/isNotEmpty stay on raw accessor reads — emptiness is a raw-value property the normalized forms lose. Re-measured paired at 50k: STILL FLAT (+0.9/+0.4ms medians, controls in band); traced verdict share ~15.4% vs ~17.1%. The bench scripts' single cold-store commit makes the fill the measured interaction, so the warm-path win (zero-fill repeat commits, test-pinned) is invisible to the settle metric. Details appended to the results doc. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * revert(row-model): drop the columnar verdict store — measured flat twice Keeps compiled predicates and slot threading. See docs/superpowers/specs/2026-08-24-columnar-verdicts-results.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: prettier-format the arc's spec documents Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(row-model): tsdoc cannot link-reference ɵ-prefixed members api-extractor's declaration-reference parser rejects the ɵ start; the api:check gate treats the warning as an error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 693f01e commit 6f2f2df

77 files changed

Lines changed: 13870 additions & 687 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/website/content/docs/headless/getting-started.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ nav: Headless engine
66

77
A headless renderer starts with `createLocalRowModel`. Add `createGrid` only when your renderer needs UI state. The grid below is exactly that: 75 services rendered from a plain `<table>`, with `createLocalRowModel` driving sort and filter and `createGrid` driving row selection.
88

9-
`setQuery` — triggered here by typing into the filter — does not settle synchronously. The model rebuilds cooperatively, yielding between slices so a large query cannot block the frame, and publishes progress and failures through `status`. (Sorting a column is the exception: a sort-only change on ungrouped data re-orders rows the model has already indexed, so it settles synchronously.) Two things follow, and the example does both: **select** what you subscribe to, or you re-render on every slice, and **read `status`**, or a rebuild that fails leaves stale rows on screen with nothing to say so.
9+
`setQuery` does not settle synchronously in the general case — the model rebuilds cooperatively, yielding between slices so a large query cannot block the frame, and publishes progress and failures through `status`. Sort-only and filter-only changes on ungrouped data are the exception, including typing into the filter here: each re-orders or re-selects rows the model has already indexed, so it settles synchronously with no `rebuilding` phase. Grouped and mixed changes still rebuild cooperatively — see [Snapshot & subscribe](/docs/headless/state-model) for a demo. Two things are still worth doing, and the example does both: **select** what you subscribe to, so a cooperative rebuild elsewhere doesn't re-render you on every slice, and **read `status`**, so a failed rebuild doesn't leave stale rows on screen with nothing to say so.
1010

1111
<Example id="headless-custom-renderer" />
1212

apps/website/content/docs/headless/state-model.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ nav: Headless engine
66

77
The row model and UI grid are independent observable stores. Subscribe only to the state your renderer uses.
88

9-
A `setQuery` that changes the filter does not settle synchronously — the model rebuilds cooperatively, yielding between slices so a large query cannot block the frame. A sort-only change on ungrouped data is the one exception: it re-orders rows the model has already indexed, so it settles synchronously and never publishes a `rebuilding` phase — a plain sort needs no progress UI. On a small dataset even the cooperative rebuild is over before a human (or React) can see it happen, which is why the button below filters 150,000 rows instead of 75: watch `status` cycle through `rebuilding` with a live percentage, then settle back to `ready`.
9+
A `setQuery` that changes row grouping does not settle synchronously — the model rebuilds cooperatively, yielding between slices so a large query cannot block the frame. Sort-only and filter-only changes on ungrouped data are the exception: each re-orders or re-selects rows the model has already indexed, so it settles synchronously and never publishes a `rebuilding` phase — plain sorting and filtering need no progress UI. Grouped and mixed changes still rebuild cooperatively. On a small dataset even the cooperative rebuild is over before a human (or React) can see it happen, which is why the button below groups 150,000 rows instead of 75: watch `status` cycle through `rebuilding` with a live percentage, then settle back to `ready`.
1010

1111
<Example id="headless-rebuild-progress" />
1212

@@ -43,7 +43,7 @@ committed, and mutations keep committing into it meanwhile: `setRows`,
4343
`applyTransaction` and both expansion paths publish a new snapshot while a
4444
rebuild runs. A renderer that stops re-reading the snapshot during a rebuild
4545
drops those, which is exactly the streaming-plus-filter case. The table in the
46-
example above stays on the last committed filter result throughout, exactly
46+
example above stays on the last committed result throughout, exactly
4747
like this.
4848

4949
`completedRows` and `totalRows` count the rebuild's **work units, not rows**.

apps/website/content/examples/headless-rebuild-progress/RebuildProgressDemo.tsx

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ export function RebuildProgressDemo() {
1919

2020
// Selecting `snapshot` (not the whole state) means this component bails
2121
// out on identity between rebuild slices — it only renders once, when the
22-
// filter actually lands. `RebuildProgress` above is the one re-rendering on
23-
// every slice in the meantime.
22+
// grouping change actually lands. `RebuildProgress` above is the one
23+
// re-rendering on every slice in the meantime.
2424
const readSnapshot = useCallback(
2525
() => rowModel.getState().snapshot,
2626
[rowModel],
@@ -31,28 +31,29 @@ export function RebuildProgressDemo() {
3131
readSnapshot,
3232
);
3333

34-
const [filtered, setFiltered] = useState(false);
34+
const [grouped, setGrouped] = useState(false);
3535

36-
// A FILTER change, not a sort: a sort-only change on ungrouped data
37-
// settles synchronously and never publishes a `rebuilding` phase, so it
38-
// could not demonstrate the progress readout at all.
39-
const toggleFilter = () => {
40-
const next = !filtered;
41-
setFiltered(next);
36+
// A GROUPING change, not a filter or a sort: both of those settle
37+
// synchronously on ungrouped data (the sort fast path and the filter fast
38+
// path each require `rowGroups.length === 0`), so neither could
39+
// demonstrate the progress readout anymore. Grouping never takes a fast
40+
// path — it always rebuilds cooperatively — which is exactly why it is the
41+
// vehicle here.
42+
const toggleGrouped = () => {
43+
const next = !grouped;
44+
setGrouped(next);
4245
rowModel.setQuery({
4346
...snapshot.query,
44-
filters: next
45-
? [{ columnId: "region", operator: "equals", value: "west" }]
46-
: [],
47+
rowGroups: next ? [{ columnId: "region" }] : [],
4748
});
4849
};
4950

5051
return (
5152
<div>
52-
<button type="button" onClick={toggleFilter}>
53-
{filtered
54-
? `Show all ${ORDER_COUNT.toLocaleString()} orders again`
55-
: `Filter ${ORDER_COUNT.toLocaleString()} orders to the west region`}
53+
<button type="button" onClick={toggleGrouped}>
54+
{grouped
55+
? "Ungroup"
56+
: `Group ${ORDER_COUNT.toLocaleString()} orders by region`}
5657
</button>
5758
<RebuildProgress rowModel={rowModel} />
5859
<p style={{ fontSize: 13 }}>
@@ -72,14 +73,21 @@ export function RebuildProgressDemo() {
7273
<tbody>
7374
{snapshot
7475
.range(0, Math.min(PREVIEW_ROWS, snapshot.visibleRowCount))
75-
.filter((entry) => entry.kind === "data")
76-
.map(({ rowId, row }) => (
77-
<tr key={rowId}>
78-
{columns.map((c) => (
79-
<td key={c.id}>{String(c.accessor(row))}</td>
80-
))}
81-
</tr>
82-
))}
76+
.map((entry) =>
77+
entry.kind === "data" ? (
78+
<tr key={entry.rowId}>
79+
{columns.map((c) => (
80+
<td key={c.id}>{String(c.accessor(entry.row))}</td>
81+
))}
82+
</tr>
83+
) : (
84+
<tr key={entry.groupId}>
85+
<td colSpan={columns.length}>
86+
{String(entry.value)} ({entry.childCount})
87+
</td>
88+
</tr>
89+
),
90+
)}
8391
</tbody>
8492
</table>
8593
</div>

apps/website/content/examples/headless-rebuild-progress/__tests__/RebuildProgressDemo.test.tsx

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,12 @@ describe("RebuildProgressDemo", () => {
4343
});
4444

4545
fireEvent.click(
46-
screen.getByRole("button", { name: /filter 150,000 orders/i }),
46+
screen.getByRole("button", { name: /group 150,000 orders/i }),
4747
);
4848

4949
await waitFor(
5050
() => {
5151
expect(status).toHaveTextContent("Ready.");
52-
// The filter landed: only the 30,000 west-region orders survive,
53-
// and every preview row is one of them.
54-
expect(screen.getByText(/30,000 rows indexed/)).toBeInTheDocument();
5552
},
5653
{ timeout: REBUILD_TIMEOUT },
5754
);
@@ -60,34 +57,52 @@ describe("RebuildProgressDemo", () => {
6057
// Proves the rebuild actually published at least one intermediate
6158
// `rebuilding` slice before landing on `ready` — the whole reason this
6259
// example exists. On the small 75-row custom-renderer example this
63-
// would be a coin flip; at 150,000 rows it is not. A sort-only change
64-
// could never pass this: on ungrouped data it settles synchronously
65-
// with no `rebuilding` phase at all.
60+
// would be a coin flip; at 150,000 rows it is not. A sort-only or
61+
// filter-only change could never pass this: on ungrouped data both
62+
// settle synchronously with no `rebuilding` phase at all. Grouping is
63+
// the one change vehicle that is cooperative by design, not omission.
6664
expect(sawRebuilding).toBe(true);
6765

66+
// The grouping landed: every visible region group has surfaced as its
67+
// own row (5 regions), distinct from the plain data rows.
6868
const previewRows = screen.getAllByRole("row").slice(1);
6969
expect(previewRows.length).toBeGreaterThan(0);
70-
for (const row of previewRows) {
71-
expect(row).toHaveTextContent("west");
72-
}
70+
const groupRows = previewRows.filter((row) =>
71+
/\(\d+\)/.test(row.textContent ?? ""),
72+
);
73+
expect(groupRows.length).toBeGreaterThan(0);
74+
75+
// Group rows sit alongside the 150,000 data rows in the indexed
76+
// count, so it goes up, not down, once grouping lands.
77+
const rowsIndexedText = screen.getByText(/rows indexed/).textContent;
78+
const indexedCount = Number(
79+
rowsIndexedText
80+
?.match(/^([\d,]+) rows indexed/)?.[1]
81+
?.replace(/,/g, ""),
82+
);
83+
expect(indexedCount).toBeGreaterThan(150_000);
7384
},
7485
REBUILD_TIMEOUT + 5_000,
7586
);
7687

7788
it(
78-
"clears the filter cooperatively on the second click",
89+
"ungroups cooperatively on the second click",
7990
async () => {
8091
render(<RebuildProgressDemo />);
8192
await waitFor(() => screen.getByText(/150,000 rows indexed/), {
8293
timeout: REBUILD_TIMEOUT,
8394
});
8495

8596
fireEvent.click(
86-
screen.getByRole("button", { name: /filter 150,000 orders/i }),
97+
screen.getByRole("button", { name: /group 150,000 orders/i }),
98+
);
99+
await waitFor(
100+
() => {
101+
const status = screen.getByRole("status");
102+
expect(status).toHaveTextContent("Ready.");
103+
},
104+
{ timeout: REBUILD_TIMEOUT },
87105
);
88-
await waitFor(() => screen.getByText(/30,000 rows indexed/), {
89-
timeout: REBUILD_TIMEOUT,
90-
});
91106

92107
let sawRebuilding = false;
93108
const status = screen.getByRole("status");
@@ -102,9 +117,7 @@ describe("RebuildProgressDemo", () => {
102117
subtree: true,
103118
});
104119

105-
fireEvent.click(
106-
screen.getByRole("button", { name: /show all 150,000 orders/i }),
107-
);
120+
fireEvent.click(screen.getByRole("button", { name: /ungroup/i }));
108121

109122
await waitFor(
110123
() => {
@@ -115,10 +128,17 @@ describe("RebuildProgressDemo", () => {
115128
);
116129

117130
observer.disconnect();
118-
// Removing a filter re-runs the same cooperative path over all
131+
// Removing the grouping re-runs the same cooperative path over all
119132
// 150,000 source rows, so the toggle demonstrates progress in both
120133
// directions.
121134
expect(sawRebuilding).toBe(true);
135+
136+
// Ungrouped: no group rows remain, only plain data rows.
137+
const previewRows = screen.getAllByRole("row").slice(1);
138+
const groupRows = previewRows.filter((row) =>
139+
/\(\d+\)/.test(row.textContent ?? ""),
140+
);
141+
expect(groupRows.length).toBe(0);
122142
},
123143
REBUILD_TIMEOUT + 5_000,
124144
);

apps/website/content/examples/headless-rebuild-progress/data.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export interface Order {
88
const REGIONS = ["north", "south", "east", "west", "central"];
99

1010
// Deliberately large and deterministic (no Math.random): big enough that a
11-
// filter change cannot settle inside one animation frame, so the rebuild
11+
// grouping change cannot settle inside one animation frame, so the rebuild
1212
// really does publish multiple `rebuilding` slices instead of jumping
1313
// straight to `ready` — see the note on the smaller custom-renderer example.
1414
export const ORDER_COUNT = 150_000;

apps/website/content/examples/headless-rebuild-progress/example.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { defineExample } from "../../../lib/docs/examples/define";
33
export default defineExample({
44
title: "Watching a rebuild",
55
description:
6-
"Filtering 150,000 rows cannot settle inside one animation frame, so status.kind cycles through rebuilding with a live completedRows/totalRows progress readout before returning to ready.",
6+
"Grouping 150,000 rows cannot settle inside one animation frame, so status.kind cycles through rebuilding with a live completedRows/totalRows progress readout before returning to ready.",
77
files: [
88
"RebuildProgressDemo.tsx",
99
"RebuildProgress.tsx",

0 commit comments

Comments
 (0)