Skip to content

flaky e2e: ppr-shell (dev) — PPR HIT parallel-slot duplication: orphan S:n segment when the final $RC instruction never executes #690

Closed

Description

@ivogt

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 $RC instruction 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

  1. ivogt commented on Aug 30, 2026

    @ivogt
    CollaboratorAuthor

    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 $RC instruction cannot land split from its S:n segment (src/ssr/inject-rsc-eager.ts). Tests wait for toHaveCount(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 duplicate ppr-price / blog-sidebar again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions