Repository navigation
Description
Symptom
On a PPR shell HIT (and once on the axis-1 live path), a subtree renders twice — one copy correctly placed inside its layout, one orphan outside it. Playwright strict-mode: locator('[data-testid="blog-sidebar"]') resolved to 2 elements (also seen as ppr-price ×2). Zero page errors, zero hydration warnings — guardHydrationErrors and expectNoPageError stay silent.
Affected tests (all tests/cloudflare-basic/e2e/ppr-shell.test.ts, dev project, CI shard 2/2): ppr-blog HIT hydrates cleanly (theme + snapshot end-to-end), HIT page hydrates with zero errors, renders and hydrates on the live path. Hit 3/3 CI runs on 2026-07-05 afternoon (PRs #687, #689 — both with diffs that provably cannot reach this code) after 4 green runs on the same shard earlier the same day — the runner pool got slower, which raises the incidence sharply. Local incidence with the CI profile (CI=1 --project=dev --workers=1 --retries=0): ~1–6 per 30.
Bisect: NOT a regression
Test app + specs held constant; only packages/rangojs-router/src swapped per point; 30 tests/point:
| router src at | failed/30 |
|---|---|
| current main-based branch (incl. #685) | 2 |
pre-#674 (d0618fb2, = #669) |
6 |
#663 (70431341) |
2 |
#647 (20c3c423) — the commit that ADDED the fixture+test |
1 |
Rates are within noise of each other; #674/#678/#680/#685 exonerated. The bug has existed as long as the fixture.
Mechanism (pinned by byte-level reproduction)
A cached-loader parallel slot on a PPR HIT produces a two-stage chained stitch on the wire: the prelude's hole is <template id="B:0"> + skeleton; the resume tail re-emits the slot as a still-pending boundary (S:0 containing <template id="B:1"> + duplicate skeleton), then $RC("B:0","S:0"), then the real sidebar as S:1, then $RC("B:1","S:1"), then the __FLIGHT_DATA rows. Stage 2's target template travels inside stage 1's segment.
React 19.2.6's $RC defers stitching (queues into $RB, reveals via rAF/$RV), and every executed path removes the source S:n div — so an orphaned S:1 proves the instruction never executed. Meanwhile isSuspenseInstanceFallback treats a $? boundary as client-renderable fallback once document.readyState !== "loading", so at stream end React silently client-renders the never-completed boundary from the (fully delivered) Flight payload. Result: visible client-rendered copy inside the layout + hidden orphan <div hidden id="S:1"> copy, no errors anywhere.
Proof: a byte-level proxy that drops ONLY the 33-byte $RC("B:1","S:1") script (everything else delivered, stream closed) reproduces the exact failure DOM, stable >6s. Controls: delaying that chunk 6s (readyState still "loading") → no duplication, React waits; truncating the stream there → no hydration at all. The failure state requires precisely: full payload + stream ended + that one instruction inert.
Unverified link: why the instruction goes inert ~1/10 e2e runs. waitForHydration passes in failing runs (payload arrived), so the sidebar completion must flush LATE (the slot's KV-backed loader ran ~1.5s behind under suite load) and the final chunk plausibly gets severed between the S:1 segment div and its $RC instruction (tail stream error → controller.error at rsc-rendering.ts:577, or a workerd→vite-proxy cut). A failing run's wire bytes were not captured (80 healthy fetches showed no anomaly; --retries=0 leaves no trace).
Hardening directions
- Flush a resumed segment div and its
$RCinstruction atomically (never end/sever the composed tail between them). - Or gate
serveShellHit's stream close on the resume's completed-boundary accounting. - Test-side (mitigation only): the strict-mode assertions could scope to the layout container, but that would hide the orphan rather than fix the stitch.
The same mechanism explains the one axis-1 (live path) occurrence — plain fizz streaming uses the identical deferred-reveal machinery.
Activity
ivogt commentedon Aug 30, 2026
Mitigated; the original CI flake has not been the active failure mode since the atomic fizz flush.
Fizz chunks flush as one atomic task so a
$RCinstruction cannot land split from itsS:nsegment (src/ssr/inject-rsc-eager.ts). Tests wait fortoHaveCount(1)so a permanent orphan still fails (tests/cloudflare-basic/e2e/ppr-shell.test.ts). The July 2026 incidence was tied to a slow runner pool (bisect showed the bug existed as long as the fixture). Reopen if shard 2 starts failing on duplicateppr-price/blog-sidebaragain.