Skip to content

feat(loaders): streaming loader data with implicitly-suspending useLoader - #813

Merged
ivogt merged 12 commits into
mainfrom
feat/useloader-suspense
Jul 25, 2026
Merged

ivogt merged 12 commits into
mainfrom
feat/useloader-suspense

Conversation

@ivogt

@ivogt ivogt commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

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 all revalidate(() => 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. useLoader becomes 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

// BEFORE — loading() required for streaming; whole route gates on the slowest loader
path("/product/:slug", ProductPage, () => [
  loader(ProductLoader),        // 400ms
  loader(ReviewsLoader),        // 2000ms
  loading(<PageSkeleton />),    // page appears WHOLE at 2000ms; tab navs flash this
])
// AFTER — no loading(); pending states live where data is read
path("/product/:slug", ProductPage, () => [
  loader(ProductLoader),
  loader(ReviewsLoader),
])

function ProductPage() {
  return (
    <>
      <Header />                                   {/* paints immediately */}
      <Suspense fallback={<PriceSkeleton />}>
        <Price />                                  {/* useLoader(ProductLoader) suspends HERE, fills at 400ms */}
      </Suspense>
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews />                                {/* fills independently at 2000ms */}
      </Suspense>
    </>
  );
}

End-to-end example (measured, cloudflare-basic demo)

tests/cloudflare-basic ships the demo at /suspense-demo (three loaders: 400/1200/2000ms):

  • /suspense-demo — no loading(): 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-level loading() for contrast: reveals at the unwrapped 400ms read, wrapped slow card fills at 2000ms.
  • /suspense-demo/ppr — ppr with NO loading(): cached shell first bytes at ~10ms carrying hero + three skeletons as three live holes; fills at 415/1214/2015ms with fresh servedAt on 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

Case Before After
Segment without loading(), document/nav tree build BLOCKS on all loaders per-loader streams; readers suspend at their own boundaries
Segment with loading() aggregate gate — content appears whole behind the fallback per-loader streams; loading() is the outer boundary, inner boundaries fill granularly
forceAwait lanes (popstate, stale revalidation, fully-prefetched) resolved data, whole commits unchanged
Action responses awaited, whole commits unchanged
PPR capture, loaders loading() = live lane (masked); without it = BAKE lane (values freeze into the shared shell) live unconditionally — masked, holes at consumer boundaries; baking is expressed via cache()/"use cache"
PPR route without any boundary above a reader bake / no shell capture root-postpones, <body> sanity gate refuses the shell (runtime render + existing eternal-MISS warning)
Loader error with errorBoundary() boundary fallback swapped in at build time read-site throw to the nearest React error boundary (pinned divergence; DSL-boundary routing is follow-up)
Loader error timing tree build rejects build completes; error throws at the read site
Handler awaits, redirects, 404, meta collection pipeline-blocking, authoritative unchanged — deliberately. Streaming handlers was prototyped and reverted; measured on main, no pending UI has ever covered handler latency (2s blank, then a 41ms skeleton cameo on the document lane)
<Outlet fallback> (new) — plain Suspense at the outlet position; explicit prop wins over the child's loading() at the outlet wrap; child's own loading() still wins closer to its suspension

Code-change summary

Nine commits, core runtime:

  • src/outlet-context.ts, src/outlet-provider.tsx — loaderStreams channel: 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 to LoaderBoundary. 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 — LoaderResolver passes 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 emit loading() fallbacks).
  • src/use-loader.tsx — the context walk finds a pending stream and use()es it: implicit suspension at the read site; decodeLoaderEntry decodes 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 — Outlet gains fallback (default outlet + named slots).
  • Unit pins updated to the new contracts (segment-system.test.tsx incl. a new forceAwait-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).
  • Demos: cloudflare-basic /suspense-demo{,/gated,/ppr}; vite-rsc-demo ShopLayout adopts <Outlet fallback> (the product route's loading({ssr:true}) canary is deliberately unchanged — it pins the no-degradation contract for the legacy path).

Test plan

  • Full pre-push gate green on every pushed commit: pnpm run typecheck, pnpm run test:unit:all (router 5633+71 RSC + all consumer dogfood suites), pnpm run lint, pnpm run format.
  • Streaming verified by byte-level measurement (line-buffered reads lie about newline-free HTML — earlier "flush stall" findings were retracted as measurement artifacts; sysread timings throughout): document lane, soft-nav lane, PPR HIT lane.
  • PPR liveness: x-rango-shell: HIT, servedAt advancing per request, holes at consumer boundaries.
  • vite-rsc-demo canary (loading({ssr:true}) + client-component skeleton) passes: skeleton streams into SSR HTML, then content.
  • Full e2e sweep NOT yet re-run since the fixes. The first spike run (1619 passed dev+prod) predates the per-loader-streams fix and the capture-lane change. Known-divergent set to resolve/update before merge: loader-error errorBoundary() e2e (2), meta-handle LateHandlePushError (mini intercept), PPR bake fixtures (/ppr-shell/no-hole negative, 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

  • Contract change to state loudly: PPR route loaders never bake. Existing apps relying on bake-lane loader freezing must move that data into cache()/"use cache". This also removes the frozen-markup-vs-fresh-payload hydration hazard measured on the baked path.
  • Handler latency is TTFB by design — the recommendation that falls out of this PR: presentation waits belong in loaders (streamable, use()-consumable), never in handler awaits.
  • Deferred (design pass required): read-site handle consumption — the SSR root currently blocks the first byte on handle collection, which gates document-lane streaming for routes with slow STREAMED handlers (pre-existing on main, exposed here); fixing it read-site should also resolve the LateHandlePushError class. ParallelOutlet fallback parity + unit/docs for <Outlet fallback> land when that API graduates.
  • Rejected after prototyping (in-branch experiments, reverted): streaming handlers unconditionally (breaks redirect/404/meta authority), client-side aggregate splitting (erases per-loader timing), SSR-root handle bypass (empties SSR handle data).

ivogt added 9 commits July 25, 2026 12:24
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.
<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.
@pkg-pr-new

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

Copy link
Copy Markdown

Open in StackBlitz

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

commit: fbded5d

ivogt added 3 commits July 25, 2026 16:08
…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
ivogt merged commit 9418275 into main Jul 25, 2026
35 of 36 checks passed
@ivogt
ivogt deleted the feat/useloader-suspense branch July 25, 2026 13:41
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.
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.
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.
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