Skip to content

Commit 96ecb33

Browse files
bloveclaude
andauthored
perf(renderer-dom): grouping apply pays one height-index replacement, not three (#522)
* perf(renderer-dom): a column change stops re-ingesting the height index A grouping apply paid three full-set cooperative height-index replacements at 50k rows (~0.6s before READY): the engine's reset commit, then two setColumns restarts — the group-column roster commit and the merged-width commit a render later — each cancelling the previous build to re-ingest the same 50,004 rows. Traced in headed Chromium with cause-tagged starts. The replacement source never reads columns; only the estimator does, and estimates are applied at publish off the live layoutColumns. So setColumns now absorbs the change in place: idle controllers clear estimates synchronously via the new layout-core RowHeightIndex.clearEstimates (measurements survive — they are DOM facts, not arithmetic) and republish; during an active replacement the live columns update and the in-flight build's own finishing publish honors them. New diagnostics counters (columnsResetPathCount / columnsResetFallbackCount) follow the reorder/refilter pattern, fallback included. Pinned end to end: a react grouping apply costs exactly ONE height-index replacement (was two in jsdom, three in a browser); the columns reset keeps measurements, clears offscreen estimates, and keeps the dense lane dense. All pins mutation-verified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(renderer-dom): the spacer anchor test's comment names the real mechanism setColumns no longer runs a cooperative replacement; the anchor here is restored through the columns reset's restoreAnchorRequest. The assertions were already mechanism-agnostic and unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: review follow-ups — the columns-reset fallback fires, and a data cell paints 1. The columns-reset fallback now has its firing pinned, like the sibling reorder/refilter fallbacks: an estimator that vetoes the first publish under the new columns advances columnsResetFallbackCount, starts a full replacement instead of an error state, and the recovery keeps the measurement. Mutation-verified (swallowing the increment reddens it). 2. The grouping-apply pin additionally asserts a known data row's cell content is in the DOM — blank-grid insurance against the counts-agree-nothing-paints defect class. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent dab7d33 commit 96ecb33

9 files changed

Lines changed: 655 additions & 45 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@pretable-internal/layout-core": patch
3+
"@pretable-internal/renderer-dom": patch
4+
---
5+
6+
A column change no longer re-ingests the whole height index.
7+
8+
`setColumns` changes only the estimator's inputs — the height-index
9+
replacement source never reads columns — yet it restarted a full cooperative
10+
replacement, which made one grouping apply cost three full-set height-index
11+
passes at 50k rows (the engine's reset, then the group-column roster commit
12+
and the merged-width commit, each cancelling the build before it). The
13+
controller now absorbs a column change in place: on an idle controller it
14+
clears estimates synchronously (`RowHeightIndex.clearEstimates`, new in
15+
layout-core — measurements survive; they are DOM facts, not arithmetic) and
16+
republishes, and during an active replacement it just updates the live
17+
columns, which the in-flight build's finishing publish already reads. A
18+
grouping apply now costs exactly one height-index replacement, test-pinned
19+
end to end.

packages/layout-core/src/__tests__/row-height-index.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2785,3 +2785,98 @@ describe("dense refilter and reorder (Amendment I, Task 3)", () => {
27852785
expect(withXBack.getHeight(2)).toBe(25);
27862786
});
27872787
});
2788+
2789+
describe("clearEstimates", () => {
2790+
test("drops estimates to the default height, keeps measurements, and leaves totals honest", () => {
2791+
const a = data("a");
2792+
const b = data("b");
2793+
const c = data("c");
2794+
const base = createIndex(
2795+
[entry(a, 50), entry(b), entry(c, 70)],
2796+
30,
2797+
).measure(2, c, 66);
2798+
expect(base.getTotalHeight()).toBe(50 + 30 + 66);
2799+
2800+
const cleared = base.clearEstimates();
2801+
// Estimated, unmeasured rows return to the default height...
2802+
expect(cleared.getHeight(0)).toBe(30);
2803+
expect(cleared.getHeight(1)).toBe(30);
2804+
// ...while a measured row keeps the height the DOM reported — the
2805+
// measurement is a fact, not arithmetic, and must survive.
2806+
expect(cleared.getHeight(2)).toBe(66);
2807+
expect(cleared.hasMeasurement(c)).toBe(true);
2808+
expect(cleared.getTotalHeight()).toBe(30 + 30 + 66);
2809+
// A later re-estimate is accepted (the estimatedHeight slot really is
2810+
// empty now — an update carrying the SAME estimate as before must not
2811+
// no-op away).
2812+
const reEstimated = cleared.apply([
2813+
{ kind: "update", ref: a, index: 0, estimatedHeight: 50 },
2814+
]);
2815+
expect(reEstimated.getHeight(0)).toBe(50);
2816+
});
2817+
2818+
test("is an identity no-op when nothing is estimated", () => {
2819+
const a = data("a");
2820+
const b = data("b");
2821+
const base = createIndex([entry(a), entry(b)], 24).measure(1, b, 40);
2822+
expect(base.clearEstimates()).toBe(base);
2823+
const empty = createIndex([], 24);
2824+
expect(empty.clearEstimates()).toBe(empty);
2825+
});
2826+
2827+
test("preserves retained (tombstoned) measurements and the retention order", () => {
2828+
const a = data("a");
2829+
const gone = data("gone");
2830+
const base = createIndex([entry(a, 20), entry(gone)], 30)
2831+
.measure(1, gone, 55)
2832+
.apply([{ kind: "remove", ref: gone, previousIndex: 1 }]);
2833+
expect(base.hasRetainedState).toBe(true);
2834+
2835+
const cleared = base.clearEstimates();
2836+
expect(cleared.hasRetainedState).toBe(true);
2837+
// The removed row's measurement still restores through a membership
2838+
// change after the clear.
2839+
const source: RowHeightReplacementSource<Key> = {
2840+
rowCount: 2,
2841+
entryAt: (index) => [entry(a), entry(gone)][index]!,
2842+
};
2843+
const widened = cleared.refilter(source);
2844+
expect(widened.getHeight(1)).toBe(55);
2845+
});
2846+
2847+
test("keeps the dense lane dense", () => {
2848+
const denseEntry = (
2849+
key: Key,
2850+
denseKey: number,
2851+
estimatedHeight?: number,
2852+
): RowHeightEntry<Key> => ({ key, estimatedHeight, denseKey });
2853+
const rows = [denseEntry(data("a"), 0, 20), denseEntry(data("b"), 3, 40)];
2854+
const builder = createIndex([]).beginReplacement({
2855+
rowCount: rows.length,
2856+
denseCapacity: 8,
2857+
entryAt: (index) => rows[index]!,
2858+
});
2859+
while (!builder.done) builder.advance({ maxUnits: 256 });
2860+
const dense = builder.finish();
2861+
2862+
const cleared = dense.clearEstimates();
2863+
expect(cleared.getHeight(0)).toBe(30);
2864+
expect(cleared.getHeight(1)).toBe(30);
2865+
// Still a DENSE generation: an op without a denseKey is refused.
2866+
expect(() =>
2867+
cleared.apply([{ kind: "update", ref: data("a"), index: 0 }]),
2868+
).toThrow(/dense/i);
2869+
// And the slot bitset still answers: a duplicate slot insert is refused.
2870+
expect(() =>
2871+
cleared.apply([
2872+
{
2873+
kind: "insert",
2874+
ref: data("x"),
2875+
index: 2,
2876+
estimatedHeight: 10,
2877+
denseKey: 3,
2878+
},
2879+
]),
2880+
).toThrow(/duplicate/i);
2881+
});
2882+
});

packages/layout-core/src/row-height-index.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1613,6 +1613,59 @@ class PersistentRowHeightIndex<TKey> implements RowHeightIndex<TKey> {
16131613
return builder.finish();
16141614
}
16151615

1616+
/**
1617+
* Synchronous BY DESIGN, like `reorder`/`refilter`: the row set stands
1618+
* still, only the estimator's inputs changed, so one in-order pass over
1619+
* the existing entries plus a balanced rebuild answers it — no source, no
1620+
* identity re-hash, no HAMT work. Measurements, tombstones, retention
1621+
* order, and the lane (dense bitset included) are carried through
1622+
* verbatim; only entries with an `estimatedHeight` are rewritten. See the
1623+
* interface docblock for the semantics.
1624+
*/
1625+
clearEstimates(): RowHeightIndex<TKey> {
1626+
if (this.#root === null) return this;
1627+
const work = createWork();
1628+
const values: HeightValue<TKey>[] = [];
1629+
let changed = false;
1630+
const stack: SequenceNode<TKey>[] = [];
1631+
let cursor: SequenceNode<TKey> | null = this.#root;
1632+
while (cursor !== null || stack.length > 0) {
1633+
while (cursor !== null) {
1634+
stack.push(cursor);
1635+
cursor = cursor.left;
1636+
}
1637+
const node = stack.pop()!;
1638+
const value = node.value;
1639+
work.previousEntriesScanned += 1;
1640+
if (value.estimatedHeight === undefined) {
1641+
values.push(value);
1642+
} else {
1643+
changed = true;
1644+
values.push({
1645+
...value,
1646+
estimatedHeight: undefined,
1647+
// A measurement is a fact the DOM reported and survives; an
1648+
// estimate is arithmetic over the inputs that just changed.
1649+
height: value.measured ? value.height : this.#defaultHeight,
1650+
});
1651+
}
1652+
cursor = node.right;
1653+
}
1654+
if (!changed) return this;
1655+
const root = buildBalancedSequence(values, 0, values.length, work);
1656+
return this.#next(
1657+
root,
1658+
this.#visibleKeys,
1659+
this.#denseCapacity,
1660+
this.#visibleSlots,
1661+
this.#measurements,
1662+
this.#tombstones,
1663+
this.#tombstoneOrder,
1664+
this.#nextTicket,
1665+
work,
1666+
);
1667+
}
1668+
16161669
/**
16171670
* Synchronous BY DESIGN: a sort-only commit reorders EXISTING rows whose
16181671
* heights are already known, so only the ordered structure and its prefix

packages/layout-core/src/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,21 @@ export interface RowHeightIndex<TKey> extends RowMetricsReader {
244244
* rowCount); callers fall back to `beginReplacement` on any throw.
245245
*/
246246
refilter(source: RowHeightReplacementSource<TKey>): RowHeightIndex<TKey>;
247+
/**
248+
* Drops every entry's ESTIMATE, synchronously, leaving everything else —
249+
* order, membership, lane, measurements, tombstones, retention order —
250+
* untouched: an estimated, unmeasured row returns to the default height
251+
* (and to an empty `estimatedHeight` slot, so a later re-estimate of the
252+
* same value is not mistaken for a no-op), while a measured row keeps the
253+
* height the DOM reported. Identity (`=== this`) when nothing is
254+
* estimated.
255+
*
256+
* This is the "estimator inputs changed, rows did not" reset: estimates
257+
* are arithmetic over inputs (columns, fonts) that can change while the
258+
* row set stands still, and re-deriving them must not cost a full
259+
* re-ingest of an index whose entries are already correct.
260+
*/
261+
clearEstimates(): RowHeightIndex<TKey>;
247262
beginReplacement(
248263
source: RowHeightReplacementSource<TKey>,
249264
): RowHeightReplacementBuilder<TKey>;
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// @vitest-environment jsdom
2+
import "@testing-library/jest-dom/vitest";
3+
import { act, cleanup, render } from "@testing-library/react";
4+
import * as React from "react";
5+
import { afterEach, describe, expect, it, vi } from "vitest";
6+
7+
import type { RowLayoutController } from "@pretable-internal/renderer-dom";
8+
// The diagnostics seam is a direct-module export deliberately kept off
9+
// renderer-dom's barrel; `vitest.config.ts` aliases this subpath to its
10+
// source and `tsconfig.typecheck.json` resolves it through the package's
11+
// built `dist/*.d.ts`.
12+
import { getRowLayoutControllerDiagnosticsForTesting } from "@pretable-internal/renderer-dom/row-layout-controller";
13+
14+
/**
15+
* The grouping-apply layout bill, pinned END TO END: applying a grouping to a
16+
* mounted react grid must cost exactly ONE height-index replacement — the
17+
* engine's reset commit, which really does change the row set (group rows
18+
* enter). It must NOT also pay full-set replacements for the column commits
19+
* that ride the same gesture (the group column swapping into the roster, and
20+
* the merged engine widths landing a render later) — those change only the
21+
* estimator's inputs, and the controller absorbs them in place.
22+
*
23+
* Before the columns-reset path existed this gesture cost THREE full 50k
24+
* ingests in a headed browser (engine reset + two `setColumns` restarts,
25+
* each cancelling the previous build) — ~0.6s of the grouping-apply settle
26+
* at S2 target scale.
27+
*/
28+
29+
type Row = { id: number; sector: string; name: string; qty: number };
30+
31+
type Controller = RowLayoutController<Row, number, unknown>;
32+
33+
const controllers: Controller[] = [];
34+
35+
vi.mock("@pretable-internal/renderer-dom", async (importOriginal) => {
36+
const actual =
37+
await importOriginal<typeof import("@pretable-internal/renderer-dom")>();
38+
const createRowLayoutController: typeof actual.createRowLayoutController = (
39+
options,
40+
) => {
41+
const controller = actual.createRowLayoutController(options);
42+
controllers.push(controller as unknown as Controller);
43+
return controller;
44+
};
45+
return { ...actual, createRowLayoutController };
46+
});
47+
48+
const { PretableSurface } = await import("../pretable-surface");
49+
50+
afterEach(() => {
51+
cleanup();
52+
controllers.length = 0;
53+
vi.clearAllMocks();
54+
});
55+
56+
const SECTORS = ["Tech", "Energy", "Health", "Retail", "Bank"];
57+
const rows: Row[] = Array.from({ length: 500 }, (_, index) => ({
58+
id: index,
59+
sector: SECTORS[index % SECTORS.length]!,
60+
name: `row ${index}`,
61+
qty: index % 97,
62+
}));
63+
64+
const columns = [
65+
{ id: "sector", header: "Sector", widthPx: 100, type: "text" as const },
66+
{ id: "name", header: "Name", widthPx: 140, type: "text" as const },
67+
{
68+
id: "qty",
69+
header: "Qty",
70+
widthPx: 100,
71+
type: "number" as const,
72+
aggregate: "sum" as const,
73+
},
74+
];
75+
76+
describe("grouping-apply layout cost", () => {
77+
it("costs exactly one height-index replacement, with the column commits absorbed in place", async () => {
78+
let grid: { setQuery: (query: unknown) => unknown } | undefined;
79+
const view = render(
80+
<PretableSurface
81+
ariaLabel="grouping-cost-grid"
82+
columns={columns}
83+
getRowId={(row: Row) => row.id}
84+
initialExpansion={{ kind: "expanded" }}
85+
onGridReady={(readyGrid) => {
86+
grid = readyGrid as unknown as typeof grid;
87+
}}
88+
overscan={0}
89+
rows={rows}
90+
viewportHeight={600}
91+
/>,
92+
);
93+
await expect
94+
.poll(() => view.container.querySelectorAll("[data-pretable-row]").length)
95+
.toBeGreaterThan(0);
96+
expect(controllers).toHaveLength(1);
97+
const controller = controllers[0]!;
98+
await expect.poll(() => controller.getState().status.kind).toBe("ready");
99+
100+
const base = getRowLayoutControllerDiagnosticsForTesting(controller);
101+
102+
await act(async () => {
103+
const transition = grid!.setQuery({
104+
filters: [],
105+
sort: [],
106+
rowGroups: [{ columnId: "sector" }],
107+
}) as { finished?: Promise<unknown> };
108+
await transition?.finished;
109+
});
110+
await expect
111+
.poll(
112+
() =>
113+
view.container.querySelectorAll("[data-pretable-group-row]").length,
114+
{ timeout: 20_000 },
115+
)
116+
.toBeGreaterThan(0);
117+
await expect
118+
.poll(() => controller.getState().status.kind, { timeout: 20_000 })
119+
.toBe("ready");
120+
// Let the roster/width column effects settle (they land a render after
121+
// the grouped snapshot publishes).
122+
await act(async () => {
123+
await new Promise((resolve) => setTimeout(resolve, 50));
124+
});
125+
126+
const after = getRowLayoutControllerDiagnosticsForTesting(controller);
127+
// ONE replacement: the engine's grouping reset. The group-column roster
128+
// commit and the merged-width commit that follow it are absorbed by the
129+
// columns-reset path (or by an in-flight replacement) — never by another
130+
// full-set ingest.
131+
expect(after.replacementStartCount - base.replacementStartCount).toBe(1);
132+
expect(
133+
after.columnsResetPathCount - base.columnsResetPathCount,
134+
).toBeGreaterThanOrEqual(1);
135+
expect(after.columnsResetFallbackCount).toBe(0);
136+
// And the grid really is grouped — the cheap path must not have bought
137+
// its count by dropping the commit.
138+
expect(controller.getState().snapshot?.visibleRowCount).toBe(
139+
rows.length + SECTORS.length,
140+
);
141+
// Blank-grid insurance (this repo has shipped a windowed grid that
142+
// painted offscreen while every count agreed): a known DATA row's cell
143+
// is actually in the DOM with its content. Groups sort ascending, so
144+
// "Bank" leads and its first member (id 4, name "row 4") sits inside the
145+
// 600px viewport.
146+
const dataRows = view.container.querySelectorAll("[data-pretable-row]");
147+
expect(dataRows.length).toBeGreaterThan(0);
148+
expect(
149+
Array.from(dataRows).some((row) =>
150+
(row.textContent ?? "").includes("row 4"),
151+
),
152+
).toBe(true);
153+
}, 60_000);
154+
});

0 commit comments

Comments
 (0)