Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3917b3f
docs: spec for #457 sort fast path (delta classifier + synchronous re…
blove Aug 17, 2026
53c183d
docs: implementation plan for the #457 sort fast path (+ spec amendme…
blove Aug 17, 2026
ae49054
feat(row-model): classify query deltas between compiled plans
blove Aug 17, 2026
7e7a694
feat(row-model): O(n) balanced tree construction from sorted entries
blove Aug 17, 2026
08a3c0d
feat(row-model): rebuild row metadata by carryover for sort-only plan…
blove Aug 17, 2026
9e55a13
feat(row-model): synchronous whole-root rebuild for sort-only changes
blove Aug 17, 2026
43894dd
feat(row-model): sort-only setQuery completes synchronously on flat q…
blove Aug 17, 2026
a98abd8
docs(bench): note the sort fast path's designed exemption from the sl…
blove Aug 17, 2026
f422ef8
docs(website): rebuild-progress demo rides a filter change; flat sort…
blove Aug 17, 2026
1140a5c
docs: spec for sort-key ownership + reorder signal (#457 cycle 2)
blove Aug 18, 2026
2e48733
docs: implementation plan for sort-key ownership + reorder signal
blove Aug 18, 2026
e747f99
feat(row-model): plan-owned sort-key store and record-based comparator
blove Aug 18, 2026
d6fd476
refactor(row-model): resolve sort keys through the plan store everywhere
blove Aug 18, 2026
0275400
feat(row-model): sort-only changes carry records and the rows map by …
blove Aug 18, 2026
6a892a5
perf(row-model): sort-only rebuild sorts with pre-resolved keys
blove Aug 18, 2026
b112650
feat(row-model): sort-only commits publish a reorder reset reason
blove Aug 18, 2026
9a34d65
chore: format drift sweep
blove Aug 18, 2026
5115004
chore(core): reorder reset reason through the public surface, with a …
blove Aug 18, 2026
a11b1a2
feat(layout-core): synchronous reorder over existing height entries
blove Aug 18, 2026
ea220a7
feat(renderer-dom): sort-only commits permute row heights instead of …
blove Aug 18, 2026
9fdfa2b
docs: spec for decorated entries + bulk mount (#457 cycle 3)
blove Aug 18, 2026
a60f530
docs: implementation plan for decorated entries + bulk mount
blove Aug 18, 2026
45d4059
perf(row-model): tree entries carry their sort keys
blove Aug 18, 2026
4568342
perf(layout-core,renderer-dom): replacements over unmeasured state bu…
blove Aug 18, 2026
73eecdf
feat(renderer-dom): reorders compose into active replacements at finish
blove Aug 18, 2026
4fae9b8
chore: format grid-core reorder-reset pin test
blove Aug 19, 2026
396ec3b
perf(row-model): one per-row WeakMap, one rehash in the worst slice
blove Aug 19, 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 sorting a column or 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`. 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` — 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.

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

Expand Down
5 changes: 3 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.

`setQuery` does not settle synchronously — the model rebuilds cooperatively, yielding between slices so a large query cannot block the frame. On a small dataset that's over before a human (or React) can see it happen, which is why the button below sorts 150,000 rows instead of 75: watch `status` cycle through `rebuilding` with a live percentage, then settle back to `ready`.
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`.

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

Expand Down Expand Up @@ -43,7 +43,8 @@ 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 sort throughout, exactly like this.
example above stays on the last committed filter result throughout, exactly
like this.

`completedRows` and `totalRows` count the rebuild's **work units, not rows**.
Grouping and concurrent mutations add units as the transition runs, so
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function RebuildProgress<
const label = progressText.startsWith("rebuilding:")
? `Rebuilding… ${progressText.slice("rebuilding:".length)}%`
: progressText === "ready"
? "Sorted."
? "Ready."
: progressText;

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ 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
// sort actually lands. `RebuildProgress` above is the one re-rendering on
// filter actually lands. `RebuildProgress` above is the one re-rendering on
// every slice in the meantime.
const readSnapshot = useCallback(
() => rowModel.getState().snapshot,
Expand All @@ -31,22 +31,28 @@ export function RebuildProgressDemo() {
readSnapshot,
);

const [descending, setDescending] = useState(true);
const [filtered, setFiltered] = useState(false);

const resort = () => {
const next = !descending;
setDescending(next);
// 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);
rowModel.setQuery({
...snapshot.query,
sort: [{ columnId: "amount", direction: next ? "desc" : "asc" }],
filters: next
? [{ columnId: "region", operator: "equals", value: "west" }]
: [],
});
};

return (
<div>
<button type="button" onClick={resort}>
Sort {ORDER_COUNT.toLocaleString()} orders by amount,{" "}
{descending ? "ascending" : "descending"}
<button type="button" onClick={toggleFilter}>
{filtered
? `Show all ${ORDER_COUNT.toLocaleString()} orders again`
: `Filter ${ORDER_COUNT.toLocaleString()} orders to the west region`}
</button>
<RebuildProgress rowModel={rowModel} />
<p style={{ fontSize: 13 }}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,15 @@ describe("RebuildProgressDemo", () => {
});

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

await waitFor(
() => {
expect(status).toHaveTextContent("Sorted.");
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 @@ -57,7 +60,64 @@ 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.
// 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.
expect(sawRebuilding).toBe(true);

const previewRows = screen.getAllByRole("row").slice(1);
expect(previewRows.length).toBeGreaterThan(0);
for (const row of previewRows) {
expect(row).toHaveTextContent("west");
}
},
REBUILD_TIMEOUT + 5_000,
);

it(
"clears the filter 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 }),
);
await waitFor(() => screen.getByText(/30,000 rows indexed/), {
timeout: REBUILD_TIMEOUT,
});

let sawRebuilding = false;
const status = screen.getByRole("status");
const observer = new MutationObserver(() => {
if (/Rebuilding…/.test(status.textContent ?? "")) {
sawRebuilding = true;
}
});
observer.observe(status, {
childList: true,
characterData: true,
subtree: true,
});

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

await waitFor(
() => {
expect(status).toHaveTextContent("Ready.");
expect(screen.getByText(/150,000 rows indexed/)).toBeInTheDocument();
},
{ timeout: REBUILD_TIMEOUT },
);

observer.disconnect();
// Removing a filter re-runs the same cooperative path over all
// 150,000 source rows, so the toggle demonstrates progress in both
// directions.
expect(sawRebuilding).toBe(true);
},
REBUILD_TIMEOUT + 5_000,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ export interface Order {

const REGIONS = ["north", "south", "east", "west", "central"];

// Deliberately large and deterministic (no Math.random): big enough that
// `setQuery` 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.
// Deliberately large and deterministic (no Math.random): big enough that a
// filter 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;

export const orders: Order[] = Array.from({ length: ORDER_COUNT }, (_, i) => ({
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:
"Re-sorting 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.",
"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.",
files: [
"RebuildProgressDemo.tsx",
"RebuildProgress.tsx",
Expand Down
Loading