Repository navigation
feat(loaders): streaming loader data with implicitly-suspending useLoader - #813
Merged
Merged
Conversation
Experimental: loaders are no longer awaited during the tree build. Segments without loading() pass per-loader UNDECODED results (possibly pending promises) through OutletProvider.loaderStreams; LoaderResolver splits a loading() segment's aggregate into stable per-loader promises instead of resolving above the children; useLoader use()es a pending stream at the read site — implicit suspension to the nearest consumer Suspense boundary, with loading() remaining the outer fallback. forceAwait (popstate, stale revalidation, fully-prefetched) and action lanes keep the blocking await so those commits stay whole. Consumer goal: wrap any useLoader read in Suspense and get a local fallback while that one loader streams, instead of the whole route gating on the slowest loader. Verified: full unit gate green (segment-system pins updated to the streaming contract, forceAwait lane pinned); RSC suite green; consumer dogfood suites green including renderRoute reads through streams; 1619 e2e passed dev+prod on the spike run, including the streaming-loader suites. Known e2e divergences to resolve before merge (4 classes, 10 failures): - SSR loading() fallback emission: a client-component fallback that suspends cold escalates past its boundary and Fizz inlines content with no skeleton (plain JSX fallbacks unaffected); resolver-level suspension was the emission mechanism (segment-loader-promise.ts zero-loader case). - errorBoundary() fallback for loader errors is bypassed: read-site decode throws to the nearest React error boundary (divergence pinned in segment-system.test.tsx). - LateHandlePushError: meta handle collection completes before a streamed subtree pushes __rsc_router_meta__ (mini intercept). - PPR shell capture bakes live-lane slot values: live holes rode boundary-level suspension. Direction: keep live-lane loader streams PENDING during capture so readers postpone at read granularity — finer holes than boundary-level, less accidental baking. Plus one unattributed dev hydration mismatch and a loading-visibility timing flake (fallbacks show only while a reader is suspended).
The gated demo exposed a flaw in the first spike commit: LoaderResolver derived per-loader promises by splitting the aggregate (aggregate.then(r => r[i])), but Promise.all resolves at the SLOWEST loader, so every derived promise inherited the slowest timing — a 400ms loader's content held until its 2000ms sibling resolved and loading() routes lost per-loader granularity entirely (measured: whole gated page at +2032ms; after the fix the route reveals at +413ms and the slow card fills at +2014ms). loading() segments now pass per-loader streams built from the individual loader.loaderData refs at the producer (segment-system), the same channel the no-loading() branch uses. LoaderResolver pass-through engages only when streams are provided; without them the pre-spike resolve-above behavior is restored — parallel/intercept slots (aggregate-only on ResolvedSegment) and zero-loader boundaries take that path again, which also restores the fresh-promise suspension that makes SSR emit the loading() fallback for content-suspending routes.
/suspense-demo — no loading(), three loaders (400/1200/2000ms): the hero paints immediately and each card suspends on its own useLoader read inside its own Suspense boundary, filling as its loader streams in. Verified in-browser on soft nav: cards painted 800ms apart, matching the loader stagger exactly. On main this route shape blocks the whole tree build on the slowest loader. /suspense-demo/gated — the contrast: route-level loading() holds the page until the unwrapped 400ms read resolves, then the wrapped 2000ms report keeps filling behind its local skeleton (painted 1600ms after stats, verified in-browser). loading() is plain JSX deliberately — a client-component fallback that suspends cold is a known SSR-emission divergence on this branch.
/suspense-demo/ppr — ppr + loading(). The renderable loading() puts all three loaders in the LIVE lane (entryLoadingMasksLoaders): the capture masks them with never-resolving promises, and with read-site suspension each masked stream postpones at its card's OWN consumer Suspense boundary — the frozen shell holds the hero plus three skeletons as three separate holes (finer than main's single LoaderBoundary hole). Verified against the dev shell store: x-rango-shell HIT, byte-level timing shows the cached shell's first bytes at ~40ms with holes filling live at ~400/1200/2000ms per loader, and loaders re-run fresh on every HIT (servedAt advances per request — nothing bakes).
CONTRACT CHANGE, paired with read-site useLoader suspension: the loading()-keyed lane trigger is gone. At shell capture every route loader is masked (never executes); its pending stream suspends the consumer's own Suspense boundary and prerender postpones it as a hole — holes form at inline boundary granularity with no loading() required. Baking route data into a shell is expressed via cache()/"use cache", not by omitting loading(). loading() is now pure UX sugar (a route-level boundary), still useful for async RSC route components whose suspension is above their own JSX. The bake-at-capture path (execute + nested-thenable mask + snapshot pinning + HIT seed overlay) is bypassed: no records register, the overlay is a no-op, every HIT runs loaders fresh. A masked reader with NO boundary above it root-postpones and the <body> sanity gate refuses the shell (runtime render + the existing eternal-MISS warning) — the intended degrade for boundary-less ppr routes. Demonstrated on cloudflare-basic /suspense-demo/ppr with NO loading(): MISS captures a shell whose first HIT bytes land ~10ms carrying hero + three skeletons, holes fill live at ~400/1200/2000ms per loader, servedAt advances every request (nothing bakes). Dropping loading() WITHOUT this flip was measured first: loaders executed at capture and card values froze into the shared shell, plus a hydration mismatch between the frozen markup and the fresh hydration payload — the footgun this contract removes. Unit pins updated to the new contract: loader-mask-capture (no-loading() entry now masks, loader never called), shell-capture-barrier-isolation (capture never executes route loaders; barrier isolation still pinned via handler pushes and own-property lifecycle). E2e bake-lane fixtures (ppr-bake, /ppr-shell/no-hole negative, loader-container-bake pins, consumption-lane rows involving DSL-loader baking) are known-divergent — to be updated with the semantic matrix when this contract is adopted.
…Suspense only /suspense-demo/ppr now has NO loading(): under the live-at-capture contract the three inline consumer Suspense boundaries are the holes. Shell HIT first bytes ~10ms with hero + three skeletons, live fills at ~400/1200/2000ms, fresh servedAt per request. Also fixes a hydration hazard in the demo cards: paintedAt rendered performance.now() during SSR, which can never match the client and guaranteed a hydration text mismatch on every route. The stamp is now client-only (useEffect), SSR renders a stable placeholder.
The route-level loading() boundary made every same-route re-render swap the whole product page for the skeleton — visibly on ?tab= navigations, where the handler re-runs to render the other tab while BOTH loaders and the route predicate are revalidate-false (nothing refetches; only the content promise is fresh). Without loading() the commit holds the current content until the new tab's content is ready — no flash. This is the recommended shape under read-site useLoader suspension: inline Suspense at actual data reads, transition() for animated holds, route-level loading() reserved for a deliberate first-paint skeleton.
…oute" This reverts commit e6a074d.
<Outlet fallback={...}> wraps the outlet position in a plain Suspense
boundary: the layout that renders the hole declares its placeholder, with
no DSL wiring and no segment-resolution threading. Works for the default
outlet and named slots; an explicit prop wins over the child segment's
loading() at the outlet wrap, while a child's own loading() boundary
still sits closer to its suspension and wins for its content.
Under read-site useLoader suspension this is the natural home for
pending UI — the doctrine is use(whatWeWaitFor) at consumption sites
with boundaries where waiting should show, and the outlet is the
layout's boundary. Handler awaits remain pipeline-blocking by contract
(redirect/404/meta authority): this fallback covers loader latency and
streamed content, not handler latency — measured on main, no pending UI
has ever covered handler awaits (2s blank then a 41ms skeleton cameo on
the document lane).
vite-rsc-demo's ShopLayout adopts it as the demo usage; the product
route keeps its loading({ssr:true}) canary unchanged.
Experimental (this branch): ParallelOutlet parity, unit coverage, and
docs land when the API graduates.
commit: |
…fallback The streaming contract moved loader-error surfacing from the build-time errorFallback swap to a read-site throw, which bypassed the DSL's errorBoundary() entirely. Restored: decodeLoaderEntry attaches the pre-rendered boundary node to the thrown error via the LOADER_ERROR_FALLBACK marker, and segment-system wraps every loader-bearing segment's children in the router-owned StreamedLoaderErrorBoundary (unconditionally — streams and forceAwait lanes must produce the same tree shape or lane changes remount the subtree). The boundary renders the marker's fallback; unmarked errors rethrow to app boundaries. Contract verified red-before-green at the consumer level: the error-boundary streaming-errors e2e (dev + production) failed with the read-site throw escaping to the root boundary and passes with the routing restored — no fixture changes needed. Also reworks the three test-app shell-cache pins that encoded the removed bake-at-capture lane to the live-unconditional contract (measured): /shell-cache/no-hole and /settled stay MISS (boundary-less reader, capture refuses, axis 1 fresh per request); /bake-slow HITs with the label LIVE per request (reader under the child's loading(); the pin-first fast path is gone with the lane). ctx.use consumption (handler/layout) is untouched and still bakes — semantic-matrix stays green in both modes (28 prod + 25 dev), shell-cache 41/41 both modes.
…apture contract Mirrors the test-app pin rework: /ppr-shell/no-hole and /settled pin eternal-MISS (boundary-less readers, capture refuses, axis 1 fresh); /ppr-shell/bake-slow pins the LIVE advancing label (no pin-first). Fixture updates to the new-contract shape, preserving each test's intent: /ppr-shell/inline-action gains loading() (the page hole the bound action streams through — the bake lane can no longer produce its shell; MISS/HIT/partial-replay all green); cache-lab wraps the pulse reader in its own Suspense (products still bake via the handler, pulse holes live — all four cache-lab tests green). Full production suite: 419 passed; the 2 remaining failures are pre-existing on main (verified by base runs: default-prefetch-none offscreen-intersection fails 3/3 on main; location-state history-restore flakes ~50% on main with a hidden-boundary restore window) — tracked separately, not part of this branch.
CI on 3bf4bf6 surfaced the two fixture mirrors the local sweep missed: - test-app /inline-bound-action gains loading() (the page hole the bound action streams through) — same shape as the cloudflare-basic twin; dev + production suites 3/3 each locally. - vercel-basic CacheLabPage wraps its pulse reader in Suspense — same shape as the cloudflare-basic cache-lab; full suite 24/24 locally. With these, the only remaining red CI job is cloudflare-basic production shard 1's default-prefetch-none offscreen-intersection test, which is pre-existing: main's own CI fails on exactly that job (runs 30143307549/30065784608 on 486c400), and the test fails 3/3 on a local main checkout.
ivogt
added a commit
that referenced
this pull request
Jul 28, 2026
…ane, loading() optional when fully baked The flag's document promise is "this loader's data, handle pushes, and status are in the HTML before first flush"; under ppr that silently degraded — the awaitBeforeFlush await was skipped at capture and the loader masked like any live-lane loader, so its data arrived in the resume stream, never the prelude. The capture lane decision is now per LOADER: - stream:"navigation" — the BAKE lane. Executes at capture; the SETTLED return is shell material; nested thenables stay masked (shape is the liveness declaration — the #692 cross-session scar) and postpone as holes; the masked container registers on _shellCaptureLoaderRecords, holding the capture gate (bounded by ppr.captureTimeout) and pinning into the shell snapshot so HIT tails replay the baked value and hydration stays byte-agreed with the frozen prelude. The dormant post-#813 bake machinery (gate hold, snapshot drain, seed overlay, pin-first) re-engages unchanged; only the trigger moved from "entry lacks loading()" to the explicit per-loader flag. - Every other loader — LIVE unconditionally, exactly as #813 left it: masked, postponed at loading() or an inline Suspense, boundary-less reads refuse the capture. loading() is therefore OPTIONAL when every loader is flagged: nothing masks, the shell captures complete instead of the eternal-MISS refusal. Groups inherit through the existing awaitedLoaderIndices projection — no group-specific code. Red-proven: the flipped await-before-flush unit (flagged loader executes + registers its record at capture; plain sibling never runs) and both e2e pins fail without the capture branch (baked value absent from the prelude; the fully-baked route degrades to eternal MISS). New pins, dev+prod: "stream:navigation loader bakes into the shell; nested promise and plain sibling stay live" (frozen-vs-live seqs across HITs), "fully-baked route needs no loading()", and the group-ppr probe extended with a baked group loader frozen in the group shell. Sweeps green: shell-cache + shell-cache-action + prerender-ppr + semantic-matrix, 88 dev / 90 prod. Docs: loader-container-bake.md addendum (trigger moved), feature-map PPR rows, client-urls.md, stream option docs both DSL surfaces, fixture doctrine header.
Merged
8 tasks done
ivogt
added a commit
that referenced
this pull request
Jul 28, 2026
…ng for group routes (#817) * feat(client-urls): ppr as a projected group path option — shell caching for group routes clientUrls() routes can now declare `ppr: true | PartialPrerenderProps`. The option was an explicit projection rejection with no structural reason (unlike parallel/boundaries) — the config is pure JSON and the runtime shell machinery is route-generic, so this lifts the rejection and threads the option through the one seam that exists: DSL validation (validateClientPpr, fails in the authoring module), projection serialization (serializePpr — frozen JSON copy, re-validated independently), and materializedPathOptions, where the materialized server path() stamps it on the manifest entry so resolvePprConfig classifies the group route exactly like a hand-written ppr page. The interplay that motivated this lands whole: the group's static markup freezes into the shell INCLUDING its useSearchParams read — search is part of shell identity (the key embeds the sorted search and the capture render seeds that same string), so the frozen value matches the URL the shell serves and HIT hydration is clean. loading() subtrees stay the live holes; the server-route rules (loader routes need loading() or capture refuses, actions/PE/nonce take the live axis) apply unchanged. Red-proven at both levels: the manifest round-trip unit (entry.ppr undefined without the materialization spread) and the e2e (x-rango-shell header absent without the projection). New shell-cache probe "clientUrls group route with ppr" pins MISS -> HIT, frozen group shell with its search value in the first flushed bytes, per-request loader liveness under the frozen shell, and zero-hydration-error browser HIT — dev AND production. Full shell-cache + client-urls sweeps green both modes (64 dev / 65 prod). Docs: client-urls.md supported surface + rejection list, feature-map, client-urls skill. * feat(ppr): stream:"navigation" loaders BAKE at capture — per-loader lane, loading() optional when fully baked The flag's document promise is "this loader's data, handle pushes, and status are in the HTML before first flush"; under ppr that silently degraded — the awaitBeforeFlush await was skipped at capture and the loader masked like any live-lane loader, so its data arrived in the resume stream, never the prelude. The capture lane decision is now per LOADER: - stream:"navigation" — the BAKE lane. Executes at capture; the SETTLED return is shell material; nested thenables stay masked (shape is the liveness declaration — the #692 cross-session scar) and postpone as holes; the masked container registers on _shellCaptureLoaderRecords, holding the capture gate (bounded by ppr.captureTimeout) and pinning into the shell snapshot so HIT tails replay the baked value and hydration stays byte-agreed with the frozen prelude. The dormant post-#813 bake machinery (gate hold, snapshot drain, seed overlay, pin-first) re-engages unchanged; only the trigger moved from "entry lacks loading()" to the explicit per-loader flag. - Every other loader — LIVE unconditionally, exactly as #813 left it: masked, postponed at loading() or an inline Suspense, boundary-less reads refuse the capture. loading() is therefore OPTIONAL when every loader is flagged: nothing masks, the shell captures complete instead of the eternal-MISS refusal. Groups inherit through the existing awaitedLoaderIndices projection — no group-specific code. Red-proven: the flipped await-before-flush unit (flagged loader executes + registers its record at capture; plain sibling never runs) and both e2e pins fail without the capture branch (baked value absent from the prelude; the fully-baked route degrades to eternal MISS). New pins, dev+prod: "stream:navigation loader bakes into the shell; nested promise and plain sibling stay live" (frozen-vs-live seqs across HITs), "fully-baked route needs no loading()", and the group-ppr probe extended with a baked group loader frozen in the group shell. Sweeps green: shell-cache + shell-cache-action + prerender-ppr + semantic-matrix, 88 dev / 90 prod. Docs: loader-container-bake.md addendum (trigger moved), feature-map PPR rows, client-urls.md, stream option docs both DSL surfaces, fixture doctrine header.
1 of 5 tasks
ivogt
added a commit
that referenced
this pull request
Sep 23, 2026
… ssr:false
Only loader(Def, { ssr: false }) bakes at PPR capture since #813/#820. Update the capture diagnostics (bake cost, no usable shell, identity refusal, rejected loader, redirect/notFound refusal) and the cookies()/headers() capture error, which still advised loading() or nested promises. Share one PPR_LANE_HINT. Make resolveLoaderData the canonical statement of the lane rule and point other comments and docs at it. Fix stale JSDoc examples, resolve the #803 conflict leftover in feature-map.md, and register the /shell-cache/guard fixture loader with ssr: false so the identity guard is exercised again.
ivogt
added a commit
that referenced
this pull request
Sep 23, 2026
… ssr:false (#871) Only loader(Def, { ssr: false }) bakes at PPR capture since #813/#820. Update the capture diagnostics (bake cost, no usable shell, identity refusal, rejected loader, redirect/notFound refusal) and the cookies()/headers() capture error, which still advised loading() or nested promises. Share one PPR_LANE_HINT. Make resolveLoaderData the canonical statement of the lane rule and point other comments and docs at it. Fix stale JSDoc examples, resolve the #803 conflict leftover in feature-map.md, and register the /shell-cache/guard fixture loader with ssr: false so the identity guard is exercised again.
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
Pending UI in Rango was owned by one DSL primitive doing three unrelated jobs.
loading()was simultaneously (1) the streaming gate — a route without it BLOCKED the server tree build until every loader resolved, (2) the PPR lane trigger — its presence decided whether loaders masked at capture or baked into the shell, and (3) the visual fallback — a route-level boundary that replaced the whole page during any content re-render, including same-route tab/search navigations where perfectly valid content was already on screen (loaders allrevalidate(() => false), nothing refetching — the skeleton flashed anyway).Consumers paid for this coupling three ways: whole-route gating on the slowest loader, a mandatory
loading()just to make PPR or streaming work at all, and no way to express granular pending states where data is actually read.This PR unbundles all three.
useLoaderbecomes a suspending read; loaders stream per-loader on every streaming lane; PPR holes form at consumer Suspense boundaries with nothing baking; and<Outlet fallback>begins the migration of the visual fallback to where it belongs — the layout that renders the hole. The doctrine in one line:use(whatWeWaitFor)at consumption sites, boundaries where waiting should show.Before vs. after
End-to-end example (measured, cloudflare-basic demo)
tests/cloudflare-basicships the demo at/suspense-demo(three loaders: 400/1200/2000ms):/suspense-demo— noloading(): document byte-timing shows shell + hero + skeletons in the first flush, then each card streams in at its own loader's pace (431/1231/2031ms). On main this route shape blocks the whole body until 2000ms./suspense-demo/gated— route-levelloading()for contrast: reveals at the unwrapped 400ms read, wrapped slow card fills at 2000ms./suspense-demo/ppr—pprwith NOloading(): cached shell first bytes at ~10ms carrying hero + three skeletons as three live holes; fills at 415/1214/2015ms with freshservedAton every HIT — nothing bakes.Soft-nav verification (vite-rsc-demo, Playwright driver): pathname-changing nav committed at +72ms with the layout's
<Outlet fallback>showing as destination feedback, content streamed in at handler-time.Semantics
loading(), document/navloading()loading()is the outer boundary, inner boundaries fill granularlyforceAwaitlanes (popstate, stale revalidation, fully-prefetched)loading()= live lane (masked); without it = BAKE lane (values freeze into the shared shell)cache()/"use cache"<body>sanity gate refuses the shell (runtime render + existing eternal-MISS warning)errorBoundary()<Outlet fallback>(new)loading()at the outlet wrap; child's ownloading()still wins closer to its suspensionCode-change summary
Nine commits, core runtime:
src/outlet-context.ts,src/outlet-provider.tsx—loaderStreamschannel: per-loader UNDECODED results (values or individually-pending promises) through context.src/segment-system.tsx— no-loading()branch streams per-loader instead of awaiting (forceAwait/action lanes keep the blocking await);loading()branch passes producer-side per-loader streams toLoaderBoundary. Producer-side is load-bearing: splitting the aggregate client-side (Promise.all(...).then(r => r[i])) inherits the slowest loader's timing — measured before the fix: a 400ms loader's content held to a 2000ms sibling.src/route-content-wrapper.tsx—LoaderResolverpasses streams through instead of resolving above the children; without streams the pre-spike resolve-above is preserved (parallel/intercept slots, zero-loader boundaries — which also preserves the fresh-promise suspension that makes SSR emitloading()fallbacks).src/use-loader.tsx— the context walk finds a pending stream anduse()es it: implicit suspension at the read site;decodeLoaderEntrydecodes per entry.src/router/segment-resolution/loader-cache.ts— capture lane:createMaskedLoaderPromise()unconditionally; the bake-at-capture path (execute + snapshot pinning + HIT seed overlay) is bypassed.src/client.tsx—Outletgainsfallback(default outlet + named slots).segment-system.test.tsxincl. a newforceAwait-lane pin;loader-mask-capture.test.ts;shell-capture-barrier-isolation.test.ts— capture never executes route loaders; barrier isolation re-pinned via handler pushes)./suspense-demo{,/gated,/ppr}; vite-rsc-demo ShopLayout adopts<Outlet fallback>(the product route'sloading({ssr:true})canary is deliberately unchanged — it pins the no-degradation contract for the legacy path).Test plan
pnpm run typecheck,pnpm run test:unit:all(router 5633+71 RSC + all consumer dogfood suites),pnpm run lint,pnpm run format.sysreadtimings throughout): document lane, soft-nav lane, PPR HIT lane.x-rango-shell: HIT,servedAtadvancing per request, holes at consumer boundaries.loading({ssr:true})+ client-component skeleton) passes: skeleton streams into SSR HTML, then content.errorBoundary()e2e (2), meta-handleLateHandlePushError(mini intercept), PPR bake fixtures (/ppr-shell/no-holenegative, loader-container-bake pins, affected consumption-lane rows) + semantic-matrix rows for the intentional capture-lane change (Hard rule 5), loading-visibility timing assumptions. The SSR-fallback-emission divergence is likely already closed by the resolve-above restoration (canary passes) — to be confirmed by the sweep.Notes
cache()/"use cache". This also removes the frozen-markup-vs-fresh-payload hydration hazard measured on the baked path.use()-consumable), never in handler awaits.LateHandlePushErrorclass.ParallelOutletfallback parity + unit/docs for<Outlet fallback>land when that API graduates.