fix(review): mint content-derived diff cache keys so single-file tabs render fully - #1219
Merged
Conversation
… render fully Single-file diff tabs have not rendered their full-content diff since v0.26.0: the expansion gap bars show no chevrons and clicking them does nothing, at every file size. @pierre/diffs 1.3.2 (the 1.2.8 -> 1.3.2 bump, upstream "Fix diff rerender in edit mode (#878)") added name-based cacheKey defaulting in FileDiff.render: an unset `fileDiff.cacheKey` becomes the file's name. `areDiffTargetsEqual` — the only identity check its render and highlight caches make — compares nothing but that key. DiffViewer renders each file twice on one surviving FileDiff instance (key={filePath}): first the PARTIAL diff from getSingularPatch, then the AUGMENTED full-content diff from processFile once /api/file-content lands. Neither set a cacheKey, so both defaulted to the filename and Pierre served the stale partial render forever. Only the augmented diff is expandable, hence the dead gap bars. Both diffs now mint content-derived keys (`<path>#<hash>` and `<path>#full#<hash>`), matching how AllFilesCodeView already keys its items — which is why the all-files view was never affected. The hash (not patch.length) matters because Pierre's worker highlight cache is a singleton that outlives remounts. The partial diff needs its own key too: with key={filePath} the instance also survives diff-type and base switches, where a same-named new patch would otherwise hit the same name-keyed stale cache. hashString moves from AllFilesCodeView to utils/hashString.ts so both surfaces mint keys the same way. Covered by a new DOM test that mounts DiffViewer against the real @pierre/diffs renderer, holds the /api/file-content response until the non-expandable partial baseline is asserted, then requires the expansion affordances to reach the pixels. It fails against the unfixed tree.
Files over the 5 MB review limit are replaced by a contents-free stub (buildOversizedTrackedStub, plus the untracked equivalent), which renders as a header-only card with no counts and no explanation. Users read that as a broken diff. The stub now carries an explicit marker line in its extended header (OVERSIZED_REVIEW_STUB_MARKER). A marker rather than a client heuristic because the only other signal, `Binary files ... differ`, is exactly what a genuine binary file emits, so a heuristic would put a false size-cap explanation on every image in the diff. The marker lives in shared/diff-paths so the browser bundle can detect it without pulling in the node-facing review core; both server runtimes pick it up from review-core, which vendor.sh already copies to Pi. Git ignores unknown extended-header lines and @pierre/diffs parses the stub identically with or without it, so nothing else moves. Which files get stubbed is unchanged. Both review surfaces now render one line under the file header saying the file is over the limit and only a stub is shown.
…atch based
CI failed two tests that pass locally. Both were timing races, neither was
an app bug.
1. DiffViewer.fullContentSwap: the swap assertion carried a 15s internal
wall-clock budget, which a cold, contended CI runner blows and a warm
laptop clears. Two changes, both aimed at the clock rather than the
symptom:
- The waits are now budgeted in SCHEDULER TURNS, not milliseconds. A
slower box spends longer inside each turn but needs no more of them,
so the budget never has to be retuned for CI hardware.
- Pierre's shared Shiki highlighter is preloaded before mounting. It is
a module singleton, and building it was the entire multi-second cost
the old budget was accidentally measuring; warming it moves that work
into an unbounded await OUTSIDE the observed window. Disposed in
afterAll, because packages/ui/utils/codeHighlight.test.ts asserts the
pre-attachment behaviour of that same singleton.
Verified against an artificially stalled clock: forcing 20s of dead time
into every wait (41s total, far past the old 15s budget) still passes,
and with the cacheKey fix removed it still fails on the assertion (not
as an opaque timeout) in ~12s. A 20-turn budget with the preload removed
and every core saturated also passed 10/10, so 400 turns is a wide
margin rather than a guess.
2. App.archiveReadOnly compared the fenced block's innerHTML before and
after a click. Since #1218, applyHighlight writes plain text first and
swaps in Shiki markup when the grammar attaches, so that MARKUP changes
on its own schedule and the assertion was racing the swap. The test is
checking that the click opened no mutation entry point, which textContent
plus the absence of an annotation <mark> says exactly, and which no
highlight swap can perturb. Latent on main; the branch's run happened to
lose the race.
Also fixed while confirming the above: codeHighlight.test.ts asserted a
GLOBAL precondition ("no grammar attached yet") that any earlier file
attaching a typescript fence invalidates, so
`DOM_TESTS=1 bun test packages/ui packages/editor` failed by file order
alone. It now resets the attachment cache through the existing
__resetCodeHighlightCacheForTests seam and asserts the contract instead of
the run order. Not currently reachable from CI (that file is not in the DOM
list), but one list edit away.
…port why a paint is missing
The preload added in the previous commit made CI strictly worse, so it is
gone. Before it, CI's partial diff painted and only the swap was missing;
with it, CI never painted at all. It was an optimization for a theory the
evidence has since killed, and it mutated a process-wide singleton to buy
it, so it is not worth keeping while the real failure is unexplained. The
afterAll dispose that existed only to undo the preload goes with it.
What the CI log actually shows:
- The "WorkerPoolManager: operation canceled because the pool terminated"
error is inside discardRestoreRender.test.tsx's own group, ~0.3s BEFORE
this file's group opens. It is that file's provider unmounting and
terminating the pool singleton it created: end-of-file teardown, the
same benign noise documented on #1209. It also prints on every local
run, where the whole list passes. It is not a mid-test terminator, and
nothing in this file uses the worker pool (no WorkerPoolContextProvider
is mounted, so useWorkerPool() is undefined and rendering takes the
main-thread path).
- This file's group prints NOTHING for its whole 10.3s: no console.warn
from the stale-content guard, no error. Pierre simply painted nothing.
Not reproducible locally: the exact DOM list from test.yml, one bun
process, forward and reverse order, 13 runs with every core saturated, all
green. So the remaining difference is the environment, which cannot be
reasoned out from here. Three changes make the next CI run answer it
instead of costing another guess:
- renderDiagnostics() dumps what Pierre actually painted (container /
separator / chevron / line-number counts plus a markup fragment) when a
wait gives up. Prints only on failure, so it is worth keeping.
- The precondition is asserted rather than assumed: the REAL
getSingularPatch and processFile must produce partial-then-full on
these fixtures. Bun's mock.module is process global and an earlier file
in this very list mocks '@pierre/diffs', so a leaked mock now fails in
milliseconds with a clear message instead of as a render that never
arrives.
- The first paint is now REPORTED, not asserted. The verdict belongs to
the swap; gating on the partial paint let a slow or absent first paint
mask the result the test exists for. Removing the cacheKey fix still
fails it (verified), because that tree paints no chevrons at any point.
Also fixed a real trap in the fixture: the hunk header said @@ -61 while
its context lines start at line 59 of both contents. Pierre realigns a
misaligned header rather than rejecting it, so it was silently tolerated.
… diff tests
Root cause, and it was never a timing problem.
AllFilesCodeView.lifecycle.test.tsx calls
`mock.module('@pierre/diffs', ...)` with a hunk-less `getSingularPatch` and
`processFile: () => null`. Bun's module mocks are process global and are not
unwound at file boundaries, and that file sits immediately before
DiffViewer.fullContentSwap.test.tsx in the DOM step's list. On the Linux
runner the stub reached this file; on macOS it did not, which is why 13
local runs of the exact list, both orders, cores saturated, stayed green.
It explains both CI symptoms exactly, including the one that looked like a
contradiction: `processFile: () => null` means the augmented diff never
exists, so no chevrons ever (the failure before the preload); the stub
`getSingularPatch` has `hunks: []`, so nothing paints at all (the failure
after it). The "WorkerPoolManager: operation canceled because the pool
terminated" line was a red herring throughout: it is inside
discardRestoreRender's own group, ~0.3s BEFORE this file's group opens, is
that file's provider unmounting the pool it created, and prints on every
local run too.
The precondition assertion added in the previous commit is what proved it,
turning a 10.3s mystery into a 0.45ms verdict:
228 | expect(expected?.isPartial).toBe(false);
error: expect(received).toBe(expected)
Expected: false
Received: undefined
Fixed at both ends:
- Source: the mocking file now captures the real modules before it stubs
them and restores both library specifiers in afterAll, so no later file
in any run inherits its stubs. This fixes the class for every future
DOM test that needs the real renderer, which was the actual leak.
- Consumer: the two tests that render against the real @pierre/diffs get
their own CI step, the same isolation (and for the same kind of reason)
this workflow already gives useFileBrowser.test.tsx. They are removed
from the shared list so that step is their single source of truth. The
restore above should make this unnecessary; it is not something to bet
a green build on from a machine that cannot reproduce the platform
behaviour.
Verified with a CI-faithful harness: one bun process per step, the exact
lists from test.yml, isolated + shared-forward + shared-reverse, 10
iterations with every core saturated, then 6 more after the final split.
All green, plus the full suite and typecheck.
backnotprop
force-pushed
the
fix/single-file-diff-cache-key
branch
from
August 6, 2026 07:23
560b4e5 to
e377f54
Compare
backnotprop
added a commit
that referenced
this pull request
Aug 6, 2026
Harmonizes with #1219's oversized-file notice at the three points they overlap. diff-paths.ts keeps both detectors, ordered specific then general: isOversizedReviewStubPatch (marker-based, from #1219) stays first, and isContentlessBinaryPatch follows as the general fallback with a doc note that callers ask the marker first. Both mount points render exactly one notice. A marker-carrying stub gets OversizedFileNotice, which knows the file is over the size cap; anything else hunkless and binary (a genuine binary, or a stub shape the marker does not cover) gets BinaryFileNotice. The fallback is gated on the marker so the two can never stack on one card. The new DOM test moves into #1219's isolated real-renderer CI step rather than the shared list, for the same reason that step exists.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TLDR: single-file diff tabs stopped rendering their full-content diff in v0.26.0. Click a file in the sidebar and the expansion gap bars have no chevrons and clicks do nothing, at every file size. Cause: the
@pierre/diffs1.2.8 to 1.3.2 bump added name-basedcacheKeydefaulting, andDiffViewerhands two different diffs of the same file to one survivingFileDiffinstance, so Pierre treated them as identical and served the stale first render forever. Fix is one class of one-liners: mint content-derived cache keys for both diffs. Resolves the "expand file does nothing" report.The upstream mechanism
FileDiff.renderin 1.3.2 defaults an unset key to the file's name:and
areDiffTargetsEqual, the only identity check its render and highlight caches make, compares nothing else:DiffViewerrenders every file twice on one instance (key={filePath}):getSingularPatch), which Pierre marks non-expandable, andprocessFile) once/api/file-contentresolves, which is the only one that can be expanded.Neither set a
cacheKey, so both defaulted tocalc.tsand step 2 was served step 1's cached render. The all-files view was never affected becauseAllFilesCodeViewalready mints content-derived keys for its items.The partial diff needs a key of its own too: with
key={filePath}the instance also survives diff-type, base and whitespace switches, where a same-named new patch would otherwise land on the same name-keyed stale cache.Content hash, not
patch.length: Pierre's worker highlight cache is a singleton that outlives remounts, so a same-length different-content patch must not collide either.hashStringmoved out ofAllFilesCodeViewintoutils/hashString.tsso both surfaces mint keys the same way.Affected versions
@pierre/diffsThe 1.3.1 bump landed in #1190 and 1.3.2 in #1191, both after the v0.25.1 tag.
Failing-then-passing proof
packages/review-editor/components/DiffViewer.fullContentSwap.test.tsxmountsDiffVieweragainst the REAL@pierre/diffsrenderer (no diff mocks, since the defect lives entirely inside its render cache), holds the/api/file-contentresponse until the non-expandable partial baseline has been asserted, then requires the expansion affordances to reach the pixels.Against the unfixed tree:
With the fix:
1 pass, 0 fail.Live-verified against a real review server on a scratch repo: a single-file tab now shows chevrons on its gap bars, and clicking one expands
197 unmodified linesdown to97with the real file content in between. All-files expansion is unchanged.Also in this PR: explaining the oversized stub
Second commit, separate concern, same area. Files over the 5 MB review limit are replaced server-side by a contents-free stub, which rendered as a header-only card with no counts and no explanation. Users read that as broken too.
The stub now carries an explicit marker line in its extended header, and both review surfaces render one line under the file header saying the file is over the limit and only a stub is shown. A marker rather than a client heuristic because the only other signal,
Binary files ... differ, is exactly what a genuine binary file emits, so a heuristic would put a false size-cap explanation on every image in a diff. The marker lives inshared/diff-pathsso the browser bundle can detect it without pulling in the node-facing review core; both server runtimes get it fromreview-core, whichvendor.shalready copies to Pi. Git ignores unknown extended-header lines and@pierre/diffsparses the stub identically with or without it. Which files get stubbed is unchanged.Covered by
DiffViewer.oversizedStub.test.tsx(stub explains itself; a genuine binary file and an ordinary text diff never show the line) plus review-core tests for the marker's presence, its absence from ordinary and genuinely binary diffs, and label-versus-byte-cap drift.Relation to #1208
Same family, different hole. #1208 is Pierre's
renderDiffrefusing to swap a HIGHLIGHTED render cache for new content, worked around inwriteRestore. This one is upstream of that check entirely: the two diffs were never recognised as different content in the first place, because their cache-key identity collided. Both are consequences of the same invariant, that every diff object handed to a survivingFileDiffmust carry a content-derivedcacheKey, which is now written down at both mint sites.Verification
bun test: 2928 pass, 0 failDOM_TESTS=1 bun test packages/review-editor packages/ui: 958 pass, 0 failbun run typecheck: clean.github/workflows/test.ymlThis change was made with AI assistance. The root cause was established against the unpatched
@pierre/diffsdist and confirmed with the failing-then-passing DOM test above plus live browser verification.Addendum: the CI failures on this branch, and what they turned out to be
This PR's first CI runs failed the new DOM test even though it passed locally. It was never a timing problem, and the fix is unrelated to the product change above.
AllFilesCodeView.lifecycle.test.tsxcallsmock.module('@pierre/diffs', ...)with a hunk-lessgetSingularPatchandprocessFile: () => null. Bun's module mocks are process global and are not unwound at file boundaries, and that file sits immediately beforeDiffViewer.fullContentSwap.test.tsxin the DOM step's list. On the Linux runner the stub reached this file; on macOS it did not, which is why 13 local runs of the exact list, in both orders with every core saturated, stayed green.That single cause explains both symptoms, including the pair that looked contradictory:
processFile: () => null, so the augmented diff never existsgetSingularPatchreturnshunks: []The
WorkerPoolManager: operation canceled because the pool terminatedline in the log was a red herring. It sits insidediscardRestoreRender's own group, about 0.3s before this file's group opens, is that file's provider unmounting the pool it created, and prints on every local run too.A precondition assertion added while diagnosing is what proved it, turning a 10.3s mystery into a 0.45ms verdict:
Fixed at both ends:
afterAll, so no later file in any run inherits its stubs. This is the actual leak, and fixing it there covers every future DOM test that needs the real renderer.@pierre/diffsget their own CI step, the same isolation this workflow already givesuseFileBrowser.test.tsx, and are removed from the shared list so that step is their single source of truth. The restore above should make this unnecessary; it is not something to bet a green build on from a machine that cannot reproduce the platform behaviour.Two smaller things were fixed along the way:
App.archiveReadOnly.test.tsxcompared a fenced block'sinnerHTMLbefore and after a click. Since perf(ui): single Shiki highlighter, palette-matched code blocks, drop highlight.js #1218applyHighlightwrites plain text first and swaps in Shiki markup when the grammar attaches, so that markup changes on its own schedule and the assertion was racing the swap. It now comparestextContent, which is what "the click opened no mutation entry point" actually means and which no highlight swap can perturb. Latent on main; this branch's run happened to lose the race.codeHighlight.test.tsasserted a global precondition ("no grammar attached yet") that any earlier file rendering a typescript fence invalidates, soDOM_TESTS=1 bun test packages/ui packages/editorfailed on file order alone. It now resets the attachment cache through the existing__resetCodeHighlightCacheForTestsseam.The swap test's waits are also budgeted in scheduler turns rather than milliseconds now, so a slower runner needs no more of them and the budget never has to be retuned for CI hardware.
Verification for all of it: a CI-faithful harness running one bun process per step with the exact lists from
test.yml(isolated, shared forward, shared reverse), 10 iterations with every core saturated, then 6 more after the final split. All green, plus the full suite and typecheck.