@rangojs/router 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/types/client-urls/server-projection.d.ts +4 -4
  2. package/dist/types/client-urls/types.d.ts +13 -12
  3. package/dist/types/route-definition/helpers-types.d.ts +6 -5
  4. package/dist/types/router/segment-resolution/loader-mask.d.ts +1 -1
  5. package/dist/types/rsc/shell-capture.d.ts +9 -0
  6. package/dist/types/rsc/types.d.ts +8 -0
  7. package/dist/types/server/context.d.ts +1 -1
  8. package/dist/types/server/request-context.d.ts +1 -1
  9. package/dist/types/ssr/index.d.ts +16 -0
  10. package/dist/types/ssr/ssr-root.d.ts +5 -0
  11. package/dist/types/types/loader-types.d.ts +24 -22
  12. package/dist/types/urls/path-helper-types.d.ts +8 -7
  13. package/dist/vite/index.js +8 -5
  14. package/package.json +22 -21
  15. package/skills/breadcrumbs/SKILL.md +2 -2
  16. package/skills/catalog.json +2 -2
  17. package/skills/client-urls/SKILL.md +9 -8
  18. package/skills/comparison/references/framework-comparison.md +2 -2
  19. package/skills/hooks/data.md +1 -1
  20. package/skills/hooks/handle-and-actions.md +1 -1
  21. package/skills/loader/SKILL.md +29 -28
  22. package/skills/migrate-nextjs/SKILL.md +3 -3
  23. package/skills/migrate-react-router/component-migration.md +1 -1
  24. package/skills/migrate-react-router/data-and-actions.md +1 -1
  25. package/skills/migrate-react-router/route-mapping.md +1 -1
  26. package/skills/parallel/SKILL.md +1 -1
  27. package/skills/ppr/SKILL.md +1 -1
  28. package/skills/rango/SKILL.md +20 -20
  29. package/skills/router-setup/SKILL.md +1 -1
  30. package/skills/scripts/SKILL.md +1 -1
  31. package/skills/shell-manifest/SKILL.md +1 -1
  32. package/src/browser/react/Link.tsx +27 -4
  33. package/src/client-urls/client-urls.ts +12 -9
  34. package/src/client-urls/server-projection.ts +6 -6
  35. package/src/client-urls/types.ts +13 -12
  36. package/src/route-definition/dsl-helpers.ts +7 -3
  37. package/src/route-definition/helpers-types.ts +6 -5
  38. package/src/router/loader-resolution.ts +3 -3
  39. package/src/router/segment-resolution/fresh.ts +2 -2
  40. package/src/router/segment-resolution/loader-cache.ts +1 -1
  41. package/src/router/segment-resolution/loader-mask.ts +1 -1
  42. package/src/rsc/rsc-rendering.ts +10 -0
  43. package/src/rsc/shell-capture.ts +10 -0
  44. package/src/rsc/ssr-setup.ts +4 -0
  45. package/src/rsc/types.ts +18 -2
  46. package/src/server/context.ts +1 -1
  47. package/src/server/request-context.ts +1 -1
  48. package/src/ssr/index.tsx +22 -2
  49. package/src/ssr/ssr-root.tsx +17 -1
  50. package/src/types/loader-types.ts +21 -19
  51. package/src/urls/path-helper-types.ts +8 -7
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: loader
3
- description: Define data loaders for fetching data in routes with createLoader. Use when pages need per-request data that stays fresh, data should stream while the page renders, client components need reactive server data, a loader should throw notFound()/redirect(), set page meta/breadcrumbs from loader data (handle writes), or loader data must be guaranteed in the SSR'd document (stream:"navigation").
3
+ description: Define data loaders for fetching data in routes with createLoader. Use when pages need per-request data that stays fresh, data should stream while the page renders, client components need reactive server data, a loader should throw notFound()/redirect(), set page meta/breadcrumbs from loader data (handle writes), or loader data must be guaranteed in the SSR'd document (ssr:false).
4
4
  argument-hint: "[loader]"
5
5
  ---
6
6
 
@@ -172,23 +172,23 @@ Loaders receive the same context shape as route handlers.
172
172
 
173
173
  ### Full field surface
174
174
 
175
- | Field | Type | Notes |
176
- | -------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
177
- | `params` | `TParams` | Merged route + explicit loader params; overridable by fetchable `load({ params })`. |
178
- | `routeParams` | `Record<string, string>` | Server-trusted route params from URL pattern matching; cannot be overridden. |
179
- | `request` | `Request` | The incoming `Request` (headers, method, body, `signal` for abort). |
180
- | `url` | `URL` | Parsed request URL. |
181
- | `pathname` | `string` | URL pathname (shortcut for `ctx.url.pathname`). |
182
- | `searchParams` | `URLSearchParams` | Shortcut for `ctx.url.searchParams`. |
183
- | `search` | `ResolveSearchSchema<TSearch>` | Typed query params when a search schema is declared on the route; `{}` otherwise. |
184
- | `env` | `TEnv` | Plain bindings from `createRouter<TEnv>()` (DB, KV, secrets, etc.). |
185
- | `get` | `(key \| ContextVar \| handle)` | Reads middleware variables/context-vars — or READS a handle's collected data, after `await ctx.rendered()`. |
186
- | `use` | `(loader \| handle) => T` | Access another loader's data (Promise), or WRITE a handle: `ctx.use(Meta)({ title })` returns the push function — handler parity. Reads moved to `get`. |
187
- | `rendered` | `() => Promise<void>` | **Experimental.** DSL loaders only — waits for all non-loader segments (including `loading()` streaming handlers) to settle before reading handle data. Not with `stream: "navigation"` (cycle; throws). |
188
- | `method` | `string` | HTTP method. `"GET"` for SSR loader runs; reflects real method for fetchable loaders. |
189
- | `body` | `TBody \| undefined` | Parsed request body for fetchable POST/PUT/PATCH/DELETE calls. |
190
- | `formData` | `FormData \| undefined` | Present when a fetchable loader is invoked via form submission. |
191
- | `reverse` | `ScopedReverseFunction` | Generate type-checked URLs from route names (same scoped semantics as route handlers). |
175
+ | Field | Type | Notes |
176
+ | -------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
177
+ | `params` | `TParams` | Merged route + explicit loader params; overridable by fetchable `load({ params })`. |
178
+ | `routeParams` | `Record<string, string>` | Server-trusted route params from URL pattern matching; cannot be overridden. |
179
+ | `request` | `Request` | The incoming `Request` (headers, method, body, `signal` for abort). |
180
+ | `url` | `URL` | Parsed request URL. |
181
+ | `pathname` | `string` | URL pathname (shortcut for `ctx.url.pathname`). |
182
+ | `searchParams` | `URLSearchParams` | Shortcut for `ctx.url.searchParams`. |
183
+ | `search` | `ResolveSearchSchema<TSearch>` | Typed query params when a search schema is declared on the route; `{}` otherwise. |
184
+ | `env` | `TEnv` | Plain bindings from `createRouter<TEnv>()` (DB, KV, secrets, etc.). |
185
+ | `get` | `(key \| ContextVar \| handle)` | Reads middleware variables/context-vars — or READS a handle's collected data, after `await ctx.rendered()`. |
186
+ | `use` | `(loader \| handle) => T` | Access another loader's data (Promise), or WRITE a handle: `ctx.use(Meta)({ title })` returns the push function — handler parity. Reads moved to `get`. |
187
+ | `rendered` | `() => Promise<void>` | **Experimental.** DSL loaders only — waits for all non-loader segments (including `loading()` streaming handlers) to settle before reading handle data. Not with `ssr: false` (cycle; throws). |
188
+ | `method` | `string` | HTTP method. `"GET"` for SSR loader runs; reflects real method for fetchable loaders. |
189
+ | `body` | `TBody \| undefined` | Parsed request body for fetchable POST/PUT/PATCH/DELETE calls. |
190
+ | `formData` | `FormData \| undefined` | Present when a fetchable loader is invoked via form submission. |
191
+ | `reverse` | `ScopedReverseFunction` | Generate type-checked URLs from route names (same scoped semantics as route handlers). |
192
192
 
193
193
  ### Example
194
194
 
@@ -443,8 +443,8 @@ boundary a parallel loader blocks its parent, so add one to keep the overlap.)
443
443
  If you come from a framework where the loader is a blocking step that runs
444
444
  before the response is built, this is the shift to internalize: here the
445
445
  response starts streaming first and loader data fills in. (The one deliberate
446
- exception is per-loader: `loader(Def, { stream: "navigation" })` awaits that
447
- loader before first flush on document renders — see "`stream: "navigation"`"
446
+ exception is per-loader: `loader(Def, { ssr: false })` awaits that
447
+ loader before first flush on document renders — see "`ssr: false`"
448
448
  below.)
449
449
 
450
450
  ### See it: `debugPerformance`
@@ -687,10 +687,10 @@ export const ProductLoader = createLoader(async (ctx) => {
687
687
 
688
688
  Semantics by lane:
689
689
 
690
- | Signal | Document load | Client navigation |
691
- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
692
- | `notFound()` | Not-found UI resolves server-side (nearest `notFoundBoundary` → router option → default) and rides the envelope; the 404 STATUS is **opportunistic** — real only if the rejection beats Response construction. `stream: "navigation"` (below) makes it deterministic. | 404 UI swaps in, URL preserved, payload stays 200 |
693
- | `redirect()` | 200 document, then a client-side replace to the target — **no document-lane 302 from loaders**; pre-stream redirect authority belongs to middleware | Redirect envelope navigates to the target |
690
+ | Signal | Document load | Client navigation |
691
+ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
692
+ | `notFound()` | Not-found UI resolves server-side (nearest `notFoundBoundary` → router option → default) and rides the envelope; the 404 STATUS is **opportunistic** — real only if the rejection beats Response construction. `ssr: false` (below) makes it deterministic. | 404 UI swaps in, URL preserved, payload stays 200 |
693
+ | `redirect()` | 200 document, then a client-side replace to the target — **no document-lane 302 from loaders**; pre-stream redirect authority belongs to middleware | Redirect envelope navigates to the target |
694
694
 
695
695
  Session/auth gates belong in middleware (they are request-shaped, not
696
696
  data-shaped, and middleware CAN emit a real pre-stream 302). Data-dependent
@@ -725,14 +725,14 @@ barrier ride the SSR handle snapshot (in the SSR'd document — `<MetaTags />`,
725
725
  on document loads (`metadata.handlesLate`) or progressively on navigations.
726
726
  A push before your slow fetch usually beats the barrier; a push derived from
727
727
  the fetched data usually does not. When it MUST be in the document, use
728
- `stream: "navigation"` below.
728
+ `ssr: false` below.
729
729
 
730
730
  Reads are the other direction and gated: `ctx.get(handle)` throws unless the
731
731
  loader first does `await ctx.rendered()` (DSL-registered loaders only —
732
732
  handler-invoked loaders cannot use `rendered()`, and a handler already
733
733
  awaiting the loader via `ctx.use()` makes it a detected deadlock).
734
734
 
735
- ## `stream: "navigation"` — Guarantee a Loader in the Document
735
+ ## `ssr: false` — Guarantee a Loader in the Document
736
736
 
737
737
  Streaming means nothing a slow loader produces is _guaranteed_ in the SSR'd
738
738
  HTML: its section SSRs as the fallback, a late handle push applies
@@ -743,12 +743,13 @@ use callback:
743
743
 
744
744
  ```typescript
745
745
  path("/product/:slug", ProductPage, { name: "product" }, () => [
746
- loader(ProductLoader, { stream: "navigation" }, () => [cache({ ttl: 60 })]),
746
+ loader(ProductLoader, { ssr: false }, () => [cache({ ttl: 60 })]),
747
747
  loader(RelatedLoader), // untouched: still streams behind its boundary
748
748
  ]),
749
749
  ```
750
750
 
751
- The name says WHERE streaming still applies, not that it is disabled:
751
+ The knob mirrors `loading(fallback, { ssr: false })` SSR delivery is off
752
+ for this loader, so nothing of it is left to stream in the document:
752
753
  document renders await this loader before first flush — data is settled
753
754
  (`useLoader` reads it synchronously, no fallback paints), handle pushes beat
754
755
  the barrier snapshot, and a thrown `notFound()` deterministically precedes
@@ -726,7 +726,7 @@ const HomePage: Handler<"home"> = (ctx) => {
726
726
 
727
727
  `generateMetadata({ params })` — DATA-derived, document-blocking metadata —
728
728
  maps to a Meta push from the LOADER that owns the data, plus
729
- `{ stream: "navigation" }` for the blocking-until-in-head part:
729
+ `{ ssr: false }` for the blocking-until-in-head part:
730
730
 
731
731
  ```typescript
732
732
  // Next.js: export async function generateMetadata({ params }) {
@@ -744,7 +744,7 @@ export const ProductLoader = createLoader(async (ctx) => {
744
744
  });
745
745
 
746
746
  path("/product/:slug", ProductPage, { name: "product" }, () => [
747
- loader(ProductLoader, { stream: "navigation" }),
747
+ loader(ProductLoader, { ssr: false }),
748
748
  ]);
749
749
  ```
750
750
 
@@ -843,7 +843,7 @@ See `/theme` for full API including system detection and cookie persistence.
843
843
  10. [ ] Convert loading/error files to `loading()` / `errorBoundary()`
844
844
  11. [ ] Migrate API routes to `path.json()` / `path.text()`
845
845
  12. [ ] Update metadata to use `Meta` handle + `<MetaTags />` in document head
846
- (`generateMetadata` → loader push + `{ stream: "navigation" }`)
846
+ (`generateMetadata` → loader push + `{ ssr: false }`)
847
847
  13. [ ] Replace `next-themes` with `theme: true` in createRouter (see `/theme`)
848
848
  14. [ ] Map rendering-mode segment config: `revalidate = N` → `cache({ ttl })`,
849
849
  `force-static` → `Static()`/`Prerender()`, `experimental_ppr` → the
@@ -134,7 +134,7 @@ const HomePage: Handler<"home"> = (ctx) => {
134
134
 
135
135
  RR's data-derived `meta({ data })` maps to the same push from the LOADER that
136
136
  owns the data — `ctx.use(Meta)({ title: data.name })` in the loader body, with
137
- `loader(Def, { stream: "navigation" })` when the title must be in the SSR'd
137
+ `loader(Def, { ssr: false })` when the title must be in the SSR'd
138
138
  head. See `/loader` → "Writing Handles from Loaders".
139
139
 
140
140
  Add `<MetaTags />` in the Document component's `<head>`:
@@ -46,7 +46,7 @@ Use it only when you need capabilities beyond what the handler provides:
46
46
  document loads, never an HTTP 302 — pre-stream 302s move to middleware)
47
47
  - **`meta({ data })` / `handle` exports** — data-derived page metadata becomes
48
48
  a handle push from the loader body (`ctx.use(Meta)({ title: data.name })`),
49
- with `loader(L, { stream: "navigation" })` when it must be in the SSR'd head
49
+ with `loader(L, { ssr: false })` when it must be in the SSR'd head
50
50
 
51
51
  If the React Router loader just fetches data for its page component AND the
52
52
  component can become a server component, merge it into the handler. If the
@@ -135,7 +135,7 @@ The loader-shaped variant is equally valid — and closer to the RR module when
135
135
  the loader carried authority. A `createLoader()` body can throw `notFound()`
136
136
  for the missing product AND push the data-derived meta itself
137
137
  (`ctx.use(Meta)({ title: product.name })`); register it with
138
- `loader(ProductLoader, { stream: "navigation" })` when the 404 status and
138
+ `loader(ProductLoader, { ssr: false })` when the 404 status and
139
139
  title must be in the document deterministically. See `/loader` → "Loader
140
140
  Authority" and "Writing Handles from Loaders". (One RR habit that does NOT
141
141
  carry over: a loader `throw redirect()` is a client-side navigate on document
@@ -136,7 +136,7 @@ naturally.
136
136
  > the baked lane under `cache()`/PPR — just to reach a `ctx.use(Meta)` call.
137
137
  > The loader can now push `Meta` itself (`/loader` → "Writing Handles from
138
138
  > Loaders"), keeping the data on the live lane; add
139
- > `loader(Def, { stream: "navigation" })` when the meta must be in the SSR'd
139
+ > `loader(Def, { ssr: false })` when the meta must be in the SSR'd
140
140
  > head. Keep the `@meta` slot for metadata that is NOT loader-derived
141
141
  > (templates, static descriptions, structured data with independent inputs).
142
142
 
@@ -669,7 +669,7 @@ LOADER-pushed Meta is different: loaders are masked at capture, so the push
669
669
  happens at request time and applies client-side (`metadata.handlesLate`) — it
670
670
  is never in the cached shell's head, by construction.
671
671
 
672
- One flag to know about here: `loader(Def, { stream: "navigation" })` (the
672
+ One flag to know about here: `loader(Def, { ssr: false })` (the
673
673
  document-render await, `/loader`) is **inert under PPR** — capture renders
674
674
  mask loaders and skip the await, and a shell HIT flushes the stored prelude
675
675
  before loaders resolve. Flagging a loader on a `ppr` route does not bake it
@@ -95,26 +95,26 @@ stated, greppable contract.
95
95
 
96
96
  ## Pick a primitive
97
97
 
98
- | I need to… | Use | Skill |
99
- | --------------------------------------- | ------------------------------------- | ----------------------- |
100
- | render data fresh every request | `loader()` + `useLoader()` | /loader |
101
- | cache a rendered subtree | `cache()` on a segment | /caching |
102
- | cache one function/component's result | `"use cache"` | /use-cache |
103
- | cache a loader's data | `loader(L, () => [cache()])` | /loader, /caching |
104
- | re-render a segment after an action | `revalidate()` | /loader |
105
- | mutate | `"use server"` action | /server-actions |
106
- | debug a slow request | `debugPerformance` / telemetry | /observability |
107
- | share config across routes | factory returning a helper array | /composability |
108
- | compose a sub-app / module | `include()` | /route |
109
- | modal / soft navigation | `intercept()` | /intercept |
110
- | route group of client components | `clientUrls()` in `"use client"` | /client-urls |
111
- | set meta/breadcrumbs from loader data | `ctx.use(Handle)` in the loader | /loader |
112
- | guarantee loader output in the SSR HTML | `loader(L, { stream: "navigation" })` | /loader |
113
- | pre-render a route at build time | `Prerender(...)` wrapper | /prerender |
114
- | feed live loaders from a cached shell | replayed handle + `ctx.rendered()` | /shell-manifest |
115
- | cache the HTML shell, keep loaders live | `ppr` path option | /ppr |
116
- | choose in-function vs CDN caching | deployment cache boundary | /deployment-caching |
117
- | stream SSE / upgrade a WebSocket | `path.stream()` / `path.any()` | /streams-and-websockets |
98
+ | I need to… | Use | Skill |
99
+ | --------------------------------------- | ---------------------------------- | ----------------------- |
100
+ | render data fresh every request | `loader()` + `useLoader()` | /loader |
101
+ | cache a rendered subtree | `cache()` on a segment | /caching |
102
+ | cache one function/component's result | `"use cache"` | /use-cache |
103
+ | cache a loader's data | `loader(L, () => [cache()])` | /loader, /caching |
104
+ | re-render a segment after an action | `revalidate()` | /loader |
105
+ | mutate | `"use server"` action | /server-actions |
106
+ | debug a slow request | `debugPerformance` / telemetry | /observability |
107
+ | share config across routes | factory returning a helper array | /composability |
108
+ | compose a sub-app / module | `include()` | /route |
109
+ | modal / soft navigation | `intercept()` | /intercept |
110
+ | route group of client components | `clientUrls()` in `"use client"` | /client-urls |
111
+ | set meta/breadcrumbs from loader data | `ctx.use(Handle)` in the loader | /loader |
112
+ | guarantee loader output in the SSR HTML | `loader(L, { ssr: false })` | /loader |
113
+ | pre-render a route at build time | `Prerender(...)` wrapper | /prerender |
114
+ | feed live loaders from a cached shell | replayed handle + `ctx.rendered()` | /shell-manifest |
115
+ | cache the HTML shell, keep loaders live | `ppr` path option | /ppr |
116
+ | choose in-function vs CDN caching | deployment cache boundary | /deployment-caching |
117
+ | stream SSE / upgrade a WebSocket | `path.stream()` / `path.any()` | /streams-and-websockets |
118
118
 
119
119
  ## Invariants
120
120
 
@@ -375,7 +375,7 @@ Handler and no-match cases set HTTP 404 status. A LOADER-thrown `notFound()`
375
375
  on a document load always streams the resolved not-found UI, but the 404
376
376
  STATUS is opportunistic — real only when the rejection settles before the
377
377
  document Response is constructed (loaders stream). Register the loader as
378
- `loader(Def, { stream: "navigation" })` to make the 404 status deterministic;
378
+ `loader(Def, { ssr: false })` to make the 404 status deterministic;
379
379
  on client navigations the 404 UI swaps in with the URL preserved (payload
380
380
  stays 200 — the client owns presentation there). See `/loader` → "Loader
381
381
  Authority".
@@ -106,7 +106,7 @@ during navigation.
106
106
  > settles before the handler barrier. A push that lands after a slow fetch
107
107
  > arrives post-hydration — and for an inline/ordered script the frozen set
108
108
  > means it is silently dropped. If a loader must contribute an inline script
109
- > to the document, register it `loader(Def, { stream: "navigation" })` so the
109
+ > to the document, register it `loader(Def, { ssr: false })` so the
110
110
  > document render awaits the push; otherwise push from a handler (or use an
111
111
  > `async` config, which stays reactive).
112
112
 
@@ -151,7 +151,7 @@ cache({ ttl: 600, tags: ["products"] }, () => [
151
151
  fetchable/standalone loader calls that run outside a route render, in
152
152
  handler-invoked loaders (a handler already awaiting the loader via
153
153
  `ctx.use()` is a detected deadlock), and in loaders registered with
154
- `{ stream: "navigation" }` (the document render awaits the loader before
154
+ `{ ssr: false }` (the document render awaits the loader before
155
155
  the barrier — a cycle by construction; see `/loader`).
156
156
  - **The reading loader serializes after the shell.** `await ctx.rendered()`
157
157
  deliberately gives up loader/render parallelism — on a miss the loader
@@ -167,17 +167,28 @@ export interface LinkProps extends Omit<
167
167
  }
168
168
 
169
169
  /**
170
- * Check if URL is external (different origin)
170
+ * Check if URL is external (different origin). `origin` is resolved by the
171
+ * component from the navigation store location so SSR and browser agree —
172
+ * reading window here made every absolute-URL Link a hydration mismatch:
173
+ * on the server the ReferenceError was swallowed by the malformed-URL catch
174
+ * and the SSR HTML never carried data-external, then the browser evaluated
175
+ * the same link as external.
171
176
  */
172
- function isExternalUrl(href: string): boolean {
177
+ function isExternalUrl(href: string, origin: string | undefined): boolean {
173
178
  // Protocol-relative URLs
174
179
  if (href.startsWith("//")) return true;
175
180
 
176
181
  // Absolute URLs
177
182
  if (href.startsWith("http://") || href.startsWith("https://")) {
183
+ // No known origin (no provider and no window): treat as internal — the
184
+ // pre-fix behavior for that configuration, and stable across hydration
185
+ // because both sides resolve the same store origin when a provider
186
+ // exists (the real-app case).
187
+ if (!origin) return false;
178
188
  try {
179
- return new URL(href).origin !== window.location.origin;
189
+ return new URL(href).origin !== origin;
180
190
  } catch {
191
+ // Genuinely malformed absolute URL.
181
192
  return false;
182
193
  }
183
194
  }
@@ -225,7 +236,19 @@ export const Link: ForwardRefExoticComponent<
225
236
  ref,
226
237
  ) {
227
238
  const ctx = useContext(NavigationStoreContext);
228
- const isExternal = isExternalUrl(to);
239
+ // Origin from the store location — the same both-sides source
240
+ // useSearchParams seeds from (SSR: the live request's URL; browser:
241
+ // window.location), so data-external agrees across hydration. Origin is
242
+ // immutable per document, so the inline getState() read cannot tear.
243
+ // window is the provider-less browser fallback (tests, portals outside
244
+ // the app root); provider-less SSR has no origin and keeps links internal.
245
+ const storeLocation = ctx?.eventController.getState().location as
246
+ | URL
247
+ | undefined;
248
+ const origin =
249
+ storeLocation?.origin ??
250
+ (typeof window !== "undefined" ? window.location.origin : undefined);
251
+ const isExternal = isExternalUrl(to, origin);
229
252
 
230
253
  // Auto-prefix with basename for app-local paths.
231
254
  // Skip if external, already prefixed, or not a root-relative path.
@@ -137,7 +137,7 @@ interface LoaderItem extends ItemBase {
137
137
  readonly type: "loader";
138
138
  readonly definition: LoaderDefinition<any, any>;
139
139
  readonly revalidate: readonly ClientRevalidateFn[];
140
- readonly stream?: "navigation";
140
+ readonly ssr?: false;
141
141
  }
142
142
 
143
143
  interface RevalidateItem extends ItemBase {
@@ -380,9 +380,14 @@ function createHelpers(): ClientUrlHelpers {
380
380
  const options =
381
381
  typeof optionsOrUse === "function" ? undefined : optionsOrUse;
382
382
  const use = typeof optionsOrUse === "function" ? optionsOrUse : maybeUse;
383
- if (options?.stream !== undefined && options.stream !== "navigation") {
383
+ if ((options as { stream?: unknown } | undefined)?.stream !== undefined) {
384
384
  throw new Error(
385
- `clientUrls() loader() stream must be "navigation" (got ${JSON.stringify(options.stream)}). Omit it to stream on every render.`,
385
+ "clientUrls() 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.",
386
+ );
387
+ }
388
+ if (options?.ssr !== undefined && typeof options.ssr !== "boolean") {
389
+ throw new Error(
390
+ `clientUrls() loader() ssr must be a boolean (got ${JSON.stringify(options.ssr)}). Omit it (or pass true) to stream on every render.`,
386
391
  );
387
392
  }
388
393
  const items = use ? runUse(use, "loader use") : [];
@@ -397,9 +402,7 @@ function createHelpers(): ClientUrlHelpers {
397
402
  item.type === "revalidate",
398
403
  )
399
404
  .map((item) => item.fn),
400
- ...(options?.stream === "navigation"
401
- ? { stream: "navigation" as const }
402
- : {}),
405
+ ...(options?.ssr === false ? { ssr: false as const } : {}),
403
406
  });
404
407
  };
405
408
 
@@ -505,9 +508,9 @@ function createHelpers(): ClientUrlHelpers {
505
508
  for (const item of items) {
506
509
  // Intercept loaders run on soft navigations only — a document-render
507
510
  // await can never apply, so accepting the flag would be silently inert.
508
- if (item.type === "loader" && item.stream !== undefined) {
511
+ if (item.type === "loader" && item.ssr !== undefined) {
509
512
  throw new Error(
510
- 'clientUrls() intercept() loaders cannot use stream: "navigation" — intercepts render on client navigations only',
513
+ "clientUrls() intercept() loaders cannot use ssr: false — intercepts render on client navigations only",
511
514
  );
512
515
  }
513
516
  }
@@ -557,7 +560,7 @@ function applyConfig(
557
560
  Object.freeze({
558
561
  loader: item.definition,
559
562
  revalidate: Object.freeze([...item.revalidate]),
560
- ...(item.stream ? { stream: item.stream } : {}),
563
+ ...(item.ssr === false ? { ssr: false as const } : {}),
561
564
  }),
562
565
  );
563
566
  } else if (item.type === "loading") {
@@ -89,10 +89,10 @@ export interface ClientUrlProjectionRoute {
89
89
  readonly options: ClientUrlProjectionOptions;
90
90
  readonly loaderIds: readonly string[];
91
91
  readonly hasLoading: boolean;
92
- /** Indices into loaderIds of loaders declared loader(Def, { stream:
93
- * "navigation" }); materialization passes the option through to the server
94
- * loader() so document renders await them before first flush. Absent (=
95
- * none) in projections serialized before stream support. */
92
+ /** Indices into loaderIds of loaders declared loader(Def, { ssr: false });
93
+ * materialization passes the option through to the server loader() so
94
+ * document renders await them before first flush. Absent (= none) in
95
+ * projections serialized before the option existed. */
96
96
  readonly awaitedLoaderIndices?: readonly number[];
97
97
  /** Data-only transition config (no `when` — server-tree only); absent in
98
98
  * projections serialized before transition support. */
@@ -274,7 +274,7 @@ function serializeRoute(route: ClientUrlRouteRecord): ClientUrlProjectionRoute {
274
274
  });
275
275
 
276
276
  const awaitedLoaderIndices = route.loaders
277
- .map(({ stream }, index) => (stream === "navigation" ? index : -1))
277
+ .map(({ ssr }, index) => (ssr === false ? index : -1))
278
278
  .filter((index) => index >= 0);
279
279
 
280
280
  const transition = serializeTransition(route);
@@ -526,7 +526,7 @@ function materializeRouteItems(
526
526
  loader(
527
527
  createLoaderStub(id),
528
528
  route.awaitedLoaderIndices?.includes(loaderIndex)
529
- ? { stream: "navigation" }
529
+ ? { ssr: false }
530
530
  : undefined,
531
531
  () => [revalidate(makeClientDecisionRevalidate(id))],
532
532
  ),
@@ -87,12 +87,12 @@ export interface ClientUrlLoaderRecord {
87
87
  /** Client-run per-loader revalidation predicates; empty = locked defaults. */
88
88
  readonly revalidate: readonly ClientRevalidateFn[];
89
89
  /**
90
- * loader(Def, { stream: "navigation" }): document renders await this loader
91
- * before first flush (see {@link LoaderOptions}). Projected into the server
92
- * tree, where the per-isSSR entry stamping applies — client navigations
93
- * stream regardless.
90
+ * loader(Def, { ssr: false }): document renders await this loader before
91
+ * first flush (see {@link LoaderOptions}). Projected into the server tree,
92
+ * where the per-isSSR entry stamping applies — client navigations stream
93
+ * regardless.
94
94
  */
95
- readonly stream?: "navigation";
95
+ readonly ssr?: false;
96
96
  }
97
97
 
98
98
  /**
@@ -141,13 +141,14 @@ export interface ClientUrlHelpers {
141
141
  * revalidate() only — a CLIENT-RUN per-loader predicate; its decision (not
142
142
  * the function) is sent with the revalidation request.
143
143
  *
144
- * Pass `{ stream: "navigation" }` to await this loader before first flush
145
- * on DOCUMENT requests (see {@link LoaderOptions}) the opt-in for loaders
146
- * whose data, handle pushes, or thrown notFound()/redirect() must be in the
147
- * SSR'd HTML. Per-loader: a dynamic sibling keeps streaming. Under a
148
- * `ppr` group route the flag BAKES: the loader executes at shell capture
149
- * and its settled return freezes into the shell (nested promises stay
150
- * live holes).
144
+ * Pass `{ ssr: false }` the same knob as loading(fallback, { ssr:
145
+ * false }) to await this loader before first flush on DOCUMENT requests
146
+ * (see {@link LoaderOptions}): the opt-in for loaders whose data, handle
147
+ * pushes, or thrown notFound()/redirect() must be in the SSR'd HTML.
148
+ * Per-loader: a streaming sibling keeps streaming. Under a `ppr` group
149
+ * route the flag BAKES: the loader executes at shell capture and its
150
+ * settled return freezes into the shell (nested promises stay live
151
+ * holes).
151
152
  */
152
153
  readonly loader: <TData>(
153
154
  definition: LoaderDefinition<TData>,
@@ -807,8 +807,12 @@ const loader: RouteHelpers<any, any>["loader"] = (
807
807
  "loader() received two use() callbacks. Pass loader(Def, options, use) or loader(Def, use).",
808
808
  );
809
809
  invariant(
810
- optionsGiven?.stream === undefined || optionsGiven.stream === "navigation",
811
- `loader() stream must be "navigation" (got ${JSON.stringify(optionsGiven?.stream)}). Omit it to stream on every render.`,
810
+ (optionsGiven as { stream?: unknown } | undefined)?.stream === undefined,
811
+ "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.",
812
+ );
813
+ invariant(
814
+ optionsGiven?.ssr === undefined || typeof optionsGiven.ssr === "boolean",
815
+ `loader() ssr must be a boolean (got ${JSON.stringify(optionsGiven?.ssr)}). Omit it (or pass true) to stream on every render.`,
812
816
  );
813
817
 
814
818
  const name = `${ctx.namespace}.$${store.getNextIndex("loader")}`;
@@ -820,7 +824,7 @@ const loader: RouteHelpers<any, any>["loader"] = (
820
824
  const loaderEntry: LoaderEntry = {
821
825
  loader: loaderDef,
822
826
  revalidate: [] as ShouldRevalidateFn<any, any>[],
823
- ...(optionsGiven?.stream === "navigation" && ctx.isSSR
827
+ ...(optionsGiven?.ssr === false && ctx.isSSR
824
828
  ? { awaitBeforeFlush: true as const }
825
829
  : {}),
826
830
  };
@@ -310,13 +310,14 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
310
310
  * return <div>{data.name}</div>;
311
311
  * }
312
312
  * ```
313
- * Pass `{ stream: "navigation" }` to await this loader before first flush on
314
- * DOCUMENT requests (see {@link LoaderOptions}) — the opt-in for loaders whose
315
- * data, handle pushes, or thrown notFound()/redirect() must be in the SSR'd
316
- * HTML. Per-loader: a dynamic sibling in the same segment keeps streaming.
313
+ * Pass `{ ssr: false }` the same knob as loading(fallback, { ssr:
314
+ * false }) — to await this loader before first flush on DOCUMENT requests
315
+ * (see {@link LoaderOptions}): the opt-in for loaders whose data, handle
316
+ * pushes, or thrown notFound()/redirect() must be in the SSR'd HTML.
317
+ * Per-loader: a streaming sibling in the same segment keeps streaming.
317
318
  *
318
319
  * ```typescript
319
- * loader(ProductLoader, { stream: "navigation" }, () => [cache()]),
320
+ * loader(ProductLoader, { ssr: false }, () => [cache()]),
320
321
  * loader(RecommendationsLoader), // still streams behind loading()
321
322
  * ```
322
323
  *
@@ -572,7 +572,7 @@ function createLoaderExecutor<TEnv>(
572
572
  }
573
573
 
574
574
  // awaitBeforeFlush cycle: segment resolution awaits this loader
575
- // (loader(Def, { stream: "navigation" })), the barrier awaits segment
575
+ // (loader(Def, { ssr: false })), the barrier awaits segment
576
576
  // resolution, and rendered() awaits the barrier — waiting here can
577
577
  // never complete. Fail fast with the cause instead of hanging the
578
578
  // document render. Only document renders populate the set (the flag is
@@ -581,10 +581,10 @@ function createLoaderExecutor<TEnv>(
581
581
  if (reqCtx._awaitBeforeFlushLoaderIds?.has(currentLoaderId)) {
582
582
  throw new Error(
583
583
  `Deadlock: loader "${currentLoaderId}" is registered with ` +
584
- `stream: "navigation", so the document render awaits it before ` +
584
+ `ssr: false, so the document render awaits it before ` +
585
585
  `the render barrier resolves — ctx.rendered() (and the ` +
586
586
  `ctx.get(handle) read it gates) can never settle here. Drop ` +
587
- `stream: "navigation" on this loader or move the handle read to ` +
587
+ `ssr: false on this loader or move the handle read to ` +
588
588
  `a component.`,
589
589
  );
590
590
  }
@@ -104,7 +104,7 @@ export async function resolveLoaders<TEnv>(
104
104
  const errorContext = buildLoaderErrorContext(ctx);
105
105
 
106
106
  if (emitStreaming) {
107
- // awaitBeforeFlush (loader(Def, { stream: "navigation" })): document
107
+ // awaitBeforeFlush (loader(Def, { ssr: false })): document
108
108
  // renders await these loaders before returning, so their data is settled,
109
109
  // their handle pushes beat the barrier snapshot, and a thrown notFound()'s
110
110
  // status write deterministically precedes Response construction. The ids
@@ -148,7 +148,7 @@ export async function resolveLoaders<TEnv>(
148
148
  loaderEntry,
149
149
  ctx,
150
150
  ctx.pathname,
151
- // The bake key rides for flagged loaders too: stream:"navigation"
151
+ // The bake key rides for flagged loaders too: ssr:false
152
152
  // bakes at capture regardless of the entry's loading() lane
153
153
  // (loader-cache.ts capture branch) and its HIT-tail seed
154
154
  // overlay needs the same key.
@@ -159,7 +159,7 @@ export function resolveLoaderData<TEnv>(
159
159
  if (isShellCaptureActive(reqCtx)) {
160
160
  // Capture lane, per LOADER (not per entry):
161
161
  //
162
- // - `stream: "navigation"` (awaitBeforeFlush) — the BAKE lane. The flag's
162
+ // - `ssr: false` (awaitBeforeFlush) — the BAKE lane. The flag's
163
163
  // document promise is "this loader's data is in the HTML before first
164
164
  // flush"; under ppr the pre-flush HTML IS the frozen prelude, so the
165
165
  // loader executes at capture and its SETTLED return bakes into the
@@ -44,7 +44,7 @@ export { createMaskedLoaderPromise } from "./mask-nested.js";
44
44
  /**
45
45
  * Entry-level lane input for an entry's loaders under PPR (the loading()
46
46
  * value; docs/design/loader-container-bake.md). The CAPTURE decision itself
47
- * is per LOADER in loader-cache.ts: a `stream: "navigation"`
47
+ * is per LOADER in loader-cache.ts: an `ssr: false`
48
48
  * (awaitBeforeFlush) loader BAKES at capture regardless of this value — the
49
49
  * flag's document promise ("data in the HTML before first flush") maps to
50
50
  * the frozen prelude — while every other loader is LIVE (masked at capture,