perf(project-tree): measure row widths once per frame

The horizontal scroll range was measured in a layout effect on every render:
each pass forced layout, its setState re-rendered synchronously and measured
again, and the ResizeObserver was recreated each time, firing once more.
Expanding a 10k-entry folder went from ~26 ms to ~63 ms.

Keep one observer per tree and coalesce renders and resizes into a single
measurement on the next animation frame. Expanding is back to ~30 ms and
arrow-key p50 drops from 3.3 ms to 1.7 ms; the widths behave as before.
Цей коміт міститься в:
l0ng-ai committed 2026-11-05 23:58:23 +08:00
1 джерело 915fec598f
коміт eb1015c19a
2 files changed
+76 -31

No files matched your search

+27 -3
Переглянути файл
@@ -309,26 +309,47 @@ describe("siblingPositions", () => {
describe("Tree horizontal scrolling", () => {
const spacer = (tree: HTMLElement) => tree.querySelector<HTMLElement>("[data-content-width]");
// Widths are measured on the next animation frame.
const frame = () => act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
it("is off unless asked for", () => {
const { tree } = setup();
expect(spacer(tree)).toBeNull();
});
it("remeasures mounted rows with the same key, including narrower content", () => {
it("remeasures mounted rows with the same key, including narrower content", async () => {
const width = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(640);
try {
const { tree, key } = setup({ horizontalScroll: true, defaultExpanded: ["src"] });
await frame();
expect(spacer(tree)?.style.width).toBe("640px");
width.mockReturnValue(100);
key("ArrowDown");
await frame();
expect(spacer(tree)?.style.width).toBe("100px");
} finally {
width.mockRestore();
}
});
it("removes widths belonging to collapsed descendants", () => {
it("measures once per frame however many times it renders", async () => {
const width = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(640);
try {
const { tree, key } = setup({ horizontalScroll: true, defaultExpanded: ["src"] });
await frame();
width.mockClear();
key("ArrowDown");
key("ArrowDown");
key("ArrowDown");
expect(width).not.toHaveBeenCalled();
await frame();
expect(width).toHaveBeenCalledTimes(tree.querySelectorAll("[data-row]").length);
} finally {
width.mockRestore();
}
});
it("removes widths belonging to collapsed descendants", async () => {
const width = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function (
this: HTMLElement,
) {
@@ -336,16 +357,18 @@ describe("Tree horizontal scrolling", () => {
});
try {
const { tree, key } = setup({ horizontalScroll: true, defaultExpanded: ["src"] });
await frame();
expect(spacer(tree)?.style.width).toBe("640px");
key("Home");
key("ArrowLeft");
await frame();
expect(spacer(tree)?.style.width).toBe("100px");
} finally {
width.mockRestore();
}
});
it("measures without the viewport minimum and restores it before returning", () => {
it("measures without the viewport minimum and restores it before returning", async () => {
const minimums: string[] = [];
const width = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function (
this: HTMLElement,
@@ -355,6 +378,7 @@ describe("Tree horizontal scrolling", () => {
});
try {
const { tree } = setup({ horizontalScroll: true });
await frame();
expect(spacer(tree)?.style.width).toBe("100px");
expect(minimums.length).toBeGreaterThan(0);
expect(minimums.every((value) => parseFloat(value) === 0)).toBe(true);
+49 -28
Переглянути файл
@@ -130,33 +130,34 @@ function useContentWidth<T>(
enabled: boolean,
): number {
const [width, setWidth] = useState(0);
const cache = useRef(new Map<string, number>());
const measuredRows = useRef<readonly Row<T>[] | null>(null);
const maximum = useRef(0);
useLayoutEffect(() => {
if (!enabled) {
cache.current.clear();
measuredRows.current = null;
maximum.current = 0;
return;
}
const latestRows = useRef(rows);
const schedule = useRef<(() => void) | null>(null);
useEffect(() => {
if (!enabled) return;
const element = scroller.current;
if (!element) return;
const mounted = [...element.querySelectorAll<HTMLElement>("[data-row]")];
const cache = new Map<string, number>();
const observed = new Set<HTMLElement>();
let measuredRows: readonly Row<T>[] | null = null;
let maximum = 0;
let frame = 0;
const measure = () => {
frame = 0;
// Hidden tool windows have no layout. Keep their estimates until shown;
// the observer remains attached so showing the window resumes measurement.
if (element.clientWidth === 0) return;
const rows = latestRows.current;
const mounted = [...element.querySelectorAll<HTMLElement>("[data-row]")];
let recompute = false;
if (measuredRows.current !== rows) {
if (measuredRows !== rows) {
const keys = new Set(rows.map((row) => row.key));
for (const [key, value] of cache.current) {
for (const [key, value] of cache) {
if (!keys.has(key)) {
cache.current.delete(key);
if (value === maximum.current) recompute = true;
cache.delete(key);
if (value === maximum) recompute = true;
}
}
measuredRows.current = rows;
measuredRows = rows;
}
// Measure max-content, not the viewport's min-width floor. Batch writes and
// restore before paint so selection backgrounds still fill the viewport.
@@ -165,27 +166,47 @@ function useContentWidth<T>(
const widths = mounted.map((row) => row.offsetWidth);
mounted.forEach((row, index) => {
row.style.minWidth = minimums[index]!;
const key = rows[Number(row.dataset.row)]!.key;
const key = rows[Number(row.dataset.row)]?.key;
if (key === undefined) return;
const value = widths[index]!;
if (cache.current.get(key) === maximum.current && value < maximum.current) recompute = true;
cache.current.set(key, value);
maximum.current = Math.max(maximum.current, value);
if (cache.get(key) === maximum && value < maximum) recompute = true;
cache.set(key, value);
maximum = Math.max(maximum, value);
});
// Ordinary scrolling only touches mounted rows. Scan cached widths only
// when the previous maximum was removed or actually became narrower.
if (recompute) {
maximum.current = 0;
for (const value of cache.current.values()) maximum.current = Math.max(maximum.current, value);
recompute = false;
maximum = 0;
for (const value of cache.values()) maximum = Math.max(maximum, value);
}
// Reconcile and measure together: never publish an intermediate shrink.
setWidth(maximum.current);
setWidth(maximum);
// Rows resize without a render when fonts or zoom change.
const current = new Set(mounted);
for (const row of observed) if (!current.has(row)) observer.unobserve(row);
for (const row of mounted) if (!observed.has(row)) observer.observe(row);
observed.clear();
for (const row of mounted) observed.add(row);
};
measure();
const observer = new ResizeObserver(measure);
// Measuring forces layout: coalesce renders and resizes into one pass per
// frame instead of measuring inside every commit.
const request = () => {
frame ||= requestAnimationFrame(measure);
};
const observer = new ResizeObserver(request);
observer.observe(element);
for (const row of mounted) observer.observe(row);
return () => observer.disconnect();
schedule.current = request;
request();
return () => {
schedule.current = null;
cancelAnimationFrame(frame);
observer.disconnect();
};
}, [enabled, scroller]);
// Rows or the virtual window may have changed; keep `rows` in step with the DOM.
useLayoutEffect(() => {
latestRows.current = rows;
schedule.current?.();
});
// Offscreen widths are estimates after label/font/indent changes; mounting
// measures them afresh, including decreases for the same key.