
Rendering huge pull requests in the GitHub Copilot app
How we rebuilt the diff surface in the GitHub Copilot app to open a million-line pull request with hundreds of inline review comments.
以下正文同步自 GitHub Blog,版权归原站所有,已转换为易读排版。
Broad refactors and migrations often have to land as one change.
Stacked pull requests are a great way to split work into smaller changes, which makes reviews easier and helps teams ship with less risk. But some changes, like this one, can’t be split cleanly. That leaves you with a single pull request that can get very large, and the review conversation causes it to grow.
The review experience needs to remain fast and smooth even when the diff and its conversation are enormous. In the GitHub Copilot app, we rebuilt the pull request view with that requirement in mind.
To see how far that goes, we opened the biggest pull request we could find: an open source one with 2,200 files, over a million changed lines, and more than 400 inline review comments. Here’s how we made even this extreme pull request performant.
The scope of the problem
Rendering a large diff at speed is well-understood: virtualize the rows, keep the mounted DOM small, and lean on the fact that every row is a line of code at a known height.
Comments are the hard part. A comment’s height depends on how its markdown wraps, the expandable sections, whether there’s a reply box in it, and whether its images have loaded yet. You find all of that out at render time. This forces a different architecture.
Three problems:
- Measurement. You can’t know how tall a comment is until you render it. This breaks the design that lets big diffs stay responsive as you scroll.
- The data pipeline. A fast diff surface is worthless if the data pipeline feeding it stalls, or if it throws away work it already did.
- How we actually found the bugs. These problems surface under load, on a specific engine, at a specific scroll position. So we defined what healthy meant, instrumented the surface to answer it, and ran the whole change → measure → improve loop unattended.
Part 1: Virtualization, and why comments break it
The first step is to understand the geometry that makes a code-only diff fast. Once comments enter the picture, that geometry is no longer enough.
What makes big diffs fast
You cannot put a million DOM nodes on a page. The standard answer is virtualization: mount only the rows that are on screen, plus a small margin, and recycle those same DOM elements as the user scrolls. The list behaves as if all million rows exist. The scrollbar is the right size, scroll-to-row works. But only about 100 rows are ever real at once.
For this illusion to hold, something has to supply the geometry. The scrollbar height is the sum of all row heights. The position of row N is the sum of the heights of the rows above it. Jumping to a row, drawing the scrollbar, deciding what’s on screen, it’s all arithmetic over a table of heights. You can build that table from estimates and correct it as rows get measured, and general-purpose variable-height virtualizers do exactly that.
But if every row is a line of code at a known font size, you don’t have to. You can compute the whole table up front and it never changes, so there’s nothing to correct later.
Call this the “all heights known before paint” contract. Our diff surface is built around it:
- An imperative, recycled code-row renderer (no React component per row)
- Typed-array geometry for the offset math
- Backend-owned diff documents streamed structure-first
- An imperative scroll API with exact “scroll to row N“
None of it scales badly, because no per-frame work grows with the total row count. On pure code this design is the right one, and we kept all of it.
How comments change the contract
Now put a review thread in the middle of the diff. How tall is it?
You don’t know, and you can’t know without rendering it. Its height depends on things that only exist at render time, and they can keep changing after first paint:
- Markdown that wraps differently at different widths
- blocks the user can expand or collapse in place
- A reply composer that opens inside the existing thread and grows as you type
- Suggested-change diffs, reactions, edit mode, resolution banners
- Images and async assets that change height when they finish loading
The obvious answer is to reserve a fixed-height slot for each comment, sized by an estimator. It falls apart on a big pull request. An estimator that’s right on average is still wrong at the extremes. It over-reserves most comments, leaving gaps of whitespace, and under-reserves the expensive ones, which clip or sprout a nested scrollbar. If you measure the real height after paint and write it back into the shared offset table, everything below moves, while the user is already scrolling. That’s a scroll jump, and on a big pull request it’s a large one.
So comments need a different contract. “All heights known before paint” is unachievable for this content. What we could promise instead: heights are bounded, measured lazily, and corrections are small and anchored to whatever the user is looking at.
Two geometries instead of one
The idea that made this tractable was to stop forcing one geometry to serve both kinds of content. We split the document’s height into two independent domains:
total height = deterministic code height (exact, known up front)
+ Σ dynamic block effective heights (estimated, then measured)
+ scroll padding
Code geometry keeps the original world. It’s deterministic, prefix-summed, exact, never rebuilt when a comment resizes.
Dynamic block geometry covers everything whose height we can’t predict, such as review threads, drafts, and reply composers. Each one is a block identified by what it is rather than where it currently sits. It has a stable key that survives its content loading, and it’s anchored to a file, line and side rather than to a pixel coordinate, so a reflow can’t lose track of it. We also keep a fingerprint of everything that could change the block’s height: its content, whether a is open, whether a composer is active. And we record the width it was last measured at, rounded into buckets, so an ordinary window resize doesn’t invalidate every measurement in the document.
A block’s effective height is then simple: the measured height if we have a valid one, a cached height if the fingerprint and width still match, and the estimate otherwise. Those heights live in their own index, separate from the code rows, so a resizing comment never forces the code geometry to be rebuilt. And the number of blocks is bounded by comments, not by rows. A few thousand blocks is fine, as long as first paint never mounts or measures all of them at once.
The measurement scheduler, and the mistake we made first
This part took the longest to get right, because our first design was wrong in an instructive way.
The obvious way to measure dynamic content is one ResizeObserver per block, which watches the element and writes its measured height back into the layout whenever it changes. This is what we designed and then rejected during performance hardening. It is the feedback loop that big virtualized surfaces have to avoid. An observer that writes a height back into the layout of the element it’s watching can retrigger itself, and the cost grows with every mounted block.
What shipped instead is a single idle- and scroll-gated measurement pass, held to the same discipline as the deterministic side:
- Off the hot path. It runs when the visible range settles, never once per scroll frame, and waits entirely while a scroll is in flight. A reflow mid-scroll is exactly the jank we’re avoiding. It runs again once scrolling stops.
- Scoped to the viewport. Only blocks within roughly 2400px of the viewport are candidates, so the work is O(viewport). Distant blocks keep riding their estimate and get corrected as they approach.
- On-screen reads win. A mounted block is on screen, so its rendered height is ground truth. The pass reads every mounted candidate in one batch, a single reflow with no writes in between, and records what it finds. A mounted block is never skipped in favor of a stale estimate. That one rule fixed the nastiest bug we hit: comments that rendered with a strip of blank space underneath, because a mounted block had been filtered out of measurement and left sitting on a too-tall estimate.
- Off-screen measurement is a bounded fallback. For a nearby block that hasn’t mounted yet, the pass does at most one off-screen render, to correct its reservation before it scrolls into view. Blocks taller than the viewport skip even that. Their over-reservation hides below the fold, so the render isn’t worth paying for.
- An observer catches the rest. Some height changes don’t move the fingerprint and don’t coincide with a scroll: typing in a reply composer, an image finishing loading, toggling a . Each mounted block keeps a ResizeObserver, but by default all it does is flag the block so the idle pass re-reads it. It never writes a height itself, which is what would close the feedback loop we rejected. It disconnects on unmount, and an inactive pull request tab observes nothing.
- With one deliberate exception. Waiting was visibly wrong for resizes you caused yourself: expanding a , opening a reply composer, an image landing. The block grew immediately, but the code below it only moved on the next idle pass. For one frame the comment was taller while everything under it sat at its old position, and you could see the two steps. So when a block is mounted and on screen, the observer now measures it and applies the correction in the same frame, before paint. The block grows, the code repositions, and everything below shifts together. Two safeguards keep this from becoming the loop we were avoiding: at most one synchronous commit per frame, so a burst of resizes collapses into one, and never during an active scroll, where it falls back to the batched pass.
Scroll anchoring: Correcting without fighting the user
When a measured height differs from its estimate, the scrollbar arithmetic changes, and the naive result is that the viewport jumps. The fix is to correct by identity rather than by pixel:
- Before applying height updates, capture what the user is anchored to (a row or a block, by identity), plus the offset within it.
- Apply the height deltas.
- Resolve that same anchor to its new pixel position.
- Scroll so the anchor stays put in the viewport.
Plus a few rules that keep it from feeling wrong:
- A block above the viewport changing height → adjust by the delta (keeps your place).
- Content hydrating below the viewport → don’t adjust (you can’t see it).
- If you toggled a or opened a reply in a visible block → suppress above-block correction for that block, so the interaction feels direct, and let the content below flow down naturally.
- Never fight active pointer or wheel momentum; batch the correction after the frame.
That last rule has a sharp edge, and it bit us. “Don’t correct while the user is scrolling” was implemented as a guard on the last observed scroll, and 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 you reflows to a different number of visual lines, the whole coordinate space shifts, and the surface emits a small scroll of its own as it settles. The guard read that as “the user just scrolled” and skipped the very correction that was supposed to keep your place, so the file you were reading drifted off screen. The fix was to tell user scrolls apart from ones the surface caused itself. Any “is the user interacting?” check has to be one your own side effects can’t satisfy.
So corrections stay small, they reuse measurements we already have, and they follow whatever you’re looking at.
Part 2: The pipeline behind the surface
A diff surface can only be as fast as the data feeding it, and three habits from that side of the work shaped what the UI could do. The first is stream structure before content. The diff is requested incrementally, so the file tree and metadata paint while the document is still loading, an
正文由 FLUX 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。