Stacked pull requests work well for splitting work into reviewable pieces, but a migration or broad refactor frequently has to land as one change. That leaves a single pull request that grows large on its own, and the review conversation pushed on top of it makes it larger still. The GitHub Copilot app's pull request view was rebuilt so that the diff and its conversation stay responsive in exactly that case.

To establish how far that holds, the team opened the largest open source pull request they could find: 2,200 files, over a million changed lines, more than 400 inline review comments.

Where the difficulty actually lives

Large diffs are a solved rendering problem. Virtualize the rows, keep the mounted DOM small, and exploit the fact that each row is a single line of code at a fixed height.

Comments defeat that assumption. Their height comes out of markdown wrapping, expandable sections, a reply box that may or may not be present, and images that may not have loaded yet — all known only at render time. Three consequences follow: measurement that can't happen ahead of paint, a data pipeline that has to keep up and not discard completed work, and a bug class that only appears under load, on a specific engine, at a specific scroll position.

Code rows are deterministic; comments are not

The geometry that makes a code-only diff fast

A million DOM nodes is not an option. The standard technique is virtualization: mount roughly the rows in view plus a small margin, recycle those DOM elements while scrolling, and let the list behave as though every row exists — correct scrollbar size, working scroll-to-row — with only about 100 rows ever real at once.

That illusion requires geometry. Scrollbar height is the sum of all row heights; the position of row N is the sum of the heights above it. Drawing the scrollbar, deciding what is visible, jumping to a row: all arithmetic over a height table. General-purpose variable-height virtualizers build that table from estimates and repair it as rows are measured. They don't have to. When each row is a line of code at a known font size, the whole table is computable before paint and never changes afterward. That is the "all heights known before paint" contract, and the diff surface was built on it: an imperative recycled code-row renderer with no React component per row, typed-array geometry for the offset math, backend-owned diff documents streamed structure-first, and an imperative scroll API with exact "scroll to row N". On pure code this is the right design, and it was kept intact.

What a review thread breaks

Drop a review thread into the middle of the diff and the question "how tall is it?" has no pre-paint answer. Its height depends on render-time facts, some of which keep changing: markdown wrapping differently at different widths, <details> blocks expanded or collapsed in place, a reply composer that opens inside the thread and grows as you type, suggested-change diffs, reactions, edit mode, resolution banners, and images or other async assets that change height when they finish loading.

Reserving a fixed-height slot sized by an estimator is the obvious response, and it fails on a large pull request. An estimator that's accurate on average is still wrong at the extremes: it over-reserves most comments, producing gaps of whitespace, and under-reserves the expensive ones, which clip or spawn a nested scrollbar. Measuring after paint and writing the real height into the shared offset table moves everything below it — while the user is already scrolling, and on a large pull request by a large amount.

Comments therefore get a different contract: heights are bounded, measured lazily, and corrections are small and anchored to whatever the user is looking at.

Splitting the height model in two

The tractable move was to stop making one geometry serve both kinds of content, and instead give the document two independent height domains.

total height = deterministic code height          (exact, known up front)
             + Σ dynamic block effective heights   (estimated, then measured)
             + scroll padding

Code geometry stays in the original world: deterministic, prefix-summed, exact, and never rebuilt because a comment resized.

Dynamic block geometry covers anything with unpredictable height — review threads, drafts, reply composers. Each is a block identified by what it is, not by where it currently sits, with a stable key that survives content loading and an anchor of file, line and side rather than a pixel coordinate, so a reflow can't lose it. Alongside the anchor the surface stores a fingerprint of everything that could affect height: content, whether a <details> is open, whether a composer is active. It also records the width the block was last measured at, bucketed, so an ordinary window resize doesn't invalidate every measurement in the document.

Effective height then reduces to a three-way choice: the measured height if a valid one exists, a cached height if the fingerprint and width still match, or the estimate. Those heights live in their own index, apart from code rows, so a resizing comment never triggers a rebuild of code geometry. The number of blocks is bounded by comments rather than rows; a few thousand is acceptable, provided first paint never mounts or measures all of them.

Measuring what you can't predict

Why the first design was thrown away

The intuitive approach — one ResizeObserver per block, writing its measured height back into the layout whenever it changes — was designed first and then rejected during performance hardening, because it creates precisely the feedback loop large virtualized surfaces must avoid: an observer inside the layout of the element it's watching can retrigger itself, and the cost rises with every mounted block.

The shipped design is a single idle- and scroll-gated measurement pass that follows the same discipline as the deterministic side. Its rules:

  • It stays off the hot path. It runs once the visible range settles rather than per scroll frame, and remains halted for the full duration of an in-flight scroll, restarting when scrolling stops. A mid-scroll reflow is the jank being avoided.
  • It's scoped to the viewport. Only blocks within about 2400px of the viewport are candidates, making the work O(viewport). Distant blocks keep their estimate until they come closer.
  • On-screen reads win. A mounted block is on screen, so its rendered height is ground truth. Every mounted candidate is read in one batched pass — a single reflow, no writes in between — and a mounted block is never skipped in favor of a stale estimate. This rule eliminated the worst bug encountered: comments rendering with blank space beneath them, because a mounted block had been filtered out of measurement and left on a too-tall estimate.
  • Off-screen measurement is a bounded fallback. A nearby, unmounted block gets at most one off-screen render to correct its reservation before it scrolls into view. Anything taller than the viewport is skipped even then: its over-reservation sits below the fold, so the render isn't worth its cost.
  • An observer handles everything else. Certain height changes neither move the fingerprint nor coincide with a scroll — typing in a reply composer, an image finishing, toggling a <details>. Each mounted block keeps a ResizeObserver for these, but it only flags the block so the idle pass re-reads it; it never writes a height itself, which is what would close the rejected feedback loop. It disconnects on unmount, and an inactive pull request tab observes nothing.
  • With one deliberate exception. Deferring was visibly wrong for resizes the user causes directly — expanding a <details>, opening a reply composer, an image landing. The block grew at once while the code below waited for the next idle pass, so for one frame the comment was taller and everything under it sat at the old position: two visible steps. When a block is mounted and on screen, the observer now measures it and applies the correction in the same frame, before paint, so the block grows, the code repositions, and everything below shifts together. Two safeguards prevent this from recreating the rejected loop: at most one synchronous commit per frame, collapsing a burst of resizes into one, and never during an active scroll, where it falls back to the batched pass.

Anchoring corrections to identity

A measured height that differs from its estimate changes the scrollbar arithmetic, which naively makes the viewport jump. The correction is done by identity instead of by pixel: capture what the user is anchored to (a row or a block, by identity) and the offset within it, apply the height deltas, resolve that same anchor to its new pixel position, and scroll so the anchor holds its place in the viewport.

Several rules keep the result from feeling wrong. A block above the viewport changing height adjusts the scroll position by the delta. Content hydrating below the viewport causes no adjustment, since it can't be seen. If you toggled a <details> or opened a reply in a visible block, above-block correction is suppressed for that block so the interaction feels direct and the content below flows down naturally. And active pointer or wheel momentum is never fought; the correction is batched after the frame.

That last rule has a sharp edge that caused a real bug. "Don't correct while the user is scrolling" was implemented as a guard on the last observed scroll, but programmatic scrolls refreshed that timestamp too. Toggling the file-tree sidebar changes the width of the diff pane; with line wrapping on, every wrapped line above reflows to a different number of visual lines, the coordinate space shifts, and the surface emits a small settling scroll of its own. The guard read that as user scrolling and skipped the correction that was meant to preserve position, letting the file being read drift off screen. Distinguishing user scrolls from those the surface causes itself was the fix — any "is the user interacting?" check has to be one your own side effects can't satisfy.

Feeding the surface: streaming, deferral, and a deliberate cache policy

An incremental diff request lets the file tree and metadata paint while the document is still in flight, and review threads are resolved up front instead of arriving piecemeal. Per-item work, meanwhile, is deferred until something actually needs it: syntax highlighting runs off the main thread, so rows appear as plain text right away and pick up color when results arrive, improving the surface rather than blocking the scroll. Large markdown bodies and suggested-change context follow the same rule — nothing is built until it approaches the viewport.

A third habit concerned which costs are worth keeping. Large diff documents are released on navigation by default, since retaining every visited diff is how a long session ends up eating memory. Pull request metadata, by contrast, persists, so the shell around the diff — header and file tree — repaints instantly on return and then waits several seconds for a diff it held complete moments earlier. An instantly-drawn shell over an empty diff reads as broken even when the overall wait is shorter. The policy therefore stayed, with a cache layered on top: keep the last few diffs resident, evict anything beyond that, and let the background refresh notice when one has gone stale.

Making invisible bugs reproducible

Nearly every bug in this project was invisible until it wasn't, and hand-reproduction is miserable — the typical report describes a whitespace strip below some comments that appears only sometimes, only on big pull requests, and heals after scrolling past and back. Debugging that by staring at the screen isn't viable, so tooling was built to do it mechanically.

Permanent structured probes instead of console.log

The naive workflow — sprinkle console.log, exercise the flow by hand, copy the output, hand it to someone or something that can analyze it, delete the logs, repeat — is slow, needs a human in the loop, and ends up measuring hand-rolled instrumentation rather than the app's real behavior.

Instead, the surface carries permanent structured probes for its own invariants: plain questions it answers about itself on every render.

  • Is the surface actually viewport-bound, and how many rows and comment blocks are mounted right now?
  • Does measurement coalesce to a single commit per frame, and how long does that frame take?
  • How large are the scroll corrections being made?
  • Was any comment block inserted after scrolling started? Once the backend topology has landed, this must be zero.
  • Do the per-block observers tear down on unmount, or does each block leak one?

Asserted as budgets in an end-to-end test against a synthetic many-comment huge-pull-request fixture, these objective pass/fail signals let CI report whether the surface is healthy.

Autonomous change → measure → improve

Two lanes made the loop automatic. A headless probe lane ran a declarative flow — open a pull request, scroll to a fraction, toggle a details block, resize the window — against a mock server while reading the app's own production instrumentation: React render counts, the performance timeline, and a requestAnimationFrame sampler for jank. It handled the instrument, drive, collect, analyze, rank cycle on its own and printed bottlenecks in order. Because the flow is just JSON handed to the probe at runtime, an agent could profile any flow described in plain English without touching source.

An autopilot drove the actual desktop app through the huge-pull-request flow unattended and on a loop: cold first, with comments still skeletons, then warm with comments loaded, toggling <details> blocks, opening and cancelling reply composers, collapsing and expanding files, toggling the sidebar tree, sweeping deep into the file list, resizing the window. Measurements were mirrored to the app's on-disk log so an agent could read runtime behavior with nobody at the keyboard. Each sample carried a health signal as the objective check: a warm sample counted as healthy only if there were no unfilled gaps between comments, no comment blocks left blank, and real thread content actually mounted across the entire scroll range, deep-file sweep included.

  1. Reproduce unattended, on the real engine. Arm the autopilot, let it loop, read the on-disk log.
  2. Detect with a health signal, not with your eyes. Trust the sample fields.
  3. Probe the suspect seam. When a signal goes bad, add one narrow structured probe there, re-arm, re-read. Editing the surface hot-reloads the live window and re-arms the autopilot, so a fresh capture is about one cycle away.
  4. Remove the scaffolding. Once the invariant is understood, pin it in a test and the design doc, and keep only the detector-grade signals.

What the result looks like

A pull request view where a million-line diff with hundreds of threaded comments opens, scrolls, and behaves like a normally sized pull request: comments render in full instead of clipping into a scrollable box, expanding a collapsed section moves the code below it and nothing else, and returning to a pull request you just left puts you where you were.

Reviewing a pull request this large previously meant waiting or giving up and reading it elsewhere. A review isn't a document with known dimensions — it's a conversation that changes shape while you're reading it, and the surface underneath has to be built for that from the start rather than patched in afterwards. If you review code for a living, it's worth feeling the difference on a pull request you already know is painful.

Try the GitHub Copilot app >