From eb1015c19a01e278b5ea3561cb16404239f66aa1 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:58:23 +0800 Subject: [PATCH] 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. --- src/ui/Tree.test.tsx | 30 +++++++++++++++-- src/ui/Tree.tsx | 77 ++++++++++++++++++++++++++++---------------- 2 files changed, 76 insertions(+), 31 deletions(-) diff --git a/src/ui/Tree.test.tsx b/src/ui/Tree.test.tsx index b75891616..a88fbbd9d 100644 --- a/src/ui/Tree.test.tsx +++ b/src/ui/Tree.test.tsx @@ -309,26 +309,47 @@ describe("siblingPositions", () => { describe("Tree horizontal scrolling", () => { const spacer = (tree: HTMLElement) => tree.querySelector("[data-content-width]"); + // Widths are measured on the next animation frame. + const frame = () => act(() => new Promise((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); diff --git a/src/ui/Tree.tsx b/src/ui/Tree.tsx index 84e5f0ceb..fe5b907aa 100644 --- a/src/ui/Tree.tsx +++ b/src/ui/Tree.tsx @@ -130,33 +130,34 @@ function useContentWidth( enabled: boolean, ): number { const [width, setWidth] = useState(0); - const cache = useRef(new Map()); - const measuredRows = useRef[] | 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("[data-row]")]; + const cache = new Map(); + const observed = new Set(); + let measuredRows: readonly Row[] | 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("[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( 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.