Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
6f33ef4
docs: spec + plan for the filter subset rebuild
blove Aug 20, 2026
2266e93
feat(row-model): classify filter-only plan changes
blove Aug 20, 2026
33b2544
feat(row-model): synchronous subset rebuild for filter-only changes
blove Aug 20, 2026
2f852a5
feat(row-model): filter-only setQuery completes synchronously on flat…
blove Aug 20, 2026
280e6fd
fix(renderer-dom): scrolling during an active replacement keeps the s…
blove Aug 20, 2026
8d90c9d
docs: amendment G — renderer membership path + model levers
blove Aug 20, 2026
bef838c
feat(row-model): filter-only commits publish a refilter reset reason
blove Aug 20, 2026
f233b18
chore(core,docs): refilter reason through the public surface and guards
blove Aug 20, 2026
55b0282
feat(layout-core): synchronous refilter over existing height entries
blove Aug 20, 2026
4615547
feat(renderer-dom): filter commits refilter row heights instead of re…
blove Aug 20, 2026
3bcfa16
docs(website): the rebuild-progress demo rides a grouping change
blove Aug 20, 2026
cce8bd2
docs: spec for membership verdicts (H-cycle)
blove Aug 20, 2026
84a60e8
perf(row-model): add the filter-verdict membership seam
blove Aug 20, 2026
87885e2
perf(row-model): resolve filter verdicts from membership, not metadata
blove Aug 20, 2026
e8d208d
perf(row-model): filter verdicts resolve from root membership
blove Aug 20, 2026
44d8e07
docs(row-model): state the plan-coherence precondition at the grouped…
blove Aug 20, 2026
fb875f2
perf(row-model): derive byId and trust proven order in bulk tree builds
blove Aug 20, 2026
25dd13c
perf(row-model): derive byId only when removals beat a refill
blove Aug 20, 2026
ad14d16
perf(row-model): iterate order-statistic trees without generator dele…
blove Aug 21, 2026
86dee06
perf(row-model): filter-only changes adopt the previous plan's evalua…
blove Aug 21, 2026
978b196
docs: dense-handle core design spec (slots, bitsets, columnar evaluat…
blove Aug 24, 2026
d1b5e72
docs: M0 probe implementation plan
blove Aug 24, 2026
f8a3d9b
docs: M0 pricing probe results for the dense-handle core
blove Aug 24, 2026
8bbe709
docs: M0 results — record load sensitivity from the review re-run
blove Aug 24, 2026
be233d2
docs: M1+M2 implementation plan (slots + membership bitsets)
blove Aug 24, 2026
e55c111
docs: M1+M2 plan — slotCapacity defined in Task 5 where it belongs
blove Aug 24, 2026
72f95a4
feat(row-model): membership bitset primitive
blove Aug 24, 2026
f8d34c9
feat(row-model): per-model slot allocator
blove Aug 24, 2026
8ecbd2d
feat(row-model): chunked copy-on-write slot vector
blove Aug 24, 2026
4645f20
refactor(row-model): fail-loud slot-vector capacity, identity-based C…
blove Aug 24, 2026
61a0272
docs: M1+M2 plan tracks the chunksTouched rename
blove Aug 24, 2026
d44f967
feat(row-model): stamp lifetime slots on row records
blove Aug 24, 2026
5225605
test(row-model): pin buildRowStore slot carry and abandoned-draft rel…
blove Aug 24, 2026
fd86125
feat(row-model): per-revision recordsBySlot slot vector
blove Aug 24, 2026
3cc5bff
feat(row-model): per-revision visibleSlots membership bitset
blove Aug 24, 2026
a6902c4
perf(row-model): filter-only rebuild walks slots and diffs membership…
blove Aug 24, 2026
816709a
docs: M1+M2 measured results (slots + membership bitsets)
blove Aug 24, 2026
aa54826
docs: Amendment I — dense-identity layout seam design
blove Aug 24, 2026
66f085e
docs: dense layout seam implementation plan (Amendment I)
blove Aug 25, 2026
dd7f041
feat(layout-core): dense-membership primitive and dense-key type surface
blove Aug 25, 2026
79822ad
feat(layout-core): dense generations — builder ingest, bitset members…
blove Aug 25, 2026
3bfc50b
perf(layout-core): slot-indexed refilter and reorder for dense genera…
blove Aug 25, 2026
803324a
feat(row-model): internal dense snapshot reads for the renderer seam
blove Aug 25, 2026
1c70b96
perf(renderer-dom): dense-keyed layout sources, bulk visible walk, sl…
blove Aug 25, 2026
bb87202
test(react): dense layout seam end-to-end pins
blove Aug 25, 2026
a2c93d0
docs: dense layout seam measured results
blove Aug 25, 2026
ed7c734
docs: Amendment J — columnar verdict cache design
blove Aug 25, 2026
1946eb3
docs: columnar verdict cache implementation plan
blove Aug 25, 2026
5d9cbae
feat(row-model): thread slots into compiled-query inputs
blove Aug 25, 2026
fc7de56
docs: Amendment J freshness invariant revised — scan is the only writer
blove Aug 25, 2026
4ad8e6b
perf(row-model): compile filter predicates once per plan
blove Aug 25, 2026
8418ecb
feat(row-model): columnar filter-value cache with commit-side clears
blove Aug 25, 2026
f2d386f
docs: Amendment J §2 records the mutable-columnar storage decision
blove Aug 25, 2026
471cb35
perf(row-model): filter rebuild verdicts from columnar scan
blove Aug 25, 2026
64ace27
docs: columnar verdict cache measured results
blove Aug 25, 2026
088ae4c
perf(row-model): one-call verdict sweep over normalized columnar cells
blove Aug 25, 2026
a4b0252
revert(row-model): drop the columnar verdict store — measured flat twice
blove Aug 25, 2026
df852c7
docs: prettier-format the arc's spec documents
blove Aug 25, 2026
ebe67a8
fix(row-model): tsdoc cannot link-reference ɵ-prefixed members
blove Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/website/content/docs/headless/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ nav: Headless engine

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.

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

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

Expand Down
4 changes: 2 additions & 2 deletions apps/website/content/docs/headless/state-model.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ nav: Headless engine

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

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

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

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

`completedRows` and `totalRows` count the rebuild's **work units, not rows**.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ export function RebuildProgressDemo() {

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

const [filtered, setFiltered] = useState(false);
const [grouped, setGrouped] = useState(false);

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

return (
<div>
<button type="button" onClick={toggleFilter}>
{filtered
? `Show all ${ORDER_COUNT.toLocaleString()} orders again`
: `Filter ${ORDER_COUNT.toLocaleString()} orders to the west region`}
<button type="button" onClick={toggleGrouped}>
{grouped
? "Ungroup"
: `Group ${ORDER_COUNT.toLocaleString()} orders by region`}
</button>
<RebuildProgress rowModel={rowModel} />
<p style={{ fontSize: 13 }}>
Expand All @@ -72,14 +73,21 @@ export function RebuildProgressDemo() {
<tbody>
{snapshot
.range(0, Math.min(PREVIEW_ROWS, snapshot.visibleRowCount))
.filter((entry) => entry.kind === "data")
.map(({ rowId, row }) => (
<tr key={rowId}>
{columns.map((c) => (
<td key={c.id}>{String(c.accessor(row))}</td>
))}
</tr>
))}
.map((entry) =>
entry.kind === "data" ? (
<tr key={entry.rowId}>
{columns.map((c) => (
<td key={c.id}>{String(c.accessor(entry.row))}</td>
))}
</tr>
) : (
<tr key={entry.groupId}>
<td colSpan={columns.length}>
{String(entry.value)} ({entry.childCount})
</td>
</tr>
),
)}
</tbody>
</table>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,12 @@ describe("RebuildProgressDemo", () => {
});

fireEvent.click(
screen.getByRole("button", { name: /filter 150,000 orders/i }),
screen.getByRole("button", { name: /group 150,000 orders/i }),
);

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

// The grouping landed: every visible region group has surfaced as its
// own row (5 regions), distinct from the plain data rows.
const previewRows = screen.getAllByRole("row").slice(1);
expect(previewRows.length).toBeGreaterThan(0);
for (const row of previewRows) {
expect(row).toHaveTextContent("west");
}
const groupRows = previewRows.filter((row) =>
/\(\d+\)/.test(row.textContent ?? ""),
);
expect(groupRows.length).toBeGreaterThan(0);

// Group rows sit alongside the 150,000 data rows in the indexed
// count, so it goes up, not down, once grouping lands.
const rowsIndexedText = screen.getByText(/rows indexed/).textContent;
const indexedCount = Number(
rowsIndexedText
?.match(/^([\d,]+) rows indexed/)?.[1]
?.replace(/,/g, ""),
);
expect(indexedCount).toBeGreaterThan(150_000);
},
REBUILD_TIMEOUT + 5_000,
);

it(
"clears the filter cooperatively on the second click",
"ungroups cooperatively on the second click",
async () => {
render(<RebuildProgressDemo />);
await waitFor(() => screen.getByText(/150,000 rows indexed/), {
timeout: REBUILD_TIMEOUT,
});

fireEvent.click(
screen.getByRole("button", { name: /filter 150,000 orders/i }),
screen.getByRole("button", { name: /group 150,000 orders/i }),
);
await waitFor(
() => {
const status = screen.getByRole("status");
expect(status).toHaveTextContent("Ready.");
},
{ timeout: REBUILD_TIMEOUT },
);
await waitFor(() => screen.getByText(/30,000 rows indexed/), {
timeout: REBUILD_TIMEOUT,
});

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

fireEvent.click(
screen.getByRole("button", { name: /show all 150,000 orders/i }),
);
fireEvent.click(screen.getByRole("button", { name: /ungroup/i }));

await waitFor(
() => {
Expand All @@ -115,10 +128,17 @@ describe("RebuildProgressDemo", () => {
);

observer.disconnect();
// Removing a filter re-runs the same cooperative path over all
// Removing the grouping re-runs the same cooperative path over all
// 150,000 source rows, so the toggle demonstrates progress in both
// directions.
expect(sawRebuilding).toBe(true);

// Ungrouped: no group rows remain, only plain data rows.
const previewRows = screen.getAllByRole("row").slice(1);
const groupRows = previewRows.filter((row) =>
/\(\d+\)/.test(row.textContent ?? ""),
);
expect(groupRows.length).toBe(0);
},
REBUILD_TIMEOUT + 5_000,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export interface Order {
const REGIONS = ["north", "south", "east", "west", "central"];

// Deliberately large and deterministic (no Math.random): big enough that a
// filter change cannot settle inside one animation frame, so the rebuild
// grouping change cannot settle inside one animation frame, so the rebuild
// really does publish multiple `rebuilding` slices instead of jumping
// straight to `ready` — see the note on the smaller custom-renderer example.
export const ORDER_COUNT = 150_000;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { defineExample } from "../../../lib/docs/examples/define";
export default defineExample({
title: "Watching a rebuild",
description:
"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.",
"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.",
files: [
"RebuildProgressDemo.tsx",
"RebuildProgress.tsx",
Expand Down
Loading