perf: fix DiffViewer scroll performance#24300
Conversation
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.
|
@codex review |
There was a problem hiding this comment.
💡 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".
…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
left a comment
There was a problem hiding this comment.
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.
…icate story helper
|
@codex review |
1 similar comment
|
@codex review |
There was a problem hiding this comment.
💡 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".
…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.
|
@codex review |
There was a problem hiding this comment.
💡 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".
…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.
|
@codex review |
There was a problem hiding this comment.
💡 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".
… 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.
|
@codex-connector please review again — all previous findings have been addressed: Round 1 (already fixed):
Round 2 (already fixed):
Round 3 (just fixed):
|
There was a problem hiding this comment.
💡 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".
mafredri
left a comment
There was a problem hiding this comment.
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.
…CommentableDiffViewer
…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)
|
@codex review |
There was a problem hiding this comment.
💡 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".
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.
|
@codex review |
There was a problem hiding this comment.
💡 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.
|
@codex review |
|
Codex Review: Didn't find any major issues. Breezy! ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
mafredri
left a comment
There was a problem hiding this comment.
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.scrollTopline 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.
…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.
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
Assuming the active file band is 5% seems like it could break as the viewport size changes?
There was a problem hiding this comment.
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.
| // Uses explicit useMemo despite the React Compiler scope because | ||
| // parsePatchFiles is external to the compiler's static analysis. |
There was a problem hiding this comment.
This justification confuses me. I'll go with it though
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:
onScrollhandler calledgetBoundingClientRect()on every filewrapper per scroll tick (O(N) forced layouts per frame).
setActiveFile()re-rendered the entire DiffViewer tree on every activefile change.
parsePatchFiles()ran on every render inLocalDiffPanel,RemoteDiffPanel, andGitPanel.rootMarginused a percentage, which resolves against theroot's width (not height), collapsing the observation strip in wide
viewports.
after an initial empty state, or when the viewport was resized.
Fix
Scroll tracking
Replaced the
getBoundingClientRectscroll handler with anIntersectionObserverwatching 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 marginpercentages resolve against width). A
ResizeObservertracks viewportheight so the strip adapts to layout changes, and the effect keys off a
stable
fileListKeystring so scroll-driven re-renders don't tear down theobserver.
Memoization: React Compiler
src/pages/AgentsPage/is opted into the React Compiler viasite/vite.config.mts. The compiler automatically memoizes values,callbacks, and JSX at build time. This PR removed the manual
useMemo/useCallbackwrappers that were added earlier in the review cycle andlets the compiler handle memoization.
React.memo()is retained onFileTreeNodeViewandLazyFileDiff— thedocumented list-item exception from
site/AGENTS.md. Theirmemo()effectiveness depends on the compiler stabilizing prop references; moving
these components outside the compiler scope without adding manual
memoization would silently regress scroll performance.
useParsedDiffuses explicituseMemoas a documented exception: thecompiler cannot prove purity of the external
parsePatchFilesfunctionfrom
@pierre/diffs, so withoutuseMemothe parser would run on everyrender even when inputs are unchanged.
activeCommentBoxRef(stable annotation handler)CommentableDiffViewerwrapsactiveCommentBoxin a ref that's syncedin event handlers (not during render). This gives
renderAnnotation,handleSubmitComment, and the annotation getters stable identities viathe compiler, so comment-box toggles no longer force every
LazyFileDiffto re-render.
CSS containment for Safari
Added
will-change: transformon the scroll container andcontain: layout styleon each file wrapper. ProgrammaticscrollIntoView/scrollBycalls usebehavior: "instant"to avoidfighting Safari's scroll compositor.
Hook extraction
Extracted
useActiveFileTracking(observer setup, viewport sizing,scroll-to-file) and
useParsedDiff(shared diff parsing withmemoization) to keep
DiffViewerfocused on layout and eliminateduplication between
LocalDiffPanelandRemoteDiffPanel.Testing
Added a
LargeDiffStorybook story (40 files × 60+ context lines,~2,400 diff lines) with
isExpanded: trueso the observer code path isexercised, plus a
playfunction that scrolls the viewport and assertsthe sidebar highlight updates.
Expected impact
getBoundingClientRectcalls per frame → 0diffStringchanges