Skip to content

Memoize re-render scope in the workspace Ink runtime #1079

@gfargo

Description

@gfargo

Summary

Every keystroke in the workspace surface re-renders the whole React tree: header chips, sidebar (recomputes `repos.filter(...)` four times for the tab counts), main list (recomputes the windowed row slice), footer, help model. On a 50-repo workspace each cursor move calls `selectVisibleRepos` 3+ times. The flicker users used to see ([fixed in #1056 via `flexWrap` removal] / [#1060 via stable input handler]) was mostly that cost being exposed. The cost is still there; we just don't notice it under normal load.

Memoize the render path so a cursor move only re-renders the cursored row + footer.

Approach

Two complementary techniques:

  1. Hoist pure computations behind `useMemo` in WorkspaceInkApp:

    • selectVisibleRepos(state) — keyed on (overview, sortMode, tab, filter, pullRequestCounts). Currently re-computed on every keystroke even when the inputs haven't changed.
    • buildWorkspaceSidebar(state) — same key.
    • buildWorkspaceListWindow(state, { width, rows, spinnerTick }) — keyed on (visibleRepos, selectedIndex, width, rows, spinnerTick). The window slice is the heaviest single computation.
    • buildWorkspaceHeaderChips(state, …) — keyed on its inputs.
  2. Wrap section renderers in `React.memo`:

    • A <WorkspaceHeaderRow> that takes pre-built chips.
    • A <WorkspaceSidebarPanel> that takes the prebuilt sidebar model.
    • A <WorkspaceListPanel> that takes the windowed rows + layout dims.
    • A <WorkspaceFooter> that takes the footer model.

When the user moves the cursor (j/k), only selectedIndex changes. Memoization should make the header / sidebar / footer no-op, and the list panel re-render with just one row's cursor state changing.

Acceptance criteria

  • selectVisibleRepos is computed at most once per render.
  • Cursor moves (j / k) don't trigger the sidebar / header / footer renderers (verifiable by adding a renderCount.current++ in a useEffect per panel during development).
  • No regression in snapshots — the rendered tree is identical.
  • No regression in test suite.
  • Spinner ticks continue to update only the list panel (the spinner-only-runs-while-fetching effect from feat(workspace): per-row inline loaders + per-row refresh (R) #1069 still gates the tick interval).

Files affected

  • src/workstation/surfaces/workspace/runtime.ts — primary. Component split + memoization.
  • src/workstation/surfaces/workspace/view.ts — may need to split `renderWorkspaceApp` into per-panel React components instead of plain functions.
  • src/workstation/surfaces/workspace/view.test.ts — snapshots may need refresh if component boundaries change the rendered tree.

Risk

  • Medium. Memoization bugs are subtle — a wrong dep array can make the panel render stale data without crashing. The snapshot tests will catch most cases; manual TTY testing required to confirm cursor moves actually skip the unrelated panels.
  • Touching the render tree shape may shift snapshots even when the visible output is identical (React.memo wrappers introduce additional <Component> nodes in the serialized snapshot).
  • Test seam: consider exposing a `renderCount` ref or similar so we can assert "header didn't re-render on cursor move" — otherwise the perf claim is hand-waved.

Effort estimate

~2 hours including snapshot refresh + manual TTY testing.

Out of scope

Why defer

The visible flicker from earlier sessions was largely caused by flexWrap (#1056) and stdin churn (#1060), both already fixed. With those out, the re-render cost is paid but not painful. Optimization is more about long-term architecture cleanliness than a current user-visible problem. Pick this up when:

  • A user reports lag on a workspace with 100+ repos, or
  • We're already in runtime.ts for the split refactor and can do both at once.

Sourced from the post-#880 workspace surface audit.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions