Skip to content

Changes tab freezes the web UI on large diffs (no virtualization; toggling tabs re-mounts the whole tree) #809

Description

@davidmoshal

Summary

First off — really enjoying Dispatch so far. The overall design is thoughtful, the feature set (worktrees, personas, jobs, MCP tooling, live terminals) is impressive, and the documentation is impressively thorough.

This is a small performance report in that spirit, not a blocker.

The Changes tab is a great feature, but on a worktree with a large diff it can make the web UI unresponsive for several seconds, and toggling between Terminal and Changes re-triggers the freeze even with cached data. This looks like a rendering-scale issue rather than a data-fetching one.

Steps to reproduce

  1. Run an agent in a worktree that accumulates a large diff (in my case ~87k changed lines across many files).
  2. In the web UI, focus that agent and click the Changes tab in the center pane.
  3. Observe: the tab hangs for several seconds before content is usable.
  4. Click Terminal, then click Changes again.
  5. Observe: the UI freezes again on re-entering Changes — this reproduces even with cached data, and any refetch is in the background (React Query structural sharing preserves unchanged per-file identity, so a refetch alone doesn't re-parse).

Expected

The Changes tab stays responsive on large diffs, and toggling away and back is near-instant (cached data, no re-mount cost).

Actual

Initial render and every subsequent toggle both freeze the UI for seconds at a time on a large worktree.

Likely cause (hypothesis, with file references)

I traced the render path while diagnosing this. A few compounding factors, all of which scale with diff size:

  1. No virtualization of the file list. DiffPane maps over all files and mounts every FileDiffSection at once:

    • apps/web/src/components/app/changes-diff-section.tsx:99{files.map((file) => <FileDiffSection ... />)}

    A grep for react-window|react-virtual|@tanstack/react-virtual across apps/web/src returns no matches, so neither the file list nor the per-file diff lines are windowed.

  2. Files are expanded by default. apps/web/src/lib/store.ts:205:

    const defaultDiffViewState: DiffViewState = {
      collapsedFiles: [], // empty = all files expanded
      ...
    };

    So on first render, every file's full diff is mounted and laid out.

    Note: I personally prefer it expanded by default too.

  3. Per-file synchronous parse + syntax highlight on the main thread. apps/web/src/components/app/unified-diff-view.tsx runs parseDiff(...) and tokenize(..., { highlight: true, refractor }) inside useMemo per file, then renders a <table> with one <tr> per diff line. With everything expanded this produces tens of thousands of DOM nodes in a single synchronous React commit.

  4. Toggling tabs unmounts the whole tree. apps/web/src/components/app/changes-tab.tsx short-circuits when inactive:

    if (!active) return <div />;

    Going to Terminal unmounts the 87k-node tree; coming back rebuilds it from scratch. That's why the second toggle (cached data, no fetch) still freezes — it's pure reconciliation + layout.

  5. Memo defeat amplifies in-tab re-renders. DiffPane passes a fresh draftComments?.filter(...) array, a fresh feedbackItems?.filter(...) array, and fresh closures (onToggleCollapse, setRef) to each FileDiffSection per render (apps/web/src/components/app/changes-diff-section.tsx:120,125). UnifiedDiffView is memo'd, but those prop identities change on every DiffPane render, so any ChangesTab state change — a gutter click, opening a comment, a review-draft keystroke, or the debounced scroll-position writeback into the localStorage-backed diffViewStateAtomFamily (apps/web/src/components/app/changes-tab.tsx:251-261) — re-reconciles every expanded file. parseDiff/tokenize memos survive (input is stable), but re-reconciling tens of thousands of <tr>s still stalls. This explains sluggishness inside the tab, not just at mount.

Net effect: with ~87k lines expanded, the browser does style recalc + layout + paint for a huge DOM in one go, on the main thread. Hence the freeze.

It's worth noting that the server already guards the per-file case — diffs are truncated at 2000 lines / 100 KB per file, with a Load diff button on the client, and the file list is capped at 1000 files (apps/server/src/shared/git/agent-diff.ts). Those guards are welcome and handle the pathological single-file case. The problem here is the aggregate: up to 1000 files × 2000 lines is still an unbounded amount of work to mount in one synchronous commit, which is exactly the ~87k-line scenario.

Note on Web Workers

My first instinct was "move parsing to a worker thread," but having traced the path I don't think that's the right fix on its own. The bottleneck is synchronous DOM rendering (and React reconciliation), which can't run in a worker — the DOM only exists on the main thread. (react-diff-view's tokenize is technically worker-able — its output is serializable and the library documents a worker pattern — but that only offloads the per-file CPU; it doesn't touch the reconciliation or layout cost, which is the dominant issue here.) A worker would only help after virtualization, and by then the remaining per-file CPU work is small enough that it likely isn't worth the complexity.

Environment

  • Dispatch v0.29.4 (running via bin/dispatch-dev up --live)
  • Web UI, macOS, Apple Silicon
  • Worktree with ~87k changed lines across many files

Suggested fix (ranked by impact)

  1. Virtualize the file list with @tanstack/react-virtual so only FileDiffSections near the viewport are mounted. Biggest win — off-screen files no longer parse, tokenize, or render. File-granularity virtualization suffices (single giant files are already server-truncated). If implementing, the non-obvious gotchas are: variable row heights (measureElement), scrollToFile currently uses DOM refs + scrollIntoView which breaks for unmounted rows (use virtualizer.scrollToIndex), persisted scrollTop restore, and sticky headers inside a virtualizer.

    Cheaper alternative worth considering: CSS content-visibility: auto + contain-intrinsic-size on each FileDiffSection. Near-zero risk, keeps the existing refs/sticky/scrollIntoView machinery working, and kills the layout/paint storm (the dominant cost). It doesn't avoid mount-time parse/tokenize CPU, but pairing it with an IntersectionObserver-gated FileDiffContent mount captures most of virtualization's win at a fraction of the rework. Offering both so the maintainers can pick the complexity level that fits.

  2. Default-collapse files when the count is large (e.g., collapse-all when files.length exceeds a threshold such as 20). Preserves current behavior for small diffs; cheap partial fix. Note: diffViewStateAtomFamily persists per-agent in localStorage, so a threshold-based default would only govern the first visit.

  3. Lazy syntax highlighting — skip highlight: true in tokenize for off-screen / unmounted files (falls out of Add CI workflow and launchd runtime wrapper #1).

  4. Keep ChangesTab mounted when inactive instead of return <div /> (hide via CSS), so toggling Terminal ↔ Changes doesn't re-mount the tree. Addresses the second-toggle freeze directly, though display:none → visible still forces a full style/layout pass on the subtree (cheaper than a remount, not free) and holds the 87k-line DOM in memory while on Terminal. Mostly becomes unnecessary once Add CI workflow and launchd runtime wrapper #1 lands (a remount is then viewport-sized), so this is a nice-to-have after Add CI workflow and launchd runtime wrapper #1 rather than co-equal with it.

Happy to try implementing #1 (virtualization, or the content-visibility alternative) if a contribution would be welcome — wanted to file the findings first in case the maintainers have a preferred direction. Thanks for the great project!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions