@rangojs/router 0.7.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 (59) hide show
  1. package/dist/types/cache/cf/cf-cache-store.d.ts +30 -16
  2. package/dist/types/cache/shell-snapshot.d.ts +2 -2
  3. package/dist/types/cache/types.d.ts +26 -7
  4. package/dist/types/client-urls/server-projection.d.ts +4 -4
  5. package/dist/types/client-urls/types.d.ts +13 -12
  6. package/dist/types/route-definition/helpers-types.d.ts +6 -5
  7. package/dist/types/router/segment-resolution/loader-mask.d.ts +1 -1
  8. package/dist/types/rsc/shell-capture.d.ts +14 -4
  9. package/dist/types/rsc/types.d.ts +8 -0
  10. package/dist/types/server/context.d.ts +1 -1
  11. package/dist/types/server/request-context.d.ts +1 -1
  12. package/dist/types/ssr/index.d.ts +16 -0
  13. package/dist/types/ssr/ssr-root.d.ts +5 -0
  14. package/dist/types/types/loader-types.d.ts +24 -22
  15. package/dist/types/urls/path-helper-types.d.ts +8 -7
  16. package/dist/vite/index.js +8 -5
  17. package/package.json +1 -1
  18. package/skills/breadcrumbs/SKILL.md +2 -2
  19. package/skills/catalog.json +2 -2
  20. package/skills/client-urls/SKILL.md +9 -8
  21. package/skills/cloudflare/SKILL.md +5 -3
  22. package/skills/comparison/references/framework-comparison.md +2 -2
  23. package/skills/hooks/data.md +1 -1
  24. package/skills/hooks/handle-and-actions.md +1 -1
  25. package/skills/loader/SKILL.md +29 -28
  26. package/skills/migrate-nextjs/SKILL.md +3 -3
  27. package/skills/migrate-react-router/component-migration.md +1 -1
  28. package/skills/migrate-react-router/data-and-actions.md +1 -1
  29. package/skills/migrate-react-router/route-mapping.md +1 -1
  30. package/skills/parallel/SKILL.md +1 -1
  31. package/skills/ppr/SKILL.md +12 -8
  32. package/skills/rango/SKILL.md +20 -20
  33. package/skills/router-setup/SKILL.md +1 -1
  34. package/skills/scripts/SKILL.md +1 -1
  35. package/skills/shell-manifest/SKILL.md +1 -1
  36. package/src/browser/react/Link.tsx +27 -4
  37. package/src/cache/cf/cf-cache-store.ts +118 -62
  38. package/src/cache/shell-snapshot.ts +2 -2
  39. package/src/cache/types.ts +27 -7
  40. package/src/client-urls/client-urls.ts +12 -9
  41. package/src/client-urls/server-projection.ts +6 -6
  42. package/src/client-urls/types.ts +13 -12
  43. package/src/route-definition/dsl-helpers.ts +7 -3
  44. package/src/route-definition/helpers-types.ts +6 -5
  45. package/src/router/loader-resolution.ts +3 -3
  46. package/src/router/segment-resolution/fresh.ts +2 -2
  47. package/src/router/segment-resolution/loader-cache.ts +1 -1
  48. package/src/router/segment-resolution/loader-mask.ts +1 -1
  49. package/src/rsc/rsc-rendering.ts +10 -0
  50. package/src/rsc/shell-build-manifest.ts +13 -6
  51. package/src/rsc/shell-capture.ts +32 -5
  52. package/src/rsc/ssr-setup.ts +4 -0
  53. package/src/rsc/types.ts +18 -2
  54. package/src/server/context.ts +1 -1
  55. package/src/server/request-context.ts +1 -1
  56. package/src/ssr/index.tsx +22 -2
  57. package/src/ssr/ssr-root.tsx +17 -1
  58. package/src/types/loader-types.ts +21 -19
  59. package/src/urls/path-helper-types.ts +8 -7
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: client-urls
3
- description: Define client-component route groups with clientUrls() in a "use client" module — no handlers, useLoader at read sites, client-run revalidate predicates, loader-thrown notFound/redirect, loader handle writes, and the stream:"navigation" SSR-completeness opt-in. Use when a route group's pages are client components, when building high-navigation-speed UIs (dashboards, admin panels, settings) where transitions must feel instant, when moving data reads from handler ctx.use() to useLoader at consumption sites, or when asking how routes defined in a "use client" file work.
3
+ description: Define client-component route groups with clientUrls() in a "use client" module — no handlers, useLoader at read sites, client-run revalidate predicates, loader-thrown notFound/redirect, loader handle writes, and the ssr:false SSR-completeness opt-in. Use when a route group's pages are client components, when building high-navigation-speed UIs (dashboards, admin panels, settings) where transitions must feel instant, when moving data reads from handler ctx.use() to useLoader at consumption sites, or when asking how routes defined in a "use client" file work.
4
4
  argument-hint: "[setup]"
5
5
  ---
6
6
 
@@ -122,7 +122,7 @@ typing work exactly as for server routes (`/typesafety`).
122
122
  | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
123
123
  | `path()` | Options are `name`, `search`, `trailingSlash`, `ppr` (shell caching — see /ppr skill; loader routes need `loading()` or capture refuses); no response variants |
124
124
  | `layout()` | Must contain at least one `path()` |
125
- | `loader()` | `loader(Def, use?)` or `loader(Def, { stream: "navigation" }, use?)` — see below |
125
+ | `loader()` | `loader(Def, use?)` or `loader(Def, { ssr: false }, use?)` — see below |
126
126
  | `loading()` | Route/layout-level pending UI; inline `<Suspense>` at read sites is usually better |
127
127
  | `revalidate()` | Valid **inside a loader() use callback only**; runs in the browser |
128
128
  | `transition()` | Data-only ViewTransition config — no `when` (that is a server-executed predicate) |
@@ -214,7 +214,7 @@ export const ProductLoader = createLoader(async (ctx) => {
214
214
  On a document load, `notFound()` streams the resolved not-found UI in the
215
215
  envelope and _opportunistically_ sets a real 404 status — the status write only
216
216
  wins if the rejection settles before the document Response is constructed (a
217
- fast, pre-fetch existence check usually wins; see `stream: "navigation"` below
217
+ fast, pre-fetch existence check usually wins; see `ssr: false` below
218
218
  for the deterministic version). On navigations the 404 UI swaps in with the
219
219
  URL preserved. A loader `redirect()` is always a client-side navigate — there
220
220
  is no document-lane 302 from loaders; pre-stream redirect authority belongs to
@@ -228,7 +228,7 @@ ride the SSR handle snapshot; later ones stream and apply post-hydration
228
228
  (document loads) or progressively (navigations). See `/loader` for the full
229
229
  contract.
230
230
 
231
- ## `stream: "navigation"` — the SSR-completeness opt-in
231
+ ## `ssr: false` — the SSR-completeness opt-in
232
232
 
233
233
  By default every loader streams on every render, so nothing a slow loader
234
234
  produces is _guaranteed_ to be in the SSR'd HTML — data SSRs as the Suspense
@@ -238,12 +238,13 @@ document — `<head>` meta via a handle, or a real 404 status — flag it:
238
238
 
239
239
  ```ts
240
240
  path("/product/:slug", ProductPage, { name: "product" }, () => [
241
- loader(ProductLoader, { stream: "navigation" }, () => [revalidate(productData)]),
241
+ loader(ProductLoader, { ssr: false }, () => [revalidate(productData)]),
242
242
  loader(RelatedLoader), // untouched: still streams behind its boundary
243
243
  ]),
244
244
  ```
245
245
 
246
- The name says WHERE streaming still applies, not that it is disabled: document
246
+ The knob mirrors `loading(fallback, { ssr: false })` SSR delivery is off
247
+ for this loader, so nothing of it is left to stream in the document: document
247
248
  renders await this loader before first flush (data settled — no fallback
248
249
  paints; handle pushes beat the barrier snapshot; a thrown `notFound()` is a
249
250
  deterministic real 404, no warm-up race); client navigations stream exactly as
@@ -303,7 +304,7 @@ a hard load of the target URL renders the full route.
303
304
  `<Suspense>` above each `useLoader` read is still the finer-grained tool
304
305
  when different reads on one route should wait independently.
305
306
  - **Two parallel loaders with equal latency look "SSR'd" together.** Loaders
306
- kick off in parallel, so awaiting one (`stream: "navigation"`) gives
307
+ kick off in parallel, so awaiting one (`ssr: false`) gives
307
308
  same-or-faster siblings time to settle coincidentally. Do not read "it was
308
309
  in the HTML once" as a guarantee — only the flagged loader is guaranteed.
309
310
  - **Hook semantics shift inside a group.** `usePathname` is ABSOLUTE (mount
@@ -332,7 +333,7 @@ The router repository (not shipped in this package) carries a canonical
332
333
  consumer at `tests/vite-rsc-demo/src/urls/client-shop.client.tsx`: layout +
333
334
  index + product routes, param-sensitive predicates, action-scoped cart
334
335
  revalidation, loader-thrown `notFound()`/`redirect()`, loader-written
335
- Meta/Breadcrumbs, viewport prefetch on cards, and the `stream: "navigation"`
336
+ Meta/Breadcrumbs, viewport prefetch on cards, and the `ssr: false`
336
337
  fixtures — with e2e suites next to it pinning each contract in dev and
337
338
  production. The sections above are self-contained; the app is corroboration,
338
339
  not required reading.
@@ -130,9 +130,11 @@ export const router = createRouter<AppBindings>({
130
130
  ```
131
131
 
132
132
  PPR shells use Cache API as the per-colo L1 and KV as the durable cross-colo
133
- L2; a KV hit promotes the coupled shell envelope back into L1. KV remains
134
- required for this family. `CFCacheStore` without `kv` can still cache
135
- segment/response entries in Cache API, but its shell family is inert.
133
+ L2; a KV hit promotes the coupled shell envelope back into L1. KV is optional:
134
+ `CFCacheStore` without `kv` runs the shell family L1-only (edge-only ppr —
135
+ each colo captures and serves its own shell). Tag eviction then needs purge
136
+ mode (`tagPurge`); a tagged shell without either warns once and expires by
137
+ ttl+swr only.
136
138
 
137
139
  ## Commands
138
140
 
@@ -621,7 +621,7 @@ boundaries, compose server-side via `ctx.use(OtherLoader)`, and carry
621
621
  route-level authority: they can throw `notFound()`/`redirect()`, WRITE handles
622
622
  (`ctx.use(Meta)({ title })` — data-derived page metadata pushed from the data's
623
623
  producer), read handle data after the render settles (`ctx.get(handle)` behind
624
- `await ctx.rendered()`), and opt into `loader(Def, { stream: "navigation" })`
624
+ `await ctx.rendered()`), and opt into `loader(Def, { ssr: false })`
625
625
  so a document render awaits them before first flush (deterministic SSR'd data,
626
626
  meta, and 404 status). Reads happen through `useLoader` in a client component
627
627
  (including its SSR pass) or `useFetchLoader` for standalone client fetches. "Fetchable" loaders are callable
@@ -691,7 +691,7 @@ that no loader-before-render has:
691
691
  read by many segments, composed via `ctx.use`, or exposed as a fetchable endpoint;
692
692
  - it **streams as a hole, not a gate** — concurrent, Suspense-resolved under
693
693
  `loading()`, so the shell never blocks on it (and gating is a deliberate
694
- per-loader opt-in — `{ stream: "navigation" }` awaits ONE loader on document
694
+ per-loader opt-in — `{ ssr: false }` awaits ONE loader on document
695
695
  renders while the rest keep streaming — not the model's default);
696
696
  - it is **client-addressable and refreshable** independent of navigation (`key`,
697
697
  `refreshGroup`, `useRefreshLoaders`), behaving like a built-in, server-defined
@@ -26,7 +26,7 @@ boundary (or the route's `loading()`) — it does NOT render with
26
26
  `isLoading: true`. Put a boundary above every read whose loader can be slow;
27
27
  `isLoading` covers later refetches (`load()`, key/group refreshes). Once the
28
28
  component renders, `data` is present. (On document loads a loader registered
29
- with `{ stream: "navigation" }` is already settled at first paint, so its
29
+ with `{ ssr: false }` is already settled at first paint, so its
30
30
  reads never suspend there — see `/loader`.)
31
31
 
32
32
  **Precondition**: Loader must be registered on route via `loader()` helper.
@@ -33,7 +33,7 @@ const lastCrumb = useHandle(Breadcrumbs, (data) => data.at(-1));
33
33
  Writers: handlers AND loader bodies — `ctx.use(Handle)` returns the push
34
34
  function in each (middleware has no handle APIs). Loader pushes follow a delivery race (settle before
35
35
  the handler barrier → in the SSR'd document; later → applied client-side), and
36
- `loader(Def, { stream: "navigation" })` makes them deterministic in the
36
+ `loader(Def, { ssr: false })` makes them deterministic in the
37
37
  document. See `/loader` → "Writing Handles from Loaders". `useHandle`
38
38
  re-renders on every handle update, late loader pushes included.
39
39
 
@@ -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
 
@@ -356,13 +356,17 @@ curl -s -D - -o /dev/null https://app.example.com/products/1 | grep -i x-rango-s
356
356
  key — no header + a declared `ppr` means look for that warning.
357
357
  - On Cloudflare, `CFCacheStore` reads PPR shells from the per-colo Cache API,
358
358
  falls through to KV on a miss, and promotes the KV hit back into that colo.
359
- WITHOUT a KV namespace its shell family remains inert: every ppr route stays
360
- `MISS` forever. The store warns once per isolate bind KV
361
- (`new CFCacheStore({ ctx, kv: env.CACHE_KV })`) or use another store. An
362
- inert store also stops captures at the gate: the store declares
363
- `shellFamilyInert` and the scheduler skips the background render entirely
364
- (`skip-inert-store` on the debug event) instead of burning a full capture per
365
- MISS whose write could only no-op.
359
+ WITHOUT a KV namespace the family runs L1-only (edge-only ppr): every colo
360
+ captures and serves its own shell from the Cache API. What changes KV-less
361
+ is tag eviction with `tagPurge` the purge-by-tag evicts shell L1 entries;
362
+ without it a TAGGED shell warns once that `updateTag()` cannot reach it
363
+ (ttl/swr-only freshness). Untagged edge-only ppr is warning-free. Tagged
364
+ BUILD-manifest shells decline on a KV-less store (`tagHistoryInert` the
365
+ immutable asset could never be evicted), and an over-limit tag set is
366
+ acknowledged `"uncacheable"` so the capture backs off instead of
367
+ re-rendering per MISS. A custom store can still declare `shellFamilyInert`
368
+ to stop captures at the gate (`skip-inert-store` on the debug event); the
369
+ built-in stores never do.
366
370
  - Structured capture diagnostics: `createRouter({ debugShellCapture: true })`
367
371
  logs one line per capture attempt/skip (outcome, durations, prelude and
368
372
  snapshot bytes, backoff state); pass a function to receive each
@@ -665,7 +669,7 @@ LOADER-pushed Meta is different: loaders are masked at capture, so the push
665
669
  happens at request time and applies client-side (`metadata.handlesLate`) — it
666
670
  is never in the cached shell's head, by construction.
667
671
 
668
- One flag to know about here: `loader(Def, { stream: "navigation" })` (the
672
+ One flag to know about here: `loader(Def, { ssr: false })` (the
669
673
  document-render await, `/loader`) is **inert under PPR** — capture renders
670
674
  mask loaders and skip the await, and a shell HIT flushes the stored prelude
671
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.