Skip to content

feat(router)!: loader ssr:false replaces stream:"navigation" - #820

Merged
ivogt merged 1 commit into
mainfrom
feat/loader-ssr-false
Jul 29, 2026
Merged

ivogt merged 1 commit into
mainfrom
feat/loader-ssr-false

Conversation

@ivogt

@ivogt ivogt commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Problem

stream: "navigation" grew the API vocabulary for a concept the DSL already expresses. loading(fallback, { ssr: false }) has meant "this fallback has no SSR presence — document renders deliver real data" since before the streaming work; the loader-level opt-in shipped as a new stream enum instead of the same knob. Consumers learning one option had to discover the other under a different name, and the stream enum implied more future values than the feature has.

Before vs. after

// before
path("/product/:slug", ProductPage, () => [
  loader(ProductLoader, { stream: "navigation" }),
  loader(RelatedLoader), // streams behind loading()
  loading(<Skeleton />),
]);

// after
path("/product/:slug", ProductPage, () => [
  loader(ProductLoader, { ssr: false }), // same knob as loading()'s
  loader(RelatedLoader), // streams behind loading()
  loading(<Skeleton />),
]);

Behavior is identical: the flagged loader is awaited before first flush on document requests (its data, handle pushes, and a thrown notFound()'s 404 status are deterministically in the SSR'd HTML; no fallback paints for it), client navigations keep streaming it behind the fallback, and under ppr it stays the bake lane (executes at capture, settled return is shell material, nested promises stay live holes).

Passing the removed option is a targeted error, not a silent ignore:

loader() stream was replaced: use loader(Def, { ssr: false }) — the same knob as
loading(fallback, { ssr: false }) — to await the loader before first flush on
document requests.

End-to-end consumer example

"use client";
export default clientUrls(({ path, loader, loading }) => [
  path("/product/:slug", ProductDetail, { ppr: true }, () => [
    loader(ProductLoader, { ssr: false }), // in the document / baked into the shell
    loader(ReviewsLoader),                 // live, streams behind the skeleton
    loading(<DetailSkeleton />),
  ]),
]);

One vocabulary throughout: ssr: false on a loader = "no SSR streaming for this loader" (awaited, document-complete); ssr: false on loading() = "no SSR presence for this fallback" (all of the route's loaders awaited). Both read as the same fact from opposite ends.

Semantics (all unchanged — this is a rename)

Case ssr: false loader Unflagged sibling
Document request Awaited before first flush; data/handles/status in the SSR'd HTML; no fallback Streams behind loading()/Suspense
Client navigation Streams (fallback shows, then data) Streams
ppr capture BAKES: executes at capture, settled return frozen into the shell, nested promises stay holes Masked (live hole)
ctx.rendered() in the loader Deadlock error (message now names ssr: false) Allowed
clientUrls intercept() loader Rejected (ssr: false can never apply — navigations only) n/a
ssr: true / omitted — Explicit default: streams on every render

Code changes

  • src/types/loader-types.ts — LoaderOptions is { ssr?: boolean }; docs rewritten (also fixes a stale claim that the flag "does NOT change PPR capture behavior" — it has been the bake lane since feat(client-urls): ppr as a projected group path option — shell caching for group routes #817).
  • src/route-definition/dsl-helpers.ts — loader() validates ssr (boolean), rejects the removed stream with the pointer message, stamps awaitBeforeFlush from ssr === false && ctx.isSSR (same per-isSSR mechanism as loading({ ssr: false }), which the adjacent comment already cross-referenced).
  • src/client-urls/client-urls.ts + types.ts — group DSL loader() renames the same way (LoaderItem.ssr, ClientUrlLoaderRecord.ssr, intercept rejection message); src/client-urls/server-projection.ts derives awaitedLoaderIndices from ssr === false and materializes { ssr: false } onto the server loader. The projection wire format (awaitedLoaderIndices) is unchanged — previously serialized projections keep working.
  • src/router/loader-resolution.ts — the rendered() deadlock error names ssr: false.
  • Internal names stay: awaitBeforeFlush, _awaitBeforeFlushLoaderIds, comment-level references updated to the new spelling.
  • Fixtures (test-app shell-cache + clientUrls group, vite-rsc-demo client-shop), docs (client-urls.md, feature-map, design docs), and 20+ skills swept; two e2e titles renamed (client-shop loader ssr:false, ssr:false loader bakes…).

Test plan

  • Unit (red-proven): 7 renamed/new pins fail on the pre-rename runtime — ssr: false stamps awaitBeforeFlush on SSR evaluation only, ssr: true is an inert explicit default, boolean validation, removed-stream rejection on both DSL surfaces, clientUrls record carries ssr: false, projection round-trip materializes the await on SSR entries only.
  • E2e dev + production: vite-rsc-demo client-shop-stream 6/6 (document-complete data/title, real 404 with no warm-up, navigation still streams the flagged loader); test-app shell-cache bake-lane pins + clientUrls ppr group route 3/3 production and green in dev — the renamed option threads DSL → projection → materialization → runtime → ppr bake.
  • Pre-push gate: typecheck (16/16), test:unit:all, lint, format, bucketing.

Notes

  • Breaking (next release, 0.9.x): stream: "navigation" is removed outright per pre-release API hygiene — no alias, but the rejection message names the replacement on both surfaces.
  • loading()'s ssr: false is untouched; this PR only brings the loader option into the same vocabulary.
  • Grep migration for consumers: stream: "navigation" → ssr: false.

The SSR-completeness opt-in now uses the knob the API already has:
loader(Def, { ssr: false }) — the same option loading(fallback, { ssr:
false }) uses for the fallback. Semantics are unchanged: document renders
await the flagged loader before first flush (data, handle pushes, and a
thrown notFound()'s status are deterministically in the SSR'd document),
client navigations keep streaming it, per-loader scoping stays, and under
ppr it remains the bake lane. The stream option is REMOVED (breaking, per
pre-release API hygiene); passing it now throws a targeted error pointing
at ssr: false on both DSL surfaces (server urls() and clientUrls()).

Renamed through: LoaderOptions, both loader() helpers, the clientUrls
LoaderItem/ClientUrlLoaderRecord shapes and projection derivation
(awaitedLoaderIndices wire format unchanged), the materialization seam,
the rendered() deadlock message, and every fixture/doc/skill mention.
Internal names (awaitBeforeFlush, _awaitBeforeFlushLoaderIds) stay — they
describe the semantic, not the API.

Red-proven: 7 renamed/new unit pins fail on the pre-rename runtime
(stamping via ssr:false, boolean validation, removed-option rejection on
both surfaces, projection round-trip); e2e green both modes on
vite-rsc-demo client-shop-stream (6/6) and test-app shell-cache bake lane
+ clientUrls ppr group (3/3 production, dev counterparts green).
@ivogt
ivogt marked this pull request as ready for review July 29, 2026 09:57
@ivogt
ivogt merged commit 7f693bf into main Jul 29, 2026
17 of 20 checks passed
@ivogt
ivogt deleted the feat/loader-ssr-false branch July 29, 2026 09:57
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