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);
+ });
+});