perf(editor): stop re-rendering breadcrumbs on every caret move (#256)

The status-bar breadcrumbs, the code breadcrumbs and the closed Navigation Bar popup subscribed to the whole editor status, which is replaced on every keystroke and caret move, so ~30 crumb buttons re-rendered per key. They now select the symbol chain at the caret with a shallow comparison (re-rendering only when it changes), and the popup subscribes to the caret only while open. Typing 38 characters: 1386 → 152 component renders.

Co-authored-by: jaw52 <[email protected]>
Цей коміт міститься в:
Jaw authored and GitHub committed 2026-10-29 23:41:03 +08:00
1 джерело 00693334f7
коміт d1f84ef364
4 files changed
+136 -16

No files matched your search

+24
Переглянути файл
@@ -35,3 +35,27 @@ test("opening several files creates tabs and ⌘W closes the active one", async
await expect(tabs).toHaveCount(1);
await expect(tabs.first()).toHaveText(/build\.gradle\.kts/);
});
test("breadcrumbs follow the caret into the symbol under it", async ({ workbench: page }) => {
const editor = await openFromTree(page, "build.gradle.kts");
const status = page.getByRole("navigation", { name: "Breadcrumbs", exact: true });
const code = page.getByRole("navigation", { name: "Code breadcrumbs" });
await editor.locator(".cm-line", { hasText: "service.find(status" }).click();
await expect(status).toContainText("list()");
await expect(code).toContainText("OrderController");
await expect(code).toContainText("list()");
// Typing inside the method keeps its crumb.
await page.keyboard.press("Home");
await page.keyboard.type("checked ");
await expect(code).toContainText("list()");
await editor.locator(".cm-line", { hasText: "return order.toDto()" }).click();
await expect(code).toContainText("create()");
await expect(code).not.toContainText("list()");
await expect(status).toContainText("create()");
// ⌘↑ opens the navigation bar at the caret's symbol.
await page.keyboard.press("Meta+ArrowUp");
const bar = page.getByRole("toolbar", { name: "Navigation bar" });
await expect(bar).toContainText("create()");
await page.keyboard.press("Escape");
await expect(bar).toBeHidden();
});
+6 -1
Переглянути файл
@@ -35,9 +35,14 @@ const close = () => useNavBarPopup.setState({ open: false });
export function NavBarPopup() {
const open = useNavBarPopup((s) => s.open);
// Closed nearly always: no subscription to the caret, which moves on every keystroke.
return open ? <OpenNavBar /> : null;
}
function OpenNavBar() {
const seq = useNavBarPopup((s) => s.seq);
const status = useEditorStatus((s) => s.status);
if (!open || !status) return null;
if (!status) return null;
return <NavBar key={seq} path={status.path} line={status.line} col={status.col} />;
}
+30 -15
Переглянути файл
@@ -1,10 +1,11 @@
/** Status bar widgets of the active text editor (design footer). */
import { Fragment, useMemo, useRef, useState } from "react";
import { Fragment, useRef, useState } from "react";
import clsx from "clsx";
import { useShallow } from "zustand/react/shallow";
import { executeAction } from "@/platform/actions";
import { shortcutLabel } from "@/platform/keymap";
import { basename, dirname, relativePath, useProject } from "@/platform/project";
import type { LineEnding } from "@/platform/ipc";
import type { LineEnding, OutlineItem } from "@/platform/ipc";
import { BarButton, Icon, Menu, badgeKind, fileIconName } from "@/ui";
import { notifications } from "@/workbench/contributions";
import { ENCODING_CHOICES, useEditorPrefs } from "@/platform/editorSettings";
@@ -23,6 +24,23 @@ function useActiveMeta() {
return meta;
}
const NO_SYMBOLS: OutlineItem[] = [];
/**
* Symbols at the caret while `path` is the active editor, `null` otherwise. Selected
* from the status store so callers re-render when the chain changes, not on every
* keystroke or caret move.
*/
function useCaretSymbols(path: string | null, outline: readonly OutlineItem[] | undefined) {
return useEditorStatus(
useShallow((s) => {
const st = s.status;
if (!st || st.path !== path) return null;
return outline ? symbolChain(outline, st.line, st.col) : NO_SYMBOLS;
}),
);
}
interface Crumb {
label: string;
icon?: string;
@@ -37,15 +55,12 @@ interface Crumb {
* symbols, with icons; clicking a segment opens a popup of its children.
*/
export function Breadcrumbs() {
const status = useEditorStatus((s) => s.status);
const path = useEditorStatus((s) => s.status?.path ?? null);
const project = useProject((s) => s.project);
const outline = useOutline((s) => (status ? s[status.path] : undefined));
const line = status?.line ?? 0;
const col = status?.col ?? 0;
const chain = useMemo(() => (outline ? symbolChain(outline, line, col) : []), [outline, line, col]);
const outline = useOutline((s) => (path ? s[path] : undefined));
const chain = useCaretSymbols(path, outline) ?? NO_SYMBOLS;
const [popup, setPopup] = useState<{ nav: NavSegment; anchor: HTMLElement } | null>(null);
if (!status) return null;
const path = status.path;
if (!path) return null;
const parent = dirname(path);
const segments: Crumb[] = [];
if (project)
@@ -137,12 +152,12 @@ export function CodeBreadcrumbs({
const prefs = useEditorPrefs().breadcrumbs;
const language = useDocMeta((s) => s[path]?.language);
const outline = useOutline((s) => s[path]);
const status = useEditorStatus((s) => (s.status?.path === path ? s.status : null));
const slot = editorController().slots.get(tabId);
const caret = status ?? (slot ? slot.caret() : null);
const line = caret?.line ?? 0;
const col = caret?.col ?? 0;
const chain = useMemo(() => (outline ? symbolChain(outline, line, col) : []), [outline, line, col]);
let chain = useCaretSymbols(path, outline);
if (!chain) {
// Not the active editor (another split): its own caret, as of this render.
const caret = editorController().slots.get(tabId)?.caret();
chain = outline ? symbolChain(outline, caret?.line ?? 0, caret?.col ?? 0) : NO_SYMBOLS;
}
if (
!prefs.show ||
prefs.placement !== position ||
+76
Переглянути файл
@@ -0,0 +1,76 @@
import { Profiler } from "react";
import { describe, expect, it, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import type { OutlineItem } from "@/platform/ipc";
import type { EditorStatus } from "../controller";
vi.mock("../controller", async () => {
const { create } = await import("zustand");
return {
editorController: () => ({ openFile: vi.fn(), slots: new Map() }),
useEditorStatus: create<{ status: EditorStatus | null }>(() => ({ status: null })),
useGotoLine: create(() => ({ open: false })),
};
});
const { useEditorStatus } = await import("../controller");
const { useOutline } = await import("../model/document");
const { Breadcrumbs, CodeBreadcrumbs } = await import("./StatusItems");
const { NavBarPopup } = await import("./NavBarPopup");
const PATH = "/p/Orders.ts";
const span = (from: number, to: number) => ({ start: { line: from, col: 0 }, end: { line: to, col: 0 } });
const item = (name: string, from: number, to: number, children: OutlineItem[] = []): OutlineItem =>
({
name,
detail: null,
kind: "method",
range: span(from, to),
selection_range: span(from, from),
children,
}) as OutlineItem;
const caret = (line: number, col: number): EditorStatus => ({
path: PATH,
doc: 1,
line,
col,
offset: line * 80 + col,
selectedChars: 0,
selectedLines: 0,
carets: 1,
});
describe("caret-driven status widgets", () => {
it("re-render when the symbol at the caret changes, not on every caret move", () => {
useOutline.setState({ [PATH]: [item("Orders", 0, 40, [item("place", 2, 10), item("cancel", 12, 20)])] });
act(() => useEditorStatus.setState({ status: caret(3, 4) }));
let commits = 0;
const count = () => void commits++;
render(
<>
<Profiler id="breadcrumbs" onRender={count}>
<Breadcrumbs />
</Profiler>
<Profiler id="code" onRender={count}>
<CodeBreadcrumbs path={PATH} tabId="t1" position="bottom" />
</Profiler>
<Profiler id="navbar" onRender={count}>
<NavBarPopup />
</Profiler>
</>,
);
expect(screen.getAllByText("place")).toHaveLength(2);
commits = 0;
// Typing inside `place`: line and column move, the chain does not.
for (let col = 5; col < 25; col++) act(() => useEditorStatus.setState({ status: caret(4, col) }));
expect(commits).toBe(0);
// Into `cancel`: both breadcrumbs follow.
act(() => useEditorStatus.setState({ status: caret(14, 0) }));
expect(screen.getAllByText("cancel")).toHaveLength(2);
expect(screen.queryByText("place")).toBeNull();
expect(commits).toBeGreaterThan(0);
});
});