Skip to content

fix(router): PPR settled nested loader promises keep promise shape on HITs (#438) - #669

Merged
ivogt merged 1 commit into
mainfrom
fix/ppr-settled-loader-marker
Jul 4, 2026
Merged

ivogt merged 1 commit into
mainfrom
fix/ppr-settled-loader-marker

Conversation

@ivogt

@ivogt ivogt commented Jul 4, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

On a PPR shell HIT, a page whose bake-lane loader carried a nested promise that settled during capture crashed the ENTIRE page: React #438 ("An unsupported type was passed to use()") from the price component, caught by the root error boundary, which replaced the document with the app-level error page. Found live on a storefront PDP (x-rango-shell: HIT + full-page "Internal Server Error"): its ~165ms price fetch settled inside the capture window because a concurrent loader kept the Flight stream byte-active past it, so whether a given shell crashed was a per-capture timing lottery.

Mechanism: elideLoaderContainer inlined a settled nested promise as its RAW value in the loader-family snapshot; on every HIT overlayLoaderContainer handed that plain value to consumers whose code says use(data.prices) — use() requires a thenable.

Before vs. after

// Consumer (unchanged, the common shape):
const { data } = useLoader(PriceLoader);
const prices = use(data.prices);      // data.prices was declared Promise<PriceData>
BEFORE (HIT, promise settled at capture):  data.prices = { SKU1: {...} }  -> use() throws #438 -> root boundary eats the page
AFTER  (HIT, promise settled at capture):  data.prices = Promise.resolve({ SKU1: {...} })  -> renders the pinned value

Payload evidence from the live PDP: prices\":{\"SB4822P...\":{\"price\":60... (inlined object) before; prices\":\"$@18\" (Flight promise reference) after.

Consumer example

const PriceLoader = createLoader(async (ctx) => {
  const prices = (async () => {
    await ctx.rendered();
    const ids = ctx.use(ProductsHandle);
    return fetchPrices(ids);          // sometimes settles inside the capture window
  })();
  return { prices };                  // bake lane: container records, nested promise = hole OR pinned
});

Whether fetchPrices wins the capture window or not, data.prices is now ALWAYS a promise for consumers — pinned value when it settled at capture (shell/payload parity), live stream when it did not.

Semantics

Nested promise state at capture Recorded as On a HIT the consumer gets Changed?
pending hole marker the fresh run's live promise no
settled, value has no deeper holes settled marker Promise.resolve(pinned) resolving immediately was: raw value -> #438
settled, value contains deeper holes settled marker + capture-computed holes bit promise chaining on the fresh run, pinned parts frozen, holes filled live was: raw value -> #438
rejected capture refused n/a (axis 1) no

Code changes

  • src/router/segment-resolution/loader-snapshot.ts — new $rangoLoaderSettled marker (LOADER_SETTLED_KEY, isLoaderSettledMarker). elideLoaderContainer split into a root unwrapper + elideNested (no root flag threading); ElideResult carries hasHole, computed during the walk elide already does, and stamped on the marker as holes: 1 — the per-HIT overlay picks its rehydration path from the bit instead of rescanning the pinned subtree (hot path). Rejecting fresh runs degrade holes to undefined without poisoning the pin.
  • tests/shared-e2e/src/index.ts — guardHydrationErrors hoisted here with ONE canonical string list (adds unminified #438 text + [RootErrorBoundary], which the router's own boundary logs). The two suite-local copies had drifted in the same direction this regression hid behind: dev spells React errors out (no "Minified React error" prefix), and assertions passed off the SSR DOM before hydration errored. Both suites now import the shared guard and waitForHydration inside the guard scope.
  • src/urls/path-helper-types.ts + src/route-definition/helpers-types.ts — parallel() dropped its inference-only TSlots generic (the object literal was an inference site, suppressing contextual typing): bare arrow slot handlers now get a typed ctx. Descriptor arrows still need (ctx: HandlerContext) — StaticHandlerDefinition.handler adds a second callable to the contextual union; pinned in parallel-slot-handler-types.test.ts.
  • Fixtures: /shell-cache/settled (test-app) + /ppr-shell/settled (cloudflare-basic) — bake-lane loader whose nested promise is Promise.resolve(...) at return (always wins the window), consumed by a client component via unconditional use().
  • Docs: skills/ppr (physics caveat now states the shape guarantee + treat fast-resolvers as PINNED for correctness), skills/parallel (slot typing note), docs/design/loader-container-bake.md (settled-marker deviation note).

Test plan

  • Red -> green: 7 new unit regressions fail on the old code (raw-value contract), 17/17 pass with the fix; new e2e fails on old code with the exact error page (error-context.md shows "An unsupported type was passed to use(): Settled fast 2" + Try Again boundary UI), passes with the fix.
  • e2e/shell-cache.test.ts dev 20/20, production 20/20 (includes the new settled test + all suites on the shared guard).
  • tests/cloudflare-basic e2e/ppr-shell.test.ts dev+production 38/38.
  • Semantic matrix 65/65 (segment-resolution touched).
  • pnpm run typecheck, pnpm run test:unit:all (all packages), lint, format — green.
  • Real-app validation: rsc-cloudflare-app linked against this branch, workerd — the previously-crashing PDP HIT renders and hydrates with zero console errors; payload carries a Flight promise ref.
  • *.gen.ts regenerated and committed for both apps that gained routes.

Notes

  • Stale snapshots: none in practice. CFCacheStore versions every KV key as v/<VERSION>/shell:... where VERSION is minted at BUILD time (@rangojs/router:version), so a rebuilt+redeployed worker lives in a fresh namespace and can never read old-format snapshots — one MISS per URL, recapture in the marker format, orphans TTL out. The caveat only applies to a store constructed with a custom static options.version override.
  • Follow-up DROPPED — no per-loader live() revival. A live() primitive already shipped briefly (feat(router): PPR shell caching — createShellCacheMiddleware, prelude+postponed store family, fizz resume #641) and was deliberately removed (feat(router): PPR opt-in via the ppr path option, integral serving, render-defined holes #645) when the mixed-chain capture subsumed its guarantee: loader(() => Promise.resolve(x)) + loading() is the deterministic hole for already-resolved data, and inline-consumed bake-lane values pin gracefully by doctrine (treat fast-resolvers as PINNED — the skill says so). Do not rebuild without a concrete adopter; see docs/design/ppr-shell-resume.md "rejected alternatives".
  • Follow-up (e2e): upgrade the shared hydration guard from console-string matching to a structural boundary signal.

… HITs

A bake-lane loader's nested promise that settled inside the capture window
was recorded as its RAW value; the HIT overlay handed that plain value to
consumers whose code is an unconditional use(data.x) - React #438, root
error boundary, whole page down (found live on a storefront PDP whose 165ms
price fetch won the quiet window because a concurrent loader kept the
Flight stream byte-active past it).

- loader-snapshot.ts: settled nested promises now record behind a
  $rangoLoaderSettled marker; overlayLoaderContainer rehydrates them as
  Promise.resolve(pinned). A capture-computed holes bit on the marker picks
  the rehydration path (fully-pinned resolves immediately; deep holes chain
  on the fresh promise) so HITs never rescan the pinned subtree. The root
  container still unwraps (elideNested owns nested marking - no root flag).
- Red->green: 7 unit regressions + /shell-cache/settled + /ppr-shell/settled
  e2e twins (dev + production, both apps).
- guardHydrationErrors hoisted to @shared/e2e with one canonical string
  list (unminified #438 text + [RootErrorBoundary]); the two suite-local
  copies had already drifted, which is how the regression e2e passed
  against broken code. waitForHydration keeps the error window in scope.
- parallel() DSL: dropped the inference-only TSlots generic in both type
  files - bare arrow slot handlers get a typed ctx (descriptor arrows still
  need an annotation; StaticHandlerDefinition.handler pollutes the union).
- skills/ppr + parallel, loader-container-bake design doc updated.
@pkg-pr-new

pkg-pr-new Bot commented Jul 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 6edc93a

@ivogt
ivogt merged commit 676e92c into main Jul 4, 2026
30 checks passed
@ivogt
ivogt deleted the fix/ppr-settled-loader-marker branch July 4, 2026 17:08
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