Repository navigation
π€ perf: make ProjectSidebar compile under React CompilerΒ #4439
Description
Activity
linear-code commented
on Sep 24, 2026 linear-codeboton Sep 24, 2026 β with Linear CodeMore actionsStatus update:
- ImmersiveReviewView is handled by π€ perf: let React Compiler compile ImmersiveReviewViewΒ #4457 (state mirror for the hunk-jump range, layout-effect ref syncs,
runWithCatchFinally). Immersive hunk-iteration script time dropped from 355 to 290 ms (median of 5). - ProjectSidebarInner stays deferred. Re-checking it on main at 334a5a6 found a second blocker beyond the render-time session caches:
- The render path reads the workspace store imperatively (
isWorkspaceLiveActive,hasActiveBashMonitor,getActiveWorkflowRunIds, all throughgetWorkspaceAttentionSignal/getWorkspaceSidebarState), anduseWorkspaceAttentionSubscriptiononly bumps an internal version to force a re-render. Once compiled, memo blocks that call these closures would not see that version as a dependency, so activity dots, workflow groups, and delegated-activity summaries could go stale after an attention change. Making it compile safely needs both changes below, and they are behavioral refactors of a hot, 2600-line component.
- The render path reads the workspace store imperatively (
Proposed split:
- Reactive attention input.
useWorkspaceAttentionSubscriptionreturns its version (or a snapshot), and the three read helpers capture it so compiled memo blocks re-run on every attention change. Small. Add a test showing the working indicator and workflow grouping update after a store-only signal change. - Session caches as state. Extract a pure
advanceSidebarSessionCaches(prev, groups, activeRuns)covering sticky expansion, retained workflow headers, and run names. Characterize it with tests (explicit toggle wins, header survives zero-worker gaps, pruning when the run ends, grouped and flat layouts). Hold the result in state and advance it with a guarded, converging setState during render. This needs a pre-pass that computes task groups for every rendered list before rendering. That pre-pass is the risky part and must stay within about 500 lines, or be split again. - Compile. Remove the two
exhaustive-depssuppressions (draft onperf-compiler-projectsidebar-draft, which keeps the A β empty β A normalization) and thetry/finally, and dropProjectSidebarInnerfromKNOWN_SKIPPEDin the PR that first producesCompileSuccess. Verify with Storybook at desktop and narrow widths and with a React profile of the sidebar while another workspace streams.
Owner: perf owner (Xum agent). Trigger: after #4457 lands.
- ImmersiveReviewView is handled by π€ perf: let React Compiler compile ImmersiveReviewViewΒ #4457 (state mirror for the hunk-jump range, layout-effect ref syncs,
- changed the title
[-]π€ perf: make ProjectSidebar and ImmersiveReviewView compile under React Compiler[/-][+]π€ perf: make ProjectSidebar compile under React Compiler[/+]on Sep 24, 2026 Measurement: is making ProjectSidebar compile worth a behavior-changing refactor?
Result: no, below the gate. While a workspace streams, sidebar rendering takes about 3β4% of renderer script time (median), and it is not re-rendered per streaming delta. I propose keeping
ProjectSidebarInnerinKNOWN_SKIPPEDand not doing the 3-PR refactor for now.Setup
- Electron perf e2e on main at 7b215e4, local, runs one at a time, 3 runs per scenario, 5 s profile window.
- Fixture: 49 workspaces across 3 projects (16 + 16 + 16, plus the demo workspace), all projects expanded. 36 rows were mounted; the rest sat in collapsed older-workspace tiers.
- Streaming: the mock AI streams about 40 deltas/s (24 chars every 25 ms).
- Renderer build: a profiling build (
vite build --mode profiling, withreact-dom/clientrouted toreact-dom/profiling), so<React.Profiler>works. - Temporary instrumentation (not committed):
- a Profiler around
<ProjectSidebar>inLeftSidebar(render-phase time of the sidebar subtree only), - a Profiler around
<ChatPane>for comparison, - a per-render counter in
AgentListItemInner.
- a Profiler around
- Share = sidebar Profiler
actualDurationΓ· ChromeScriptDurationover the same window.
Results (medians; per-run values in brackets)
Scenario Renderer script Sidebar renders Sidebar render time Share of script Row renders ChatPane render time Viewed workspace streams 227 ms/s 3.4/s 10.4 ms/s [10.4, 8.3, 11.1] 4.2% [4.2, 4.1, 4.9] 32.6/s 177 ms/s Viewed + one background workspace stream 214 ms/s 3.6/s 6.7 ms/s [6.7, 5.1, 24.4] 2.9% [2.9, 2.5, 11.4] 32.7/s 168 ms/s Background stream only (viewed workspace idle) 3 ms/s 0.2/s 0.1 ms/s ~2% of a near-idle renderer 0/s 1.4 ms/s - Not per delta: the sidebar renders about 3.5 times per second, not about 40 (once per delta). Averaged over deltas that is about 0.25 ms per streaming frame, well under 2 ms.
- Per render: about 2β3 ms on median, and each render re-renders about 10 of the 36 rows. Memoized rows skip the rest.
- One outlier: a single 41.7 ms sidebar render in one of three runs of the viewed-plus-background scenario (that run's share was 11.4%). It coincides with a stream transition (start or end). Median single-render maxima are 12β16 ms.
- Comparison: ChatPane rendering is 17β25Γ larger than the sidebar's during streaming. That is where the remaining streaming cost is, and it is already compiled.
Decision
This is below the gate (β₯5% of renderer script time, or β₯2 ms per streaming frame at 50 workspaces). The refactor would turn the session caches into state and make the store reads explicit inputs, both behavior-changing in a 2600-line component. The expected gain is at most about 4% of renderer script time while streaming, and less while idle.
Proposal:
- Keep
ProjectSidebarInnerinKNOWN_SKIPPED. The reason is recorded here: render-time session caches, plus imperative store reads behind a version-bump subscription. Compiling it safely requires the refactor, and the measured payoff is small. - Close or park this issue. Re-open it if a profile shows the sidebar above the gate, for example with many more visible rows, or if the about 3.5/s re-render trigger or the transition spike becomes user-visible. The draft branch
perf-compiler-projectsidebar-draftstays as-is. - Possible cheaper follow-up (not started): find which subscription drives the about 3.5 sidebar renders/s while streaming, and the spike at stream transitions. That may be fixable without compiling the component.
Measurement patch (temporary spec + instrumentation, not committed)
diff --git a/src/browser/components/AgentListItem/AgentListItem.tsx b/src/browser/components/AgentListItem/AgentListItem.tsx index 90301787d6..46540f05f9 100644 --- a/src/browser/components/AgentListItem/AgentListItem.tsx +++ b/src/browser/components/AgentListItem/AgentListItem.tsx @@ -79,6 +79,7 @@ import { WorkspaceActionsMenuContent } from "../WorkspaceActionsMenuContent/Work import { hasWorkspaceRepository } from "@/browser/utils/workspaceCapabilities"; import { useAPI } from "@/browser/contexts/API"; import { useWorkspaceActionsOptional } from "@/browser/contexts/WorkspaceContext"; +import { recordSyntheticReactRenderSample as __recordSample } from "@/browser/utils/perf/reactProfileCollector"; export interface WorkspaceSelection { projectPath: string; @@ -1483,6 +1484,9 @@ function RegularAgentListItemInner(props: AgentListItemProps) { type UnifiedAgentListItemProps = AgentListItemProps | DraftAgentListItemProps; function AgentListItemInner(props: UnifiedAgentListItemProps) { + React.useLayoutEffect(() => { + __recordSample({ id: "measure.agent-row", phase: "update", actualDuration: 0, baseDuration: 0, startTime: 0, commitTime: 0 }); + }); if (props.variant === "draft") { return <DraftAgentListItemInner {...props} />; } diff --git a/src/browser/components/LeftSidebar/LeftSidebar.tsx b/src/browser/components/LeftSidebar/LeftSidebar.tsx index 55e41a26d1..890f7db2ef 100644 --- a/src/browser/components/LeftSidebar/LeftSidebar.tsx +++ b/src/browser/components/LeftSidebar/LeftSidebar.tsx @@ -5,6 +5,7 @@ import { LEFT_SIDEBAR_COLLAPSED_WIDTH_PX, LEFT_SIDEBAR_DEFAULT_WIDTH_PX } from " import ProjectSidebar from "../ProjectSidebar/ProjectSidebar"; import { TitleBar } from "../TitleBar/TitleBar"; import { isDesktopMode } from "@/browser/hooks/useDesktopTitlebar"; +import { recordSyntheticReactRenderSample as __recordSample } from "@/browser/utils/perf/reactProfileCollector"; interface LeftSidebarProps { collapsed: boolean; @@ -74,11 +75,13 @@ export function LeftSidebar(props: LeftSidebarProps) { style={{ width }} > {!collapsed && <TitleBar onBeforeOpenSettings={handleBeforeOpenSettings} />} + <React.Profiler id="measure.sidebar" onRender={(id: string, phase: "mount" | "update" | "nested-update", actualDuration: number, baseDuration: number, startTime: number, commitTime: number) => __recordSample({ id, phase: phase === "nested-update" ? "update" : phase, actualDuration, baseDuration, startTime, commitTime })}> <ProjectSidebar {...projectSidebarProps} collapsed={collapsed} onToggleCollapsed={onToggleCollapsed} /> + </React.Profiler> {!collapsed && !isMobileOverlay && onStartResize && ( <div diff --git a/src/browser/components/WorkspaceShell/WorkspaceShell.tsx b/src/browser/components/WorkspaceShell/WorkspaceShell.tsx index 10b2a336be..57077e0a09 100644 --- a/src/browser/components/WorkspaceShell/WorkspaceShell.tsx +++ b/src/browser/components/WorkspaceShell/WorkspaceShell.tsx @@ -26,6 +26,7 @@ import { LEFT_SIDEBAR_MIN_WIDTH_PX, } from "@/constants/layout"; import { ChatPane } from "../ChatPane/ChatPane"; +import { recordSyntheticReactRenderSample as __recordSample } from "@/browser/utils/perf/reactProfileCollector"; // ChatPane uses tailwind `min-w-96`. const CHAT_PANE_MIN_WIDTH_PX = 384; @@ -230,6 +231,7 @@ export const WorkspaceShell: React.FC<WorkspaceShellProps> = (props) => { {/* Keep the transcript viewport mounted across workspace switches so the browser doesn't visually tear the pane while the new workspace content hydrates. ChatPane resets its per-workspace local UI state internally, and the composer remains keyed by workspaceId. */} + <React.Profiler id="measure.chat-pane" onRender={(id: string, phase: "mount" | "update" | "nested-update", actualDuration: number, baseDuration: number, startTime: number, commitTime: number) => __recordSample({ id, phase: phase === "nested-update" ? "update" : phase, actualDuration, baseDuration, startTime, commitTime })}> <ChatPane workspaceId={props.workspaceId} projectPath={props.projectPath} @@ -242,6 +244,7 @@ export const WorkspaceShell: React.FC<WorkspaceShellProps> = (props) => { onOpenTerminal={handleOpenTerminal} immersiveHidden={isReviewImmersive} /> + </React.Profiler> <RightSidebar key={props.workspaceId} diff --git a/src/vite/__measureReactDomClient.ts b/src/vite/__measureReactDomClient.ts new file mode 100644 index 0000000000..c568f931af --- /dev/null +++ b/src/vite/__measureReactDomClient.ts @@ -0,0 +1,4 @@ +// TEMPORARY (#4439 measurement, not for commit): route createRoot through react-dom/profiling. +import { createRoot, hydrateRoot } from "react-dom/profiling"; +export { createRoot, hydrateRoot }; +export default { createRoot, hydrateRoot }; diff --git a/tests/e2e/scenarios/perf.sidebarStreaming.spec.ts b/tests/e2e/scenarios/perf.sidebarStreaming.spec.ts new file mode 100644 index 0000000000..b53264700d --- /dev/null +++ b/tests/e2e/scenarios/perf.sidebarStreaming.spec.ts @@ -0,0 +1,121 @@ +// TEMPORARY measurement spec for #4439 (not for commit): profiles ProjectSidebar render +// cost while workspaces stream, with ~50 workspaces across 3 projects. +import fs from "fs"; +import path from "path"; +import { spawnSync } from "child_process"; +import { electronTest as base, electronExpect as expect } from "../electronTest"; +import { getXumE2EEnv } from "../env"; +import { Config } from "../../../src/node/config"; +import { + readReactProfileSnapshot, + resetReactProfileSamples, + withChromeProfiles, + writePerfArtifacts, +} from "../utils/perfProfile"; + +const WORKSPACES_PER_PROJECT = Number(process.env.SIDEBAR_WS_PER_PROJECT ?? "16"); +const PROJECTS = 3; +const WINDOW_MS = 5_000; + +function gitInit(dir: string): void { + fs.mkdirSync(dir, { recursive: true }); + spawnSync("git", ["init", "-q"], { cwd: dir }); + spawnSync("git", ["config", "commit.gpgsign", "false"], { cwd: dir }); + spawnSync( + "git", + [ + "-c", + "user.email=t@e.com", + "-c", + "user.name=T", + "commit", + "--allow-empty", + "-q", + "-m", + "init", + ], + { cwd: dir } + ); +} + +const test = base.extend({ + workspace: async ({ workspace }, use) => { + const root = workspace.configRoot; + const config = new Config(root); + const payload = JSON.parse(fs.readFileSync(workspace.demoProject.configPath, "utf-8")) as { + projects: Array<[string, { workspaces: Array<{ path: string }> }]>; + }; + for (let p = 0; p < PROJECTS; p++) { + const projectName = p === 0 ? path.basename(workspace.demoProject.projectPath) : `proj-${p}`; + const projectPath = + p === 0 ? workspace.demoProject.projectPath : path.join(root, "fixtures", projectName); + if (p !== 0) gitInit(projectPath); + let entry = payload.projects.find(([pp]) => pp === projectPath); + if (!entry) { + entry = [projectPath, { workspaces: [] }]; + payload.projects.push(entry); + } + for (let i = 0; i < WORKSPACES_PER_PROJECT; i++) { + const name = `ws-p${p}-${i}`; + const wsPath = path.join(root, "src", projectName, name); + gitInit(wsPath); + const id = config.generateLegacyId(projectPath, wsPath); + entry[1].workspaces.push({ path: wsPath }); + const sessionDir = path.join(workspace.demoProject.sessionsDir, id); + fs.mkdirSync(sessionDir, { recursive: true }); + fs.writeFileSync( + path.join(sessionDir, "metadata.json"), + JSON.stringify({ id, name, projectName, projectPath }) + ); + fs.writeFileSync(path.join(sessionDir, "chat.jsonl"), ""); + } + } + fs.writeFileSync(workspace.demoProject.configPath, JSON.stringify(payload, null, 2)); + await use(workspace); + }, +}); + +const LONG = (tag: string, n: number) => + `${tag} ` + "streaming sidebar perf sample text ".repeat(n); + +test.describe("sidebar streaming perf (temporary)", () => { + test.skip(getXumE2EEnv("E2E_RUN_PERF") !== "1", "perf only"); + + for (const mode of [ + "viewed-stream", + "viewed-plus-background-stream", + "background-only", + ] as const) { + test(`perf: sidebar during ${mode}`, async ({ ui, page }, testInfo) => { + const nav = page.getByRole("navigation", { name: "Projects" }); + await ui.projects.openFirstWorkspace(); + // Expand every project so all rows are mounted. + for (const btn of await nav.getByRole("button", { name: /expand project/i }).all()) { + await btn.click().catch(() => undefined); + } + const rowCount = await nav.locator('div[role="button"]').count(); + const demoName = "demo-review"; + + if (mode !== "viewed-stream") { + await nav.getByText("ws-p1-3", { exact: true }).click(); + await ui.chat.sendMessage(LONG("bg", 400)); // ~14k chars -> ~14 s stream + await page.waitForTimeout(300); + await nav.getByText(demoName, { exact: true }).click(); + await ui.chat.waitForTranscript(); + } + + await resetReactProfileSamples(page); + const runLabel = `sidebar-${mode}-${rowCount}rows`; + const chromeProfile = await withChromeProfiles(page, { label: runLabel }, async () => { + if (mode !== "background-only") { + await ui.chat.sendMessage(LONG("fg", 250)); // ~9k chars -> ~9 s stream + } + await page.waitForTimeout(WINDOW_MS); + }); + const reactProfile = await readReactProfileSnapshot(page); + if (!reactProfile) throw new Error("no react profile"); + await writePerfArtifacts({ testInfo, runLabel, chromeProfile, reactProfile }); + expect(rowCount).toBeGreaterThan(20); + }); + } +}); diff --git a/vite.config.ts b/vite.config.ts index d97a80ce8a..6ba746dd30 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -77,6 +77,7 @@ export default defineConfig(({ mode }) => { if (isProfiling) { aliasMap["react-dom$"] = "react-dom/profiling"; + aliasMap["react-dom/client"] = path.resolve(__dirname, "./src/vite/__measureReactDomClient.ts"); aliasMap["scheduler/tracing"] = "scheduler/tracing-profiling"; }
Generated with
xumβ’ Model:anthropic:claude-opus-5-5β’ Thinking:highβ’ Cost:$4.05- added a commit that references this issue
on Sep 25, 2026 - addedinvestigationTriage: proposal / research / trackingTriage: proposal / research / tracking
on Oct 9, 2026
Summary
Two hot components are still skipped by React Compiler and stay in
KNOWN_SKIPPEDinscripts/check_react_compiler_coverage.ts(guard from #4422, fixes in the #4425β#4429 stack). Neither can compile with a syntax-level fix; both need a small state refactor with its own tests.ProjectSidebarInner (
src/browser/components/ProjectSidebar/ProjectSidebar.tsx)Blocker: three render-time mutable session caches, mutated during render on purpose (design D6):
sessionActiveTaskGroupKeysRef(workflow groups active this session stay expanded),retainedWorkflowTaskGroupsRef(a run's group header survives between sequential worker steps),workflowRunNamesRef(run names survive past their workers).Mutations are spread across the flat pre-pass, the per-project grouping function, and a JSX callback. Hiding them behind an uncompiled hook would let compiled memo blocks read stale cache contents, so that shortcut is rejected.
Proposed split:
advance(prev, inputs) β next | prevfunction, with tests for sticky expansion, explicit toggles winning, headers and names surviving zero-worker gaps, and pruning of inactive runs, in both grouped and flat layouts.react-hooks/exhaustive-depssuppressions and thetry/finally, and dropProjectSidebarInnerfromKNOWN_SKIPPEDin the same PR that first producesCompileSuccess. Verify with Storybook at desktop and narrow widths, and with the workspace-open perf e2e.A preparation draft (suppressions,
try/finally) exists on branchperf-compiler-projectsidebar-draft. Its normalization latch must keep the original A β empty β A re-normalization.ImmersiveReviewView (
src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.tsx)Blocker:
selectedLineSummary(a render-pathuseMemo) callsgetCurrentLineSelection(), which readshunkJumpLineRangeRefduring render. The fix is to move the hunk-jump range into state, which changes render timing on the hunk-iteration hot path. It needs before/after numbers fromperf.reviewHunkIteration.spec.ts. The remaining blockers (??=,try/finally, three render-time latest-ref writes) are mechanical.Owner: perf owner (Xum agent) via the React Compiler coverage work; trigger: after the #4425β#4429 stack lands.
Generated with
xumβ’ Model:anthropic:claude-opus-5-5β’ Thinking:highβ’ Cost:$4.05