Файли
openidea/src/ui/Tree.test.tsx
T
l0ng-ai eb1015c19a 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.
2026-11-05 23:58:23 +08:00

493 рядки
18 KiB
TypeScript

import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
import { createRef } from "react";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { Tree, type TreeHandle } from "./Tree";
import { getContext } from "@/platform/context";
interface N {
id: string;
children?: N[];
}
const data: N[] = [
{
id: "src",
children: [{ id: "main", children: [{ id: "App.tsx" }, { id: "index.ts" }] }, { id: "util.ts" }],
},
{ id: "README.md" },
];
function setup(props: Partial<React.ComponentProps<typeof Tree<N>>> = {}) {
const onActivate = vi.fn();
const utils = render(
<Tree<N>
aria-label="Files"
roots={data}
getKey={(n) => n.id}
getLabel={(n) => n.id}
getChildren={(n) => n.children}
isExpandable={(n) => !!n.children}
onActivate={onActivate}
{...props}
/>,
);
const tree = screen.getByRole("tree");
tree.focus();
const lead = () => {
const id = tree.getAttribute("aria-activedescendant");
return id ? document.getElementById(id)?.textContent : null;
};
const key = (k: string, init: Partial<KeyboardEventInit> = {}) => fireEvent.keyDown(tree, { key: k, ...init });
return { ...utils, tree, lead, key, onActivate };
}
const labels = (tree: HTMLElement) =>
within(tree)
.queryAllByRole("treeitem")
.map((el) => el.textContent);
describe("Tree directory context", () => {
it("does not contribute virtual document URIs as filesystem directories", () => {
const { key } = setup({ getDirectory: () => "jdt://contents/library.jar/package" });
key("Home");
expect(getContext().focusedDirectory).toBeUndefined();
});
it("follows the selected lead, clears on non-path rows, blur and unmount", () => {
const { tree, key, unmount } = setup({
getDirectory: (n) => (n.id === "main" ? null : `/p/${n.id}`),
});
expect(getContext().focusedDirectory).toBeUndefined();
key("ArrowDown");
expect(getContext().focusedDirectory).toBe("/p/src");
key("End", { shiftKey: true });
expect(getContext().focusedDirectory).toBe("/p/README.md");
key("Home");
key("ArrowRight");
key("ArrowRight");
expect(getContext().focusedDirectory).toBeUndefined();
key("Home");
fireEvent.blur(tree);
expect(getContext().focusedDirectory).toBeUndefined();
fireEvent.focus(tree);
expect(getContext().focusedDirectory).toBe("/p/src");
unmount();
expect(getContext().focusedDirectory).toBeUndefined();
});
it("updates a controlled selection while focused and clears a removed selection", () => {
const view = (selected: Set<string>, roots = data) => (
<Tree<N>
roots={roots}
getKey={(n) => n.id}
getLabel={(n) => n.id}
getChildren={(n) => n.children}
isExpandable={(n) => !!n.children}
selected={selected}
getDirectory={(n) => `/p/${n.id}`}
/>
);
const { rerender } = render(view(new Set(["src"])));
screen.getByRole("tree").focus();
expect(getContext().focusedDirectory).toBe("/p/src");
rerender(view(new Set()));
expect(getContext().focusedDirectory).toBeUndefined();
rerender(view(new Set(["src"])));
expect(getContext().focusedDirectory).toBe("/p/src");
rerender(view(new Set(["src"]), []));
expect(getContext().focusedDirectory).toBeUndefined();
});
it("clears on focus transfer and an old tree's unmount does not clear the new owner", () => {
const view = (showFirst: boolean) => (
<>
{showFirst && (
<Tree<N>
roots={data}
getKey={(n) => n.id}
getLabel={(n) => n.id}
getChildren={() => []}
isExpandable={() => false}
defaultSelected={["src"]}
getDirectory={() => "/first"}
/>
)}
<Tree<N>
roots={data}
getKey={(n) => n.id}
getLabel={(n) => n.id}
getChildren={() => []}
isExpandable={() => false}
defaultSelected={["src"]}
getDirectory={() => "/second"}
/>
<input aria-label="Editor" />
</>
);
const { rerender } = render(view(true));
const [first, second] = screen.getAllByRole("tree");
first!.focus();
expect(getContext().focusedDirectory).toBe("/first");
second!.focus();
expect(getContext().focusedDirectory).toBe("/second");
rerender(view(false));
expect(getContext().focusedDirectory).toBe("/second");
screen.getByLabelText("Editor").focus();
expect(getContext().focusedDirectory).toBeUndefined();
});
});
describe("Tree keyboard navigation", () => {
it("moves with arrows, expands with Right and collapses / goes to parent with Left", () => {
const { tree, lead, key } = setup();
expect(labels(tree)).toEqual(["src", "README.md"]);
key("ArrowDown");
expect(lead()).toBe("src");
key("ArrowRight");
expect(labels(tree)).toEqual(["src", "main", "util.ts", "README.md"]);
key("ArrowRight");
expect(lead()).toBe("main");
key("ArrowRight");
key("ArrowDown");
expect(lead()).toBe("App.tsx");
key("ArrowLeft");
expect(lead()).toBe("main");
key("ArrowLeft");
expect(labels(tree)).toEqual(["src", "main", "util.ts", "README.md"]);
key("End");
expect(lead()).toBe("README.md");
key("Home");
expect(lead()).toBe("src");
});
it("Enter toggles folders and activates leaves", () => {
const { tree, lead, key, onActivate } = setup();
key("ArrowDown");
key("Enter");
expect(labels(tree)).toContain("main");
key("End");
expect(lead()).toBe("README.md");
key("Enter");
expect(onActivate).toHaveBeenCalledWith(expect.objectContaining({ id: "README.md" }), expect.anything());
});
it("F4 and ⌘↓ activate like Enter (Jump to Source)", () => {
const { key, onActivate } = setup();
key("End");
key("F4");
expect(onActivate).toHaveBeenCalledTimes(1);
key("ArrowDown", { metaKey: true });
expect(onActivate).toHaveBeenCalledTimes(2);
expect(onActivate).toHaveBeenLastCalledWith(expect.objectContaining({ id: "README.md" }), expect.anything());
});
it("extends the selection with Shift+Arrow", () => {
const onSelectionChange = vi.fn();
const { key } = setup({ defaultExpanded: ["src"], onSelectionChange });
key("ArrowDown");
key("ArrowDown", { shiftKey: true });
key("ArrowDown", { shiftKey: true });
const last = onSelectionChange.mock.calls.at(-1)![0] as Set<string>;
expect([...last].sort()).toEqual(["main", "src", "util.ts"]);
});
it("lazy-loads children on expand", () => {
const loadChildren = vi.fn();
const { tree, key } = setup({ getChildren: () => undefined, loadChildren });
key("ArrowDown");
key("ArrowRight");
expect(loadChildren).toHaveBeenCalledWith(expect.objectContaining({ id: "src" }));
expect(tree.textContent).toContain("Loading");
});
});
describe("Tree speed search", () => {
it("jumps to and highlights matches, cycles with arrows and clears with Escape", () => {
const { tree, lead, key } = setup({ defaultExpanded: ["src", "main"] });
key("ArrowDown");
key("i");
key("n");
key("d");
expect(lead()).toBe("index.ts");
expect(screen.getByRole("status")).toHaveTextContent("ind");
expect([...tree.querySelectorAll("mark")].map((m) => m.textContent)).toEqual(["ind"]);
key("Backspace");
// "in" matches "main" (above) and "index.ts": arrows cycle between matches.
key("ArrowUp");
expect(lead()).toBe("main");
key("ArrowDown");
expect(lead()).toBe("index.ts");
key("ArrowDown");
expect(lead()).toBe("main");
key("Escape");
expect(screen.queryByRole("status")).toBeNull();
expect(tree.querySelectorAll("mark")).toHaveLength(0);
});
it("Home / End jump to the first / last match; Left closes the search and collapses", () => {
const { tree, lead, key } = setup({ defaultExpanded: ["src", "main"] });
key("ArrowDown");
key("t");
key("s");
// "ts" matches App.tsx, index.ts and util.ts.
key("End");
expect(lead()).toBe("util.ts");
key("Home");
expect(lead()).toBe("App.tsx");
key("ArrowLeft");
expect(screen.queryByRole("status")).toBeNull();
expect(lead()).toBe("main");
expect(labels(tree)).toContain("App.tsx");
});
it("filter mode hides non-matching rows but keeps ancestors", () => {
const { tree, key } = setup({ defaultExpanded: ["src", "main"], speedSearchMode: "filter" });
key("ArrowDown");
for (const c of "App") key(c);
expect(labels(tree)).toEqual(["src", "main", "App.tsx"]);
});
});
describe("Tree occurrences", () => {
it("selects the next / previous leaf, expanding its ancestors", () => {
const handle = createRef<TreeHandle>();
const onSelectionChange = vi.fn();
const { tree, lead } = setup({ handleRef: handle, onSelectionChange });
let step: ReturnType<TreeHandle["selectOccurrence"]> = null;
act(() => void (step = handle.current!.selectOccurrence(1)));
expect(step).toMatchObject({ key: "App.tsx", index: 1, total: 4 });
expect(labels(tree)).toEqual(["src", "main", "App.tsx", "index.ts", "util.ts", "README.md"]);
expect(lead()).toBe("App.tsx");
expect(onSelectionChange).toHaveBeenLastCalledWith(
new Set(["App.tsx"]),
expect.objectContaining({ id: "App.tsx" }),
);
act(() => void (step = handle.current!.selectOccurrence(1)));
expect(step).toMatchObject({ key: "index.ts", index: 2 });
act(() => void (step = handle.current!.selectOccurrence(-1)));
expect(step).toMatchObject({ key: "App.tsx", index: 1 });
act(() => void (step = handle.current!.selectOccurrence(-1)));
expect(step).toBeNull();
expect(lead()).toBe("App.tsx");
});
it("starts from the selected group and honours isOccurrence", () => {
const handle = createRef<TreeHandle>();
const { key } = setup({ handleRef: handle, isOccurrence: (n) => n.id !== "src" });
key("ArrowDown"); // src
let step: ReturnType<TreeHandle["selectOccurrence"]> = null;
act(() => void (step = handle.current!.selectOccurrence(1)));
expect(step).toMatchObject({ key: "main", index: 1, total: 5 });
act(() => void (step = handle.current!.selectOccurrence(-1)));
expect(step).toBeNull();
});
it("skips the lead's subtree on request", () => {
const handle = createRef<TreeHandle>();
const { key } = setup({ handleRef: handle, defaultExpanded: ["src"] });
key("ArrowDown");
key("ArrowDown"); // main
let step: ReturnType<TreeHandle["selectOccurrence"]> = null;
act(() => void (step = handle.current!.selectOccurrence(1, { skipSubtree: true })));
expect(step).toMatchObject({ key: "util.ts", index: 3 });
});
});
describe("siblingPositions", () => {
it("numbers rows among siblings", async () => {
const { siblingPositions } = await import("./Tree");
expect(
siblingPositions([{ parentKey: null }, { parentKey: "a" }, { parentKey: "a" }, { parentKey: null }]),
).toEqual([
[1, 2],
[1, 2],
[2, 2],
[2, 2],
]);
});
});
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", 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("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,
) {
return this.textContent === "util.ts" ? 640 : 100;
});
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", async () => {
const minimums: string[] = [];
const width = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function (
this: HTMLElement,
) {
if (this.hasAttribute("data-row")) minimums.push(this.style.minWidth);
return parseFloat(this.style.minWidth) === 0 ? 100 : 800;
});
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);
for (const row of tree.querySelectorAll<HTMLElement>("[data-row]")) expect(row.style.minWidth).toBe("");
} finally {
width.mockRestore();
}
});
});
describe("Tree scroll following", () => {
let scrollTo: ReturnType<typeof vi.fn>;
let original: PropertyDescriptor | undefined;
beforeAll(() => {
// jsdom has no Element.scrollTo; virtual-core calls it to write scrollTop.
scrollTo = vi.fn();
original = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTo");
Object.defineProperty(Element.prototype, "scrollTo", { value: scrollTo, configurable: true, writable: true });
});
afterAll(() => {
if (original) Object.defineProperty(Element.prototype, "scrollTo", original);
else delete (Element.prototype as { scrollTo?: unknown }).scrollTo;
});
it("brings the keyboard lead into view", () => {
const { key } = setup();
scrollTo.mockClear();
key("End");
expect(scrollTo).toHaveBeenCalled();
});
it("keeps the viewport when expanding a folder above the selection", () => {
// The selected file stays selected while rows inserted above it shift its
// index; scrolling then would jump to the selection from the opened folder.
const { tree } = setup({ defaultSelected: ["README.md"] });
scrollTo.mockClear();
const chevron = tree.querySelector("[data-tree-toggle]");
expect(chevron).not.toBeNull();
fireEvent.mouseDown(chevron!, { button: 0 });
expect(scrollTo).not.toHaveBeenCalled();
// The folder did open, inserting rows above the selection.
expect(within(tree).queryAllByRole("treeitem").length).toBeGreaterThan(2);
});
it.each(["defaultSelected", "selected"] as const)("scrolls a late %s lead once when its row loads", (selection) => {
const view = (roots: N[]) => (
<Tree<N>
roots={roots}
getKey={(n) => n.id}
getLabel={(n) => n.id}
getChildren={(n) => n.children}
isExpandable={(n) => n.id === "src" || !!n.children}
defaultExpanded={["src"]}
{...{ [selection]: new Set(["late.ts"]) }}
/>
);
const { rerender } = render(view([{ id: "src" }]));
scrollTo.mockClear();
// An intermediate load still does not contain the selected row.
rerender(view([{ id: "src", children: [{ id: "main", children: [{ id: "App.tsx" }] }] }]));
expect(scrollTo).not.toHaveBeenCalled();
rerender(view([{ id: "src", children: [{ id: "main", children: [{ id: "App.tsx" }] }, { id: "late.ts" }] }]));
expect(scrollTo).toHaveBeenCalledTimes(1);
expect(screen.getByRole("treeitem", { name: "late.ts" })).toHaveAttribute("aria-selected", "true");
scrollTo.mockClear();
const folder = screen.getByRole("treeitem", { name: "main" });
fireEvent.mouseDown(folder.querySelector("[data-tree-toggle]")!, { button: 0 });
expect(screen.getByRole("treeitem", { name: "App.tsx" })).toBeInTheDocument();
expect(scrollTo).not.toHaveBeenCalled();
fireEvent.mouseDown(folder.querySelector("[data-tree-toggle]")!, { button: 0 });
expect(scrollTo).not.toHaveBeenCalled();
});
it("replaces a pending lead when the user selects another row", () => {
const view = (roots: N[]) => (
<Tree<N>
roots={roots}
getKey={(n) => n.id}
getLabel={(n) => n.id}
getChildren={(n) => n.children}
isExpandable={(n) => !!n.children}
defaultSelected={["late.ts"]}
/>
);
const { rerender } = render(view([{ id: "README.md" }]));
fireEvent.keyDown(screen.getByRole("tree"), { key: "End" });
scrollTo.mockClear();
rerender(view([{ id: "late.ts" }, { id: "README.md" }]));
expect(screen.getByRole("treeitem", { name: "README.md" })).toHaveAttribute("aria-selected", "true");
expect(scrollTo).not.toHaveBeenCalled();
});
it("explicitly reveals an unchanged lead", () => {
const handle = createRef<TreeHandle>();
setup({ handleRef: handle, defaultSelected: ["README.md"] });
scrollTo.mockClear();
act(() => handle.current!.reveal("README.md"));
expect(scrollTo).toHaveBeenCalledTimes(1);
});
it("brings speed search matches into view", () => {
const { key, lead } = setup({ defaultExpanded: ["src", "main"] });
key("Home");
scrollTo.mockClear();
key("u");
expect(lead()).toBe("util.ts");
expect(scrollTo).toHaveBeenCalledTimes(1);
});
});