Skip to content

perf(desktop): fix diff sidebar performance with many commits#1531

Merged
Kitenite merged 5 commits into
mainfrom
kitenite/diff-sidebar-performance
Feb 16, 2026
Merged

perf(desktop): fix diff sidebar performance with many commits#1531
Kitenite merged 5 commits into
mainfrom
kitenite/diff-sidebar-performance

Conversation

@Kitenite
Copy link
Copy Markdown
Collaborator

@Kitenite Kitenite commented Feb 16, 2026

Summary

  • Diff sidebar became very laggy with many commits (~7 commits, ~14 files) due to broken virtualization, causing unbounded DOM growth
  • Custom rangeExtractor in VirtualizedFileList accumulated all rendered indices and never released them, defeating virtualization entirely
  • Every scrolled-past file diff stayed mounted with its IntersectionObservers, tRPC queries, and syntax-highlighted diff viewers

Changes

  • Fix virtualization: Replace custom rangeExtractor (which grew unboundedly) with defaultRangeExtractor + fixed overscan, so items outside the viewport are properly unmounted
  • Collapse commits by default: Previously all commit sections expanded on mount, creating a VirtualizedFileList + getCommitFiles query per commit immediately
  • Server-side status cache: Add 2s TTL cache on getStatus to debounce overlapping git status polls from multiple consumers (ChangesContent polling + sidebar hover)

Test Plan

  • Open diff sidebar on a branch with 5+ commits and 10+ changed files
  • Verify smooth scrolling through file diffs without lag
  • Verify typing in Claude Code terminal remains responsive while diff sidebar is open
  • Click a commit section to expand — verify files load correctly
  • Hover workspace items in sidebar — verify diff stats still appear

Summary by CodeRabbit

Release Notes

  • Performance

    • Status updates now cached server-side for faster response times
    • Virtual list rendering optimized for improved responsiveness
  • Changes

    • Commit sections default to collapsed state; expand to view file details

…owth

The custom rangeExtractor accumulated all previously rendered indices in
a ref and never removed them, causing every file diff section to stay
mounted permanently after scrolling past it. This defeated virtualization
entirely — with many commits/files, the DOM grew unboundedly with
IntersectionObservers, tRPC queries, and diff viewers for every item.

Switch to defaultRangeExtractor with a fixed overscan count so items
outside the visible window are properly unmounted.
Each poll runs ~8 git commands (status, rev-list, log, diff name-status,
diff numstat x3, plus reading untracked files). At 2.5s intervals this
saturates the Node process and blocks the UI. 10s is sufficient since the
user is reading diffs, not actively making changes.
With many commits, expanding all by default creates a VirtualizedFileList
and getCommitFiles query per commit on mount. Collapse by default so only
the sections the user clicks into are loaded.
Structural sharing in React Query prevents re-renders when data hasn't
changed, so the polling interval isn't the perf bottleneck — the broken
virtualization was. Keep 2.5s for snappy updates.
Multiple consumers poll getStatus (ChangesContent at 2.5s, sidebar on
hover). Each call runs ~8 git commands. Add a 2s TTL cache keyed by
worktreePath+defaultBranch so duplicate calls within the window return
the cached result without spawning any git processes.
@cursor
Copy link
Copy Markdown

cursor Bot commented Feb 16, 2026

You have run out of free Bugbot PR reviews for this billing cycle. This will reset on March 14.

To receive reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 16, 2026

📝 Walkthrough

Walkthrough

This PR optimizes data fetching and state management across the desktop application. It adds server-side caching to Git status operations with TTL-based invalidation, defers commit file fetching until the commit is expanded, and simplifies virtualizer range extraction by using the react-virtual library's default behavior instead of custom logic.

Changes

Cohort / File(s) Summary
Server-side Status Caching
apps/desktop/src/lib/trpc/routers/changes/status.ts
Introduces a TTL-based cache for getStatus results keyed by worktree path and default branch. Git initialization now occurs only on cache misses, avoiding redundant operations on frequent polls. Cache invalidation and result storage happen transparently within the endpoint.
Lazy-loading Commit Data
apps/desktop/src/renderer/.../CommitSection/CommitSection.tsx
Changes default CommitSection expansion state from true to false. The getCommitFiles query is now enabled only when isCommitExpanded is true, deferring data fetching until the user expands the commit.
Virtualizer Range Extraction Refactor
apps/desktop/src/renderer/.../VirtualizedFileList/VirtualizedFileList.tsx
Replaces custom range extraction logic with react-virtual's defaultRangeExtractor. Removes renderedIndicesRef state and useCallback-based extraction implementation, reducing custom state management while maintaining overscan and other virtualizer configuration.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 Cache the status, let queries rest,
Commit files expand when they're at their best,
Virtualizer hops with library grace,
Three little tweaks that quicken the pace! ✨

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main performance fix: addressing diff sidebar performance issues with many commits through virtualization and caching improvements.
Description check ✅ Passed The pull request description provides comprehensive context including the root cause, detailed changes, and a test plan. However, it does not follow the template structure with Type of Change, Testing as separate sections, and Related Issues.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch kitenite/diff-sidebar-performance

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fix all issues with AI agents
Verify each finding against the current code and only fix it if needed.


In `@apps/desktop/src/lib/trpc/routers/changes/status.ts`:
- Around line 14-19: statusCache never evicts entries causing unbounded growth;
when writing new entries (the code that sets statusCache for a
worktreePath:defaultBranch key) add eviction: remove any entries with timestamp
older than STATUS_CACHE_TTL_MS and enforce a cap (e.g. introduce
MAX_STATUS_CACHE_ENTRIES = 100) by deleting the oldest timestamped entries until
size <= cap. Implement this pruning logic in the same place that sets
statusCache so you call it before/after inserting the new {result,timestamp},
referencing STATUS_CACHE_TTL_MS and statusCache to locate where to add the
cleanup.
- Around line 34-38: Export a cache invalidation helper and call it from each
status-mutating RPC: add and export a function clearStatusCache() that clears
the in-memory statusCache (the Map used alongside STATUS_CACHE_TTL_MS and
cacheKey logic that reads cached.result) and then invoke clearStatusCache() at
the end of each mutation handler (stage, unstage, discard, commit) so the next
status() call bypasses stale entries; ensure the helper is imported where those
mutation resolvers are defined and call it after the mutation completes (success
path).

In
`@apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/components/CommitSection/CommitSection.tsx`:
- Around line 24-30: The commit files query currently leaves the expanded commit
area empty because commitFiles is undefined while loading; update the component
that calls electronTrpc.changes.getCommitFiles.useQuery (referencing
commitFiles, isCommitExpanded and the local files variable used in the
files.length > 0 guard) to render a small spinner/skeleton when the query is
loading and the commit is expanded (i.e., when isLoading && isCommitExpanded)
instead of letting the area stay blank; ensure the spinner is shown only during
loading and that normal files rendering remains unchanged once commitFiles
resolves.
🧹 Nitpick comments (3)
🤖 Fix all nitpicks with AI agents
Verify each finding against the current code and only fix it if needed.


In `@apps/desktop/src/lib/trpc/routers/changes/status.ts`:
- Around line 14-19: statusCache never evicts entries causing unbounded growth;
when writing new entries (the code that sets statusCache for a
worktreePath:defaultBranch key) add eviction: remove any entries with timestamp
older than STATUS_CACHE_TTL_MS and enforce a cap (e.g. introduce
MAX_STATUS_CACHE_ENTRIES = 100) by deleting the oldest timestamped entries until
size <= cap. Implement this pruning logic in the same place that sets
statusCache so you call it before/after inserting the new {result,timestamp},
referencing STATUS_CACHE_TTL_MS and statusCache to locate where to add the
cleanup.
- Around line 34-38: Export a cache invalidation helper and call it from each
status-mutating RPC: add and export a function clearStatusCache() that clears
the in-memory statusCache (the Map used alongside STATUS_CACHE_TTL_MS and
cacheKey logic that reads cached.result) and then invoke clearStatusCache() at
the end of each mutation handler (stage, unstage, discard, commit) so the next
status() call bypasses stale entries; ensure the helper is imported where those
mutation resolvers are defined and call it after the mutation completes (success
path).

In
`@apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/components/CommitSection/CommitSection.tsx`:
- Around line 24-30: The commit files query currently leaves the expanded commit
area empty because commitFiles is undefined while loading; update the component
that calls electronTrpc.changes.getCommitFiles.useQuery (referencing
commitFiles, isCommitExpanded and the local files variable used in the
files.length > 0 guard) to render a small spinner/skeleton when the query is
loading and the commit is expanded (i.e., when isLoading && isCommitExpanded)
instead of letting the area stay blank; ensure the spinner is shown only during
loading and that normal files rendering remains unchanged once commitFiles
resolves.
apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/components/CommitSection/CommitSection.tsx (1)

24-30: Minor UX gap: no loading indicator while commit files are being fetched.

When the user expands a commit, commitFiles is undefined until the query resolves, so files is [] and the files.length > 0 guard on Line 54 renders nothing. The section appears expanded (chevron rotates) but shows an empty area until data arrives. Consider showing a small spinner or skeleton while isLoading && isCommitExpanded to provide feedback.

This is low priority given the perf focus of this PR.

Also applies to: 54-66

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/InfiniteScrollView/components/CommitSection/CommitSection.tsx`
around lines 24 - 30, The commit files query currently leaves the expanded
commit area empty because commitFiles is undefined while loading; update the
component that calls electronTrpc.changes.getCommitFiles.useQuery (referencing
commitFiles, isCommitExpanded and the local files variable used in the
files.length > 0 guard) to render a small spinner/skeleton when the query is
loading and the commit is expanded (i.e., when isLoading && isCommitExpanded)
instead of letting the area stay blank; ensure the spinner is shown only during
loading and that normal files rendering remains unchanged once commitFiles
resolves.
apps/desktop/src/lib/trpc/routers/changes/status.ts (2)

14-19: Cache entries are never evicted — minor unbounded growth.

The statusCache Map accumulates entries for every unique worktreePath:defaultBranch pair and never removes them. For a desktop app this is practically bounded (few worktrees), but for correctness consider pruning stale entries periodically or capping the map size.

♻️ Optional: prune stale entries on cache write
 			statusCache.set(cacheKey, { result, timestamp: Date.now() });
+
+			// Prune expired entries to prevent unbounded growth
+			for (const [key, entry] of statusCache) {
+				if (Date.now() - entry.timestamp >= STATUS_CACHE_TTL_MS) {
+					statusCache.delete(key);
+				}
+			}
+
 			return result;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/lib/trpc/routers/changes/status.ts` around lines 14 - 19,
statusCache never evicts entries causing unbounded growth; when writing new
entries (the code that sets statusCache for a worktreePath:defaultBranch key)
add eviction: remove any entries with timestamp older than STATUS_CACHE_TTL_MS
and enforce a cap (e.g. introduce MAX_STATUS_CACHE_ENTRIES = 100) by deleting
the oldest timestamped entries until size <= cap. Implement this pruning logic
in the same place that sets statusCache so you call it before/after inserting
the new {result,timestamp}, referencing STATUS_CACHE_TTL_MS and statusCache to
locate where to add the cleanup.

34-38: Add server-side cache invalidation for status mutations (stage, unstage, discard, commit).

The server-side statusCache (2s TTL) is not invalidated when mutations occur. With a 2.5s polling interval, a user staging a file can see stale unstaged status until the cache expires. Export a clearStatusCache helper function and call it after each mutation (stage, unstage, discard, commit) to ensure the next poll always fetches fresh data.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/lib/trpc/routers/changes/status.ts` around lines 34 - 38,
Export a cache invalidation helper and call it from each status-mutating RPC:
add and export a function clearStatusCache() that clears the in-memory
statusCache (the Map used alongside STATUS_CACHE_TTL_MS and cacheKey logic that
reads cached.result) and then invoke clearStatusCache() at the end of each
mutation handler (stage, unstage, discard, commit) so the next status() call
bypasses stale entries; ensure the helper is imported where those mutation
resolvers are defined and call it after the mutation completes (success path).

@Kitenite Kitenite merged commit ba0bf81 into main Feb 16, 2026
6 checks passed
@github-actions
Copy link
Copy Markdown
Contributor

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ⚠️ Neon database branch
  • ⚠️ Electric Fly.io app
  • ⚠️ Streams Fly.io app

Thank you for your contribution! 🎉

@Kitenite Kitenite deleted the kitenite/diff-sidebar-performance branch February 16, 2026 21:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant