Skip to content

fix(ppr): shell serve-gate hardening — buildVersion gate, payload integrity check, tail-failure recapture (#684 SSR-03/04) - #687

Merged
ivogt merged 3 commits into
mainfrom
fix/shell-hit-integrity-and-build-version
Jul 5, 2026
Merged

ivogt merged 3 commits into
mainfrom
fix/shell-hit-integrity-and-build-version

Conversation

@ivogt

@ivogt ivogt commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Closes out the SSR-03 and SSR-04 investigate items of #684 (verdicts + full trace in the issue comment).

Problem statement

A PPR shell entry stores React's postponed state, which encodes hole positions against one exact tree. The serve gate validated only entry.reactVersion === React.version — so with a persistent shared store (KV / Vercel runtime-cache, which survive deploys), an app redeploy that keeps the same React version left a stale build's shell live under the same key. Resuming it against the new build's Flight tree-mismatches inside resume() after the 200 + full static prelude already flushed: the visitor gets a visually complete page that never hydrates, and because nothing evicts the entry and a failed tail scheduled no recapture, every subsequent request re-serves the same broken page until TTL (default 300s; consumer-set can be hours). A corrupt stored payload (store-layer fault: truncated postponed, undecodable prelude) hit the same post-commit failure mode. VercelCacheStore is the sharpest exposure: it has no default version prefix, so naive getCache() wiring crosses deploys silently; CFCacheStore's v/{version}/ keyspace rotation mitigates by default but is disabled by a pinned options.version. The ShellCacheEntry doc even promised "never mixed across a React upgrade or a build change" — documented, unenforced.

Before vs. after

Before (deploy new app code, same React, warm persistent store):

GET /product/1  ->  200, x-rango-shell: HIT, full static shell paints...
                    tail resume() tree-mismatch -> stream aborts
                    page never hydrates; identical on EVERY request until TTL

After:

GET /product/1  ->  200, x-rango-shell: MISS (stale-build entry fails the gate)
                    normal axis-1 page, fully working
                    background recapture overwrites the entry
GET /product/1  ->  200, x-rango-shell: HIT (current build's shell)

What changed (three coordinated guards, all self-healing to MISS + recapture)

  1. buildVersion validity gate. ShellCacheEntry.buildVersion is stamped at capture from the new required ShellCaptureDescriptor.buildVersion (= HandlerContext.version, the @rangojs/router:version build stamp — new hex value every build, bumped on dev RSC edits). isValidShellHit(entry, buildVersion) now gates on reactVersion AND buildVersion; an entry without the field (stored pre-field) is a miss (pre-release, no compat shim). Gate-not-key by design: a mismatch recaptures over the same key (the established reactVersion doctrine) instead of orphaning dead KV entries per deploy.
  2. hasIntactShellPayload pre-commit integrity check (rsc/shell-serve.ts): try-decode the base64 prelude + try-parse the postponed JSON at the HIT gate, before anything commits. Corrupt entries report via reportCacheError and degrade to MISS. Cost: one duplicate decode/parse per HIT, sub-ms.
  3. Tail-failure recapture (serveShellHit stream catch): the pre-commit gates cannot catch a parseable-but-mismatched blob or a hard render error above the holes — those still throw post-commit. The catch now schedules scheduleShellCapture so the entry heals on the next request instead of persisting its full TTL. A client disconnect landing in the same catch schedules a spurious-but-idempotent recapture, bounded by the existing stampede guard + backoff.

Store envelopes: bv added to KVShellEnvelope (CF) and VercelShellEnvelope (+ validator) — dropping it in either direction would turn every persisted HIT into an infinite MISS, so both round-trips are pinned by tests.

Semantics table

Stored entry state Before After
reactVersion mismatch MISS + recapture unchanged
buildVersion mismatch (redeploy, same React) HIT → committed-200 dead page until TTL MISS + recapture heals
no buildVersion field (pre-field entry) HIT (unknowable build) MISS + recapture re-stamps
corrupt postponed JSON HIT → dead page until TTL MISS + reportCacheError + recapture
undecodable prelude base64 throw pre-commit (500-path), repeating MISS + reportCacheError + recapture
parseable blob, resume() still throws broken serve repeating until TTL broken serve ONCE; catch schedules healing recapture
valid current entry (incl. DATA variant, postponed null) HIT unchanged
dev HMR (named memory store surviving reloads) stale shell until TTL RSC edit bumps VERSION → MISS + recapture

Consumer example

A Vercel deployment with the documented store wiring is now safe across deploys even without a version namespace:

import { getCache } from "@vercel/functions";
import { VercelCacheStore } from "@rangojs/router/cache/vercel";

const router = createRouter({
  cache: { store: new VercelCacheStore({ cache: getCache() }) }, // no version — previously served stale-build shells after deploy
  routes: (r) => [
    r.path("/products/:id", ProductPage, { name: "product", ppr: { ttl: 600, swr: 120 } }, () => [
      r.loader(LivePriceLoader),
      r.loading(<PriceSkeleton />),
    ]),
  ],
});

Code-change summary

  • src/cache/types.ts — ShellCacheEntry.buildVersion? + doc now states both enforced gates.
  • src/rsc/shell-serve.ts — isValidShellHit(entry, buildVersion), new hasIntactShellPayload(entry).
  • src/rsc/rsc-rendering.ts — gate call passes ctx.version; corrupt-entry branch reports + falls to the existing MISS path; descriptor carries buildVersion; serveShellHit takes the descriptor and schedules recapture in its stream catch.
  • src/rsc/shell-capture.ts — ShellCaptureDescriptor.buildVersion (required) stamped into the stored entry.
  • src/cache/cf/cf-cache-store.ts, src/cache/vercel/vercel-cache-store.ts — bv envelope field, both directions (+ Vercel validator).
  • test-app: /shell-cache/__corrupt test-only fault injector (e2e/test-app/src/urls/shell-cache.tsx) + router.named-routes.gen.ts (one line).
  • Docs: ppr-shell-resume.md (entry sketch, serve plumbing, upgrade matrix + new redeploy row), feature-map.md, feature-file-map.md.

Location rationale: the gate stays in shell-serve.ts (the serve-plumbing owner); stamping rides the descriptor because captureAndStoreShell deliberately has no HandlerContext and the descriptor is the existing capture-policy carrier.

Test plan

  • Red-before-green — new tests against main (git stash of the src fix): 8 failed — 5 serve-gate (rsc-rendering-shell-ppr.test.ts: buildVersion mismatch, missing buildVersion, corrupt postponed, corrupt prelude, tail-failure recapture), 1 capture stamp (shell-capture.test.ts), 2 KV envelope round-trips (cf-cache-store-shell.test.ts). With the fix: 74 passed in the same three files.
    Tests  8 failed | 66 passed (74)   # main src
    Tests  74 passed (74)              # with fix
    
  • New unit tests drive the REAL serve path (real memory store, real shell-serve logic, mocked capture-dispatch seam only); pinned contracts: each bad-entry state → MISS + recapture descriptor stamped with the running build; tail failure → healing recapture; bv KV round-trip both stores.
  • Dev + production e2e (shell-cache.test.ts, both existing describes): corrupt-postponed and stale-buildVersion entries each degrade to a working MISS (page HTML intact) and heal back to HIT via recapture — 2 passed in --project=dev, 2 passed in --project=production.
  • pnpm run typecheck — 16/16 tasks clean.
  • pnpm run test:unit:all — router 4987 passed | 6 todo; all consumer suites green.
  • pnpm run lint 0/0; pnpm run format clean on all tracked files; pnpm check:e2e-bucketing OK.
  • *.gen.ts: only test-app/src/router.named-routes.gen.ts (+1 line, the fixture route); no other app's routes changed.

Notes

  • Plan 007 (hole-free HIT replay, NO-GO) was evaluated with this change in mind — if ever reopened it sequences AFTER this gate, which it depends on for stale-build hygiene.
  • Plan 009 (capture barrier inheritance) touches the same capture area and is the next follow-up PR after the SWR swap fix (plan 010).
  • The __corrupt fixture mirrors buildShellKey for single-search-param URLs; it is a test-app-only endpoint, not shipped surface.

@pkg-pr-new

pkg-pr-new Bot commented Jul 5, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@687

commit: 7359398

…e-commit payload integrity check, tail-failure recapture (#684 SSR-03/SSR-04)

A persistent shell store (KV/runtime-cache) survives deploys, but
isValidShellHit gated only on reactVersion — an app redeploy with the same
React left a stale build's prelude+postponed live under the same key, and
resuming it against the new build's Flight tree-mismatches AFTER the 200 +
full static prelude flushed: a visually complete page that never hydrates,
re-served on every request until TTL (no eviction path, no recapture on
failure). Corrupt stored payloads hit the same post-commit failure mode.

Three coordinated guards, all degrading to a working axis-1 MISS whose
background recapture overwrites the bad entry (self-healing):

- ShellCacheEntry.buildVersion, stamped at capture from the new required
  ShellCaptureDescriptor.buildVersion (= HandlerContext.version, the
  @rangojs/router:version build stamp); isValidShellHit(entry, buildVersion)
  now gates on reactVersion AND buildVersion; pre-field entries miss.
  Carried through the CF (bv) and Vercel (bv) shell envelopes — dropping it
  in either direction would make every persisted HIT an infinite MISS.
- hasIntactShellPayload: pre-commit base64/JSON integrity check; a corrupt
  entry reports via reportCacheError and serves MISS instead of a dead page.
- serveShellHit's stream catch schedules a healing recapture: a
  parseable-but-mismatched postponed blob or hard render error above the
  holes throws post-commit; without this the same entry re-fails every
  request until it ages out.

Red-before-green: 8 tests fail on main (5 serve-gate + 1 capture stamp +
2 KV envelope round-trips), all green with the fix. Dev + production e2e
via the new /shell-cache/__corrupt fault injector in test-app.
@ivogt
ivogt force-pushed the fix/shell-hit-integrity-and-build-version branch from ac36fc9 to 475c6a0 Compare July 5, 2026 14:02
@ivogt
ivogt marked this pull request as ready for review July 5, 2026 14:02
@ivogt

ivogt commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Added to this PR after the fact: e2e/rango-state-version.test.ts — locks the mid-session deploy contract end-to-end in dev + production. (1) a router fetch carrying a stale _rsc_v gets 200 + X-RSC-Reload with internal params stripped (the full-refresh signal that drops every in-memory cache); (2) a rango-state cookie left by a previous build is re-minted with the running build's version at boot, which rotates every prefetch storage key (rangoState\0…) and the Vary: X-Rango-State HTTP key — all pre-deploy prefetches disqualify. The behavior itself already existed (request-classification version-mismatch, handleReloadHeader window.location.href, initRangoState deploy-bust mint); this pins it. Both tests verified locally in both modes (2+2 passed).

…ty-and-build-version

# Conflicts:
#	packages/rangojs-router/docs/internal/feature-file-map.md
@ivogt
ivogt merged commit a1cae54 into main Jul 5, 2026
8 of 34 checks passed
@ivogt
ivogt deleted the fix/shell-hit-integrity-and-build-version branch July 5, 2026 15:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant