Repository navigation
fix(router): PPR settled nested loader promises keep promise shape on HITs (#438) - #669
Merged
Merged
Conversation
… 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.
commit: |
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.
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:
elideLoaderContainerinlined a settled nested promise as its RAW value in the loader-family snapshot; on every HIToverlayLoaderContainerhanded that plain value to consumers whose code saysuse(data.prices)—use()requires a thenable.Before vs. after
Payload evidence from the live PDP:
prices\":{\"SB4822P...\":{\"price\":60...(inlined object) before;prices\":\"$@18\"(Flight promise reference) after.Consumer example
Whether
fetchPriceswins the capture window or not,data.pricesis now ALWAYS a promise for consumers — pinned value when it settled at capture (shell/payload parity), live stream when it did not.Semantics
Promise.resolve(pinned)resolving immediatelyholesbitCode changes
src/router/segment-resolution/loader-snapshot.ts— new$rangoLoaderSettledmarker (LOADER_SETTLED_KEY,isLoaderSettledMarker).elideLoaderContainersplit into a root unwrapper +elideNested(norootflag threading);ElideResultcarrieshasHole, computed during the walk elide already does, and stamped on the marker asholes: 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 toundefinedwithout poisoning the pin.tests/shared-e2e/src/index.ts—guardHydrationErrorshoisted here with ONE canonical string list (adds unminified#438text +[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 andwaitForHydrationinside the guard scope.src/urls/path-helper-types.ts+src/route-definition/helpers-types.ts—parallel()dropped its inference-onlyTSlotsgeneric (the object literal was an inference site, suppressing contextual typing): bare arrow slot handlers now get a typedctx. Descriptor arrows still need(ctx: HandlerContext)—StaticHandlerDefinition.handleradds a second callable to the contextual union; pinned inparallel-slot-handler-types.test.ts./shell-cache/settled(test-app) +/ppr-shell/settled(cloudflare-basic) — bake-lane loader whose nested promise isPromise.resolve(...)at return (always wins the window), consumed by a client component via unconditionaluse().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
error-context.mdshows "An unsupported type was passed to use(): Settled fast 2" + Try Again boundary UI), passes with the fix.e2e/shell-cache.test.tsdev 20/20, production 20/20 (includes the new settled test + all suites on the shared guard).tests/cloudflare-basic e2e/ppr-shell.test.tsdev+production 38/38.pnpm run typecheck,pnpm run test:unit:all(all packages), lint, format — green.*.gen.tsregenerated and committed for both apps that gained routes.Notes
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 staticoptions.versionoverride.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".