дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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.
Цей коміт міститься в:
1 джерело
915fec598f
коміт
eb1015c19a
2 files changed
+76
-31
No files matched your search
@@ -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.
|
||||
|
||||
Reference in new issue
Заблокувати користувача