From d1f84ef3644b365031a2a5bac45527ca30e96ded Mon Sep 17 00:00:00 2001 From: Jaw <53564781+jaw52@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:41:03 +0800 Subject: [PATCH] perf(editor): stop re-rendering breadcrumbs on every caret move (#256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 <53564781+jaw52@users.noreply.github.com> --- e2e/editor.spec.ts | 24 +++++++ src/editor/core/view/NavBarPopup.tsx | 7 ++- src/editor/core/view/StatusItems.tsx | 45 +++++++++----- src/editor/core/view/statusItems.test.tsx | 76 +++++++++++++++++++++++ 4 files changed, 136 insertions(+), 16 deletions(-) create mode 100644 src/editor/core/view/statusItems.test.tsx diff --git a/e2e/editor.spec.ts b/e2e/editor.spec.ts index 8ce7092eb..4f8163dfc 100644 --- a/e2e/editor.spec.ts +++ b/e2e/editor.spec.ts @@ -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(); +}); diff --git a/src/editor/core/view/NavBarPopup.tsx b/src/editor/core/view/NavBarPopup.tsx index c3bc81e0c..74ef75732 100644 --- a/src/editor/core/view/NavBarPopup.tsx +++ b/src/editor/core/view/NavBarPopup.tsx @@ -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 ? : 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 ; } diff --git a/src/editor/core/view/StatusItems.tsx b/src/editor/core/view/StatusItems.tsx index 082851bab..99776feb9 100644 --- a/src/editor/core/view/StatusItems.tsx +++ b/src/editor/core/view/StatusItems.tsx @@ -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 || diff --git a/src/editor/core/view/statusItems.test.tsx b/src/editor/core/view/statusItems.test.tsx new file mode 100644 index 000000000..6e3cbcf22 --- /dev/null +++ b/src/editor/core/view/statusItems.test.tsx @@ -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( + <> + + + + + + + + + + , + ); + 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); + }); +});