Skip to content

perf: fix DiffViewer scroll performance#24300

Merged
jaaydenh merged 23 commits into
mainfrom
git-panel-4b00
Apr 22, 2026
Merged

perf: fix DiffViewer scroll performance#24300
jaaydenh merged 23 commits into
mainfrom
git-panel-4b00

Conversation

@jaaydenh

@jaaydenh jaaydenh commented Apr 13, 2026

Copy link
Copy Markdown
Contributor

Problem

The GitPanel's DiffViewer had several performance and correctness issues that
manifested as CPU spikes and scrollbar jank, especially on Safari with large
diffs:

  1. The onScroll handler called getBoundingClientRect() on every file
    wrapper per scroll tick (O(N) forced layouts per frame).
  2. setActiveFile() re-rendered the entire DiffViewer tree on every active
    file change.
  3. parsePatchFiles() ran on every render in LocalDiffPanel,
    RemoteDiffPanel, and GitPanel.
  4. File header rootMargin used a percentage, which resolves against the
    root's width (not height), collapsing the observation strip in wide
    viewports.
  5. The IntersectionObserver effect didn't re-run when the viewport mounted
    after an initial empty state, or when the viewport was resized.

Fix

Scroll tracking

Replaced the getBoundingClientRect scroll handler with an
IntersectionObserver watching a narrow strip at the top of the viewport.
The observation strip is a pixel value derived from viewport.clientHeight
(the previous -95% margin was broken in wide viewports because CSS margin
percentages resolve against width). A ResizeObserver tracks viewport
height so the strip adapts to layout changes, and the effect keys off a
stable fileListKey string so scroll-driven re-renders don't tear down the
observer.

Memoization: React Compiler

src/pages/AgentsPage/ is opted into the React Compiler via
site/vite.config.mts. The compiler automatically memoizes values,
callbacks, and JSX at build time. This PR removed the manual useMemo /
useCallback wrappers that were added earlier in the review cycle and
lets the compiler handle memoization.

React.memo() is retained on FileTreeNodeView and LazyFileDiff — the
documented list-item exception from site/AGENTS.md. Their memo()
effectiveness depends on the compiler stabilizing prop references; moving
these components outside the compiler scope without adding manual
memoization would silently regress scroll performance.

useParsedDiff uses explicit useMemo as a documented exception: the
compiler cannot prove purity of the external parsePatchFiles function
from @pierre/diffs, so without useMemo the parser would run on every
render even when inputs are unchanged.

activeCommentBoxRef (stable annotation handler)

CommentableDiffViewer wraps activeCommentBox in a ref that's synced
in event handlers (not during render). This gives renderAnnotation,
handleSubmitComment, and the annotation getters stable identities via
the compiler, so comment-box toggles no longer force every LazyFileDiff
to re-render.

CSS containment for Safari

Added will-change: transform on the scroll container and
contain: layout style on each file wrapper. Programmatic
scrollIntoView / scrollBy calls use behavior: "instant" to avoid
fighting Safari's scroll compositor.

Hook extraction

Extracted useActiveFileTracking (observer setup, viewport sizing,
scroll-to-file) and useParsedDiff (shared diff parsing with
memoization) to keep DiffViewer focused on layout and eliminate
duplication between LocalDiffPanel and RemoteDiffPanel.

Testing

Added a LargeDiff Storybook story (40 files × 60+ context lines,
~2,400 diff lines) with isExpanded: true so the observer code path is
exercised, plus a play function that scrolls the viewport and asserts
the sidebar highlight updates.

Expected impact

  • Scroll handler: O(N) getBoundingClientRect calls per frame → 0
  • Re-renders on scroll: full DiffViewer tree → sidebar only
  • Diff parsing: on every render → only when diffString changes

Replace O(N) per-frame layout thrashing with zero-layout-read
IntersectionObserver, isolate scroll-driven state from the diff list,
memoize expensive render-body computations, wrap child components in
React.memo, stabilize callbacks with useCallback, and add CSS
containment hints.

Key changes:
- Replace requestAnimationFrame + getBoundingClientRect scroll handler
  with IntersectionObserver watching top 5% viewport strip
- Split activeFile into ref + tree-only state so scroll updates only
  re-render the sidebar, not the entire diff list
- Memoize fileTree, sortedFiles, perFileOptions, perFileAnnotations,
  perFileSelectedLines, diffOptions, and fileOptions with useMemo
- Wrap FileTreeNodeView and LazyFileDiff in React.memo
- Wrap getOptionsForFile and handleFileClick in useCallback
- Wrap all CommentableDiffViewer callbacks in useCallback
- Add CSS contain:layout style on file wrapper divs
- Add will-change:transform on scroll container
- Change scrollToFile from smooth to instant behavior
…dDiff hooks

Extract two reusable hooks from DiffViewer to keep the component lean:

- useActiveFileTracking: Encapsulates IntersectionObserver-based active
  file detection, scroll-to-file, and file-ref management. DiffViewer
  now calls the hook instead of owning all the refs/state/effects.

- useParsedDiff: Consolidates the identical
  parsePatchFiles(...).flatMap(p => p.files) pattern used in both
  LocalDiffPanel and RemoteDiffPanel into a single hook. Handles
  null/undefined input and parse errors with a try/catch fallback.
  Supports an optional cacheKeyPrefix for the @pierre/diffs worker
  pool LRU cache (used by RemoteDiffPanel).

Per-file Map consolidation was evaluated but skipped because the
three Maps (perFileOptions, perFileAnnotations, perFileSelectedLines)
have sufficiently different dependency arrays that merging them
would over-invalidate.

Pure refactor — no behavior changes.
@github-actions github-actions Bot added the community Pull Requests and issues created by the community. label Apr 13, 2026
@jaaydenh jaaydenh changed the title perf(site/AgentsPage): fix DiffViewer scroll performance and memoize expensive computations perf(site/src/pages/AgentsPage/components): fix DiffViewer scroll performance and memoize expensive computations Apr 13, 2026
@jaaydenh

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cbd155fa80

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread site/src/pages/AgentsPage/components/DiffViewer/DiffViewer.tsx Outdated
…r memo components

The React Compiler hit an internal invariant error ('Expected a node
for all identifiers, none found for 55') when compiling components
defined inline inside memo<Props>(function Name(...) { ... }).

Separate the function definitions from the memo() wrapping for both
FileTreeNodeView and LazyFileDiff so the compiler processes the plain
function declarations instead of the combined memo pattern. Both
components remain wrapped in React.memo for runtime memoization.

@jaaydenh jaaydenh left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Nice work on the performance overhaul. The IntersectionObserver approach for active file tracking is an excellent replacement for the layout-thrashing scroll handler, and the CSS containment strategy is well chosen. The overall direction is solid.

A couple things to look at: 2 P2, 2 P3, 4 nits across 8 inline comments.

Comment thread site/src/pages/AgentsPage/components/DiffViewer/CommentableDiffViewer.tsx Outdated
Comment thread site/src/pages/AgentsPage/components/DiffViewer/DiffViewer.tsx
Comment thread site/src/pages/AgentsPage/components/DiffViewer/useParsedDiff.ts Outdated
Comment thread site/src/pages/AgentsPage/components/DiffViewer/DiffViewer.tsx
Comment thread site/src/pages/AgentsPage/components/DiffViewer/DiffViewer.stories.tsx Outdated
Comment thread site/src/pages/AgentsPage/components/DiffViewer/useActiveFileTracking.ts Outdated
Comment thread site/src/pages/AgentsPage/components/DiffViewer/DiffViewer.tsx
@jaaydenh jaaydenh changed the title perf(site/src/pages/AgentsPage/components): fix DiffViewer scroll performance and memoize expensive computations perf: fix DiffViewer scroll performance Apr 14, 2026
@jakehwll jakehwll removed the community Pull Requests and issues created by the community. label Apr 15, 2026
@jaaydenh

Copy link
Copy Markdown
Contributor Author

@codex review

1 similar comment
@jaaydenh

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a33b947ffa

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread site/src/pages/AgentsPage/components/DiffViewer/useActiveFileTracking.ts Outdated
Comment thread site/src/pages/AgentsPage/components/DiffViewer/testHelpers.ts Outdated
…seActiveFileTracking

The useEffect that sets up the IntersectionObserver depended directly
on sortedFiles, which is an array rebuilt inline every render in
DiffViewer.tsx. Every scroll-driven treeActiveFile state update caused
the observer to be torn down and recreated, adding O(N) work per frame.

Fix: store sortedFiles in a ref (synced via a no-deps useEffect) and
derive a stable fileListKey string from the file names. The observer
effect now depends on fileListKey instead of sortedFiles, so it only
re-runs when the actual file list changes. The observer callback reads
from the ref to always see the latest value.
@jaaydenh

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 924094a1ad

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread site/src/pages/AgentsPage/components/DiffViewer/useActiveFileTracking.ts Outdated
…ionObserver

The IntersectionObserver rootMargin used "-95%" to create a narrow
observation strip at the top 5% of the viewport. Per the CSS spec,
percentage values in margins — including top and bottom — resolve
against the root element's width, not its height. In a wide diff
pane (e.g., 1200px wide x 800px tall), -95% resolves to -1140px,
which exceeds the viewport height and collapses the observation
area to zero, breaking active file tracking.

Compute an explicit pixel value from viewport.clientHeight instead
so the observation strip is reliably 5% of the visible height
regardless of aspect ratio.
@jaaydenh

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2ed25a6d28

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread site/src/pages/AgentsPage/components/DiffViewer/useParsedDiff.ts Outdated
Comment thread site/src/pages/AgentsPage/components/DiffViewer/useActiveFileTracking.ts Outdated
… strip on resize

useParsedDiff: parsePatchFiles was called on every render even when
inputs hadn't changed, because the function lacked memoization. Add
a state-based cache (set state during render pattern) so parsing
only runs when diffString or cacheKeyPrefix actually change. This is
compatible with the React Compiler (ref reads during render are not).

useActiveFileTracking: the IntersectionObserver rootMargin was
computed once from viewport.clientHeight at setup time but never
updated on resize. Add a ResizeObserver that tracks viewport height
and include it in the effect dependency array so the observer is
re-created with a correct margin after layout changes.
@jaaydenh

Copy link
Copy Markdown
Contributor Author

@codex-connector please review again — all previous findings have been addressed:

Round 1 (already fixed):

  • P1: Removed all manual useMemo/useCallback (React Compiler handles it)
  • P2: Stabilized IntersectionObserver effect deps via fileListKey
  • P3: Corrected hunk header counts in generateLargeDiff

Round 2 (already fixed):

  • P2: Switched rootMargin from percentage to height-based pixel value

Round 3 (just fixed):

  • P2: useParsedDiff now caches results in state — no re-parsing on unrelated renders
  • P3: Added ResizeObserver to recompute observer strip when viewport height changes

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6ebfc52854

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread site/src/pages/AgentsPage/components/DiffViewer/useActiveFileTracking.ts Outdated
@jaaydenh jaaydenh requested a review from mafredri April 20, 2026 13:52
@jaaydenh jaaydenh marked this pull request as ready for review April 20, 2026 13:52

@mafredri mafredri left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The IntersectionObserver replacement is a clear improvement over the O(N) getBoundingClientRect loop. The architectural split (observer-based tracking in a hook, CSS containment, lazy file mounting) is well-calibrated. The activeCommentBoxRef addition in CommentableDiffViewer correctly fixes a stale-closure risk. Good work on iterating through the edge cases (viewport mount timing, resize, pixel margins) across the commit history.

One P2, three P3s, four nits.

The headline issue is useParsedDiff: nine reviewers independently flagged it. The hand-rolled useState-during-render cache reimplements useMemo with extra complexity, causes a double render on every cache miss, and allocates the full diff string as a key on every render. In a PR focused on eliminating unnecessary render work, this is the wrong pattern.

The PR description claims explicit useMemo/useCallback wrapping that was removed in commit 12fe8d78. The actual memoization mechanism is the React Compiler, which the description never mentions. This creates a gap between what a reviewer reads and what the code does.

"A future maintainer reading the PR description will believe scroll-driven re-renders are isolated and may skip necessary optimization work." (Mafuuu)

"Move these components to a shared directory outside AgentsPage/, or change the compiler filter regex, and scroll performance silently regresses. No test fails, no runtime error surfaces." (Hisoka)

🤖 This review was automatically generated with Coder Agents.

Comment thread site/src/pages/AgentsPage/components/DiffViewer/useParsedDiff.ts Outdated
Comment thread site/src/pages/AgentsPage/components/DiffViewer/DiffViewer.tsx
Comment thread site/src/pages/AgentsPage/components/DiffViewer/useActiveFileTracking.ts Outdated
Comment thread site/src/pages/AgentsPage/components/DiffViewer/CommentableDiffViewer.tsx Outdated
Comment thread site/src/pages/AgentsPage/components/DiffViewer/useActiveFileTracking.ts Outdated
Comment thread site/src/pages/AgentsPage/components/DiffViewer/useActiveFileTracking.ts Outdated
Comment thread site/src/pages/AgentsPage/components/DiffViewer/useActiveFileTracking.ts Outdated
…leTracking

DEREM-2: Remove dead activeFileRef (written but never read).
DEREM-4: Add React Compiler dependency comments near memo() calls.
DEREM-5: Only fire onScrollToFileComplete when target element is found;
         add fileListKey to deps so the effect retries when files mount.
DEREM-7: Add behavior: "instant" to handleFileClick for consistency.
DEREM-8: Extract 0.95 magic number to VIEWPORT_BOTTOM_MARGIN_RATIO.
DEREM-9: Rename onScrollComplete to onScrollToFileComplete.
…path

The LargeDiff story had isExpanded unset and a 900px container, so
showTree resolved to false (FILE_TREE_THRESHOLD is 1000). This meant
the IntersectionObserver for active-file tracking never ran and the
file tree sidebar never rendered.

Set isExpanded: true so the tree + observer code path is always
exercised regardless of container width. Add a play function that:
1. Waits for the file tree nav to render (proves showTree=true)
2. Scrolls the diff viewport to the middle
3. Asserts the IntersectionObserver fires and highlights a file
   that is not the first one (proving scroll-driven tracking works)
@jaaydenh

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 33f12f9e5c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread site/src/pages/AgentsPage/components/DiffViewer/DiffViewer.stories.tsx Outdated
Add aria-current="true" to the active file tree button in DiffViewer,
providing a semantic active marker alongside the existing CSS class.

Update the LargeDiff story assertion to query [aria-current="true"]
instead of the brittle .border-content-link class selector, following
the site/AGENTS.md guideline to assert observable behavior.
@jaaydenh

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4e2c623c4a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…diff

When scrollToFile names a file that doesn't exist in the current diff
(e.g. clicking a stale file-reference chip after the diff changed),
the target element never appears, onScrollToFileComplete never fires,
and the parent's scrollTarget stays stuck — making setScrollTarget(sameName)
a no-op.

Distinguish 'not mounted yet but in file list' (keep waiting for
fileListKey to change) from 'not in file list at all' (give up and
call onScrollToFileComplete to clear the stuck state).

Uses sortedFilesRef.current instead of adding sortedFiles to deps,
since fileListKey already triggers the effect when the file list
changes.
@jaaydenh

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@jaaydenh jaaydenh requested a review from mafredri April 21, 2026 13:01

@mafredri mafredri left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

All 9 R1 findings addressed. The PR description now correctly describes the React Compiler as the memoization mechanism, useParsedDiff uses useMemo, the scroll-to-file race condition has a two-branch fix, and the LargeDiff story activates the observer code path with a play function.

One new P2: the LargeDiff play function's scroll assertion is vacuously true. Five of five R2 reviewers converged on this. The fix is straightforward.

The rest of the code is in good shape. The IntersectionObserver design, the activeCommentBoxRef pattern, the intersecting Set lifecycle, and the CSS containment strategy all hold up under closer inspection.

"Delete the viewport.scrollTop line and the test still passes. What happens when scroll-driven tracking regresses? This test won't tell you." (Hisoka)

🤖 This review was automatically generated with Coder Agents.

Comment thread site/src/pages/AgentsPage/components/DiffViewer/DiffViewer.stories.tsx Outdated
…rtion

The LargeDiff story's play function asserted that the active file
after scrolling was not 'src/module0.ts', but buildFileTree sorts
directories alphabetically so src/module0.ts was never the first
file — making the assertion vacuously true.

Instead, capture whichever file the IntersectionObserver highlights
at mount, scroll to the middle, then assert the active file changed.
This proves the observer actually responds to scroll events rather
than just the initial mount pass.
@jaaydenh jaaydenh requested a review from mafredri April 21, 2026 14:07

@coder-agents-review coder-agents-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Status: APPROVED

All 11 findings across R1 and R2 are addressed. R3 panel (3 reviewers): Mafuuu and Meruem found no issues. Bisky noted two untested code paths (scrollToFile branches and handleFileClick) as coverage suggestions, not bugs. The code paths were verified correct in prior rounds.

11 findings raised across 3 rounds, 10 fixed, 1 dropped (pre-existing). Zero open.

The PR delivers what it claims: layout-thrashing scroll handler replaced with IntersectionObserver, state isolation via ref+state split, CSS containment, correct memoization through the React Compiler, and a play function that genuinely proves scroll tracking works.

"This genuinely proves the observer responds to scroll events, not just the initial mount pass." (author, on DEREM-11 fix)

🤖 This review was automatically generated with Coder Agents.

@mafredri mafredri left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks alright to me, did not spot any glaring issues.

import type { FileDiffMetadata } from "@pierre/diffs";
import { type RefObject, useEffect, useRef, useState } from "react";

// Leaves a 5% strip at the top of the viewport as the "active file" band.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Assuming the active file band is 5% seems like it could break as the viewport size changes?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This doesn't appear to be an issue because there is minimum height that the window can be resized to which is still quite large.

Comment on lines +5 to +6
// Uses explicit useMemo despite the React Compiler scope because
// parsePatchFiles is external to the compiler's static analysis.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This justification confuses me. I'll go with it though

@jaaydenh jaaydenh merged commit e421c2f into main Apr 22, 2026
27 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 22, 2026
@jaaydenh jaaydenh deleted the git-panel-4b00 branch April 22, 2026 16:47
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants