Skip to content

πŸ€– perf: make ProjectSidebar compile under React CompilerΒ #4439

Description

@ThomasK33

Summary

Two hot components are still skipped by React Compiler and stay in KNOWN_SKIPPED in scripts/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:

  1. Characterize and extract the cache transition as a pure advance(prev, inputs) β†’ next | prev function, 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.
  2. Hold the caches in immutable React state advanced during render (guarded, converging setState), remove the two react-hooks/exhaustive-deps suppressions and the try/finally, and drop ProjectSidebarInner from KNOWN_SKIPPED in the same PR that first produces CompileSuccess. Verify with Storybook at desktop and narrow widths, and with the workspace-open perf e2e.

A preparation draft (suppressions, try/finally) exists on branch perf-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-path useMemo) calls getCurrentLineSelection(), which reads hunkJumpLineRangeRef during 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 from perf.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

Activity

  1. linear-code commented on Sep 24, 2026

    @linear-code
  2. ThomasK33 commented on Sep 24, 2026

    @ThomasK33
    MemberAuthor

    Status 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 through getWorkspaceAttentionSignal/getWorkspaceSidebarState), and useWorkspaceAttentionSubscription only 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.

    Proposed split:

    1. Reactive attention input. useWorkspaceAttentionSubscription returns 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.
    2. 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.
    3. Compile. Remove the two exhaustive-deps suppressions (draft on perf-compiler-projectsidebar-draft, which keeps the A β†’ empty β†’ A normalization) and the try/finally, and drop ProjectSidebarInner from KNOWN_SKIPPED in the PR that first produces CompileSuccess. 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.

  3. changed the title [-]πŸ€– perf: make ProjectSidebar and ImmersiveReviewView compile under React Compiler[/-] [+]πŸ€– perf: make ProjectSidebar compile under React Compiler[/+] on Sep 24, 2026
  4. ThomasK33 commented on Sep 24, 2026

    @ThomasK33
    MemberAuthor

    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 ProjectSidebarInner in KNOWN_SKIPPED and 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, with react-dom/client routed to react-dom/profiling), so <React.Profiler> works.
    • Temporary instrumentation (not committed):
      • a Profiler around <ProjectSidebar> in LeftSidebar (render-phase time of the sidebar subtree only),
      • a Profiler around <ChatPane> for comparison,
      • a per-render counter in AgentListItemInner.
    • Share = sidebar Profiler actualDuration Γ· Chrome ScriptDuration over 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 ProjectSidebarInner in KNOWN_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-draft stays 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    investigationTriage: proposal / research / tracking

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions