@rangojs/router 0.0.0-experimental.138 → 0.0.0-experimental.139

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.
@@ -33,6 +33,48 @@ export default {
33
33
  };
34
34
  ```
35
35
 
36
+ ## Deploying: Cloudflare vs node/vercel
37
+
38
+ How a host router is _served_ depends on the preset, because the preset decides who owns the server entry.
39
+
40
+ | Preset | Who owns the entry | What the host module exports |
41
+ | ----------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------- |
42
+ | `cloudflare` | You (your `worker.rsc.tsx`) | `export default { fetch(request, env, ctx) { return router.match(request, { env, ctx }); } }` |
43
+ | `node` / `vercel` | rango (generated RSC entry) | `export default router;` (the `HostRouter` instance itself), or a named `export const hostRouter`/`router`. |
44
+
45
+ On `node`/`vercel`, rango generates the served RSC entry, so it needs the `HostRouter` **instance** to call `hostRouter.match()` for you. Export the instance, not a `{ fetch }` object:
46
+
47
+ ```typescript
48
+ // src/worker.rsc.tsx (node / vercel)
49
+ import { createHostRouter } from "@rangojs/router/host";
50
+
51
+ export const hostRouter = createHostRouter();
52
+ hostRouter.host(["admin.*"]).lazy(() => import("./apps/admin/handler.js"));
53
+ hostRouter.host(["."]).lazy(() => import("./apps/site/handler.js"));
54
+
55
+ // Export the instance — the generated entry serves it via hostRouter.match().
56
+ export default hostRouter;
57
+ ```
58
+
59
+ Each sub-app exports a handler exactly as on Cloudflare (no change):
60
+
61
+ ```typescript
62
+ // src/apps/admin/handler.ts
63
+ import { router } from "./router.js";
64
+ export default (request: Request, input: any) => router.fetch(request, input);
65
+ ```
66
+
67
+ Selecting the host entry — a host app has several `createRouter()` sub-apps, so single-router auto-discovery can't pick one. Either let rango auto-detect the lone `createHostRouter()` file, or point at it explicitly:
68
+
69
+ ```typescript
70
+ // vite.config.ts
71
+ rango({ preset: "vercel", hostRouter: "./src/worker.rsc.tsx" });
72
+ ```
73
+
74
+ On Vercel this is a single function running `hostRouter.match()` for every request (mirrors the Cloudflare single-worker model); `{ env, ctx }` (`process.env` + `{ waitUntil }`) is threaded unchanged to each matched sub-app's handler and `cache(env, ctx)` factory. See the `vercel` skill.
75
+
76
+ Unmatched hosts on node/vercel: because rango owns the generated entry (you have no worker `try/catch`), it catches `NoRouteMatchError` and returns **404** by default — so you do **not** need a catch-all host route. If you want different behavior (a branded 404, a redirect, a default app), register a catch-all mount as the **last** route, e.g. `host(["**"]).lazy(() => import("./apps/site/handler.js"))` — it matches any host, so the built-in 404 only fires when nothing matched at all. (Note `fallback()` is for cookie-override errors, not general unmatched hosts.)
77
+
36
78
  ## Inline handlers (`.map`) vs lazy mounts (`.lazy`)
37
79
 
38
80
  A host pattern maps to one of two things, and you pick the method by intent:
@@ -134,17 +176,17 @@ router.fallback().map((request) => {
134
176
  });
135
177
  ```
136
178
 
137
- For unmatched hosts without `hostOverride`, catch `NoRouteMatchError` in your worker fetch:
179
+ For unmatched hosts without `hostOverride`, catch `NoRouteMatchError` in your worker fetch. Use the `isNoRouteMatchError()` guard rather than a bare `instanceof`: a workspace with a duplicated `@rangojs/router` copy can throw the error with a different class identity, and `instanceof` would then turn the 404 into an opaque 500.
138
180
 
139
181
  ```typescript
140
- import { NoRouteMatchError } from "@rangojs/router/host";
182
+ import { isNoRouteMatchError } from "@rangojs/router/host";
141
183
 
142
184
  export default {
143
185
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {
144
186
  try {
145
187
  return await router.match(request, { env, ctx });
146
188
  } catch (err) {
147
- if (err instanceof NoRouteMatchError) {
189
+ if (isNoRouteMatchError(err)) {
148
190
  return new Response("Not Found", { status: 404 });
149
191
  }
150
192
  throw err;
@@ -147,12 +147,21 @@ A layout as a child of `path()` wraps the route content and can read
147
147
  data set by the route handler via `ctx.get()`. The handler always
148
148
  executes before its children.
149
149
 
150
- This handler-first guarantee applies to a single full render pass
151
- (initial render, prerender, or full HTML re-render). During partial
152
- action revalidation, only the segments that revalidate are recomputed.
153
- If an orphan layout depends on data established by an outer handler or
154
- layout, that outer segment must also revalidate, or the orphan must
155
- guard/reload the data independently.
150
+ This is the recommended way to pass handler data downward, and it is
151
+ safe under partial action revalidation with zero configuration: orphan
152
+ layouts (and their parallels) belong to the route entry, and on an
153
+ action the whole entry re-runs together by default — route segment,
154
+ loaders, and `belongsToRoute` children all seed revalidate-true, with
155
+ handler-first ordering preserved. Producer and consumer cannot desync
156
+ unless you narrow one side with a predicate that returns a hard `false`
157
+ (then put the same contract on both — see "Revalidation Contracts").
158
+
159
+ Data from an **outer** handler or layout entry is the opposite case:
160
+ outer entries do not revalidate on actions by default (parent-chain
161
+ skip). If an orphan layout depends on data established above its own
162
+ route entry, that outer segment must share a revalidation contract, or
163
+ the orphan must guard/reload the data independently. See `/rango` →
164
+ "Passing data down the tree" for the full safest-first ladder.
156
165
 
157
166
  ```typescript
158
167
  import { Outlet, ParallelOutlet } from "@rangojs/router/client";
@@ -191,7 +200,10 @@ orphan layouts to read them.
191
200
 
192
201
  ## Layout Revalidation
193
202
 
194
- Layouts don't revalidate by default. Control with `revalidate()`:
203
+ Standalone `layout()` entries don't revalidate by default — on an action,
204
+ parent-chain segments are skipped unless a `revalidate()` opts them in.
205
+ (Orphan layouts inside a `path()` are the opposite: they ride along with
206
+ the route entry by default.) Control with `revalidate()`:
195
207
 
196
208
  ```typescript
197
209
  layout(<ShopLayout />, () => [
@@ -218,8 +230,13 @@ their `ctx.set()` state.
218
230
 
219
231
  ### Revalidation Contracts
220
232
 
221
- For shared upstream data, define named revalidation functions and reuse
222
- them on both producer and consumer segments:
233
+ Contracts are the tool for cross-entry sharing — the bottom rung of the
234
+ data-passing ladder (`/rango` → "Passing data down the tree"). Before
235
+ writing one, check whether the producer can move down a rung: into the
236
+ consumer's own entry as an orphan layout, into middleware, or into a
237
+ loader. When the data genuinely must flow from an outer entry, define
238
+ named revalidation functions and reuse them on both producer and
239
+ consumer segments:
223
240
 
224
241
  ```typescript
225
242
  // revalidation-contracts.ts
@@ -60,8 +60,12 @@ data itself.
60
60
  ### Revalidation Contracts with Middleware-Backed Trees
61
61
 
62
62
  Middleware can establish request-level context (`ctx.set`) for segments that
63
- execute in the current render pass. It does not change partial revalidation
64
- boundaries between handler/layout/parallel segments.
63
+ execute in the current render pass. Because route middleware wraps **every**
64
+ render pass — normal renders, post-action revalidation, PE re-renders — its
65
+ variables are never stale: middleware is the safest `ctx.set` rung on the
66
+ data-passing ladder (`/rango` → "Passing data down the tree"). But it does
67
+ not change partial revalidation boundaries between handler/layout/parallel
68
+ segments.
65
69
 
66
70
  For shared segment data, use named revalidation contracts on both the producer
67
71
  and consumer segments, even when middleware is present in the chain.
@@ -112,7 +112,29 @@ const router = createRouter({
112
112
  });
113
113
  ```
114
114
 
115
- Both factories return a `RouterTracingConfig` for the same `tracing` slot;
115
+ On **Vercel Functions** (Node runtime), use `createVercelTracing` — a thin
116
+ wrapper over `createOTelTracing` that reads the global OTel tracer
117
+ `@vercel/otel`'s `registerOTel()` installs, so you do not call `trace.getTracer`
118
+ yourself. Custom spans are Node-only (unsupported on the Edge runtime):
119
+
120
+ ```typescript
121
+ // instrumentation.ts — install the provider, then export the tracing config.
122
+ // Importing this module is what runs registerOTel() — a Rango/Vite app does not
123
+ // auto-load instrumentation.ts like Next.js, so a standalone registerOTel() that
124
+ // nothing imports is a silent no-op.
125
+ import { registerOTel } from "@vercel/otel";
126
+ import { createVercelTracing } from "@rangojs/router/vercel";
127
+ registerOTel({ serviceName: "my-app" });
128
+ export const tracing = createVercelTracing(); // { enabled, spans, tracerName, tracer }
129
+
130
+ // router.tsx — importing `tracing` runs instrumentation.ts
131
+ import { createRouter } from "@rangojs/router";
132
+ import { tracing } from "./instrumentation.js";
133
+
134
+ const router = createRouter({ document: Document, urls: urlpatterns, tracing });
135
+ ```
136
+
137
+ These factories return a `RouterTracingConfig` for the same `tracing` slot;
116
138
  `telemetry` stays independent (events only, no phase spans). Phase spans:
117
139
  `rango.request`, `rango.middleware`, `rango.action`, `rango.loader`,
118
140
  `rango.render`, `rango.ssr` — the same phases the `debugPerformance` timeline
@@ -344,9 +344,17 @@ parallel(
344
344
  )
345
345
  ```
346
346
 
347
- Revalidating only the parallel does not re-run outer handlers/layouts.
348
- If the slot reads `ctx.get()` data established above it, opt the outer
349
- segment into revalidation as well.
347
+ Where the slot sits decides its action default. A parallel under a
348
+ `path()` (or one of its orphan layouts) belongs to the route entry and
349
+ revalidates together with it on every action — handler-set data stays
350
+ consistent with no configuration. A parallel under a standalone
351
+ `layout()` entry follows the parent-chain default instead: skipped on
352
+ actions unless a `revalidate()` opts it in.
353
+
354
+ In either position, revalidating only the parallel does not re-run outer
355
+ handlers/layouts. If the slot reads `ctx.get()` data established above
356
+ it, opt the outer segment into revalidation as well (see `/rango` →
357
+ "Passing data down the tree").
350
358
 
351
359
  A `revalidate()` callback may return a hard `boolean`, a soft
352
360
  `{ defaultShouldRevalidate }` object, or nothing (`void` / `null` /
@@ -59,22 +59,57 @@ appears, not up front.
59
59
  To decide where something can live: **does it define a URL? structure, stays in
60
60
  `urls()`. Does it modify a node? config, compose freely.**
61
61
 
62
+ ## Passing data down the tree
63
+
64
+ Four ways to get per-request data to a segment below you, ordered safest-first.
65
+ Reach for the next rung only when the one above doesn't fit — the higher rungs
66
+ are immune to partial-revalidation staleness by construction.
67
+
68
+ 1. **A loader** (`loader()` + `useLoader()`). Loaders resolve fresh on every
69
+ pass — full renders, action revalidations, cache hits. Nothing to keep in
70
+ sync. If the data can be a loader, make it a loader.
71
+ 2. **Middleware `ctx.set()`**. Route middleware wraps every render pass,
72
+ including post-action revalidation and PE re-renders, so its variables are
73
+ never stale. Right for request-shaped context: auth, session, locale.
74
+ 3. **Handler `ctx.set()` to its own children** —
75
+ `path(handler, ..., () => [layout(...)])`. Orphan layouts and their
76
+ parallels belong to the route entry: on an action the whole entry re-runs
77
+ together by default (handler-first preserved), so the data stays consistent
78
+ with zero configuration. Right for data the page must compute anyway —
79
+ e.g. pagination, where the handler's search decides how many pages the
80
+ layout chrome renders. One rule: if you narrow the entry's revalidation
81
+ with a predicate that can return a hard `false`, put the same contract on
82
+ the entry's children too — a hard `false` on one side of a
83
+ producer/consumer pair desyncs it.
84
+ 4. **Cross-entry sharing** — an outer `layout()` entry feeding descendants.
85
+ Outer entries do NOT revalidate on actions by default (the revalidation
86
+ trace calls this `action:parent-chain-skip`), so this rung always requires
87
+ a shared revalidation contract: the same named `revalidate()` function on
88
+ the producer and every consumer. See `/layout` → "Revalidation Contracts".
89
+ Before writing one, check whether the producer can move down a rung.
90
+
91
+ The failure mode this ladder prevents: a consumer re-runs, its producer
92
+ doesn't, `ctx.get()` reads `undefined`, and fallback UI silently replaces good
93
+ UI after an action. Rungs 1–3 make that unrepresentable; rung 4 makes it a
94
+ stated, greppable contract.
95
+
62
96
  ## Pick a primitive
63
97
 
64
- | I need to… | Use | Skill |
65
- | ------------------------------------- | -------------------------------- | ----------------------- |
66
- | render data fresh every request | `loader()` + `useLoader()` | /loader |
67
- | cache a rendered subtree | `cache()` on a segment | /caching |
68
- | cache one function/component's result | `"use cache"` | /use-cache |
69
- | cache a loader's data | `loader(L, () => [cache()])` | /loader, /caching |
70
- | re-render a segment after an action | `revalidate()` | /loader |
71
- | mutate | `"use server"` action | /server-actions |
72
- | debug a slow request | `debugPerformance` / telemetry | /observability |
73
- | share config across routes | factory returning a helper array | /composability |
74
- | compose a sub-app / module | `include()` | /route |
75
- | modal / soft navigation | `intercept()` | /intercept |
76
- | pre-render a route at build time | `Prerender(...)` wrapper | /prerender |
77
- | 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
+ | pre-render a route at build time | `Prerender(...)` wrapper | /prerender |
111
+ | feed live loaders from a cached shell | replayed handle + `ctx.rendered()` | /shell-manifest |
112
+ | stream SSE / upgrade a WebSocket | `path.stream()` / `path.any()` | /streams-and-websockets |
78
113
 
79
114
  ## Invariants
80
115
 
@@ -185,6 +220,12 @@ resolve `dist/` outside `./vite`, and it may lag `src/`.
185
220
 
186
221
  Grouped by concern — read when you need to…
187
222
 
223
+ **Positioning & evaluation**:
224
+
225
+ | Skill | Description |
226
+ | ------------- | ------------------------------------------------------------ |
227
+ | `/comparison` | Compare Rango with Next.js, TanStack Start, and Waku fairly. |
228
+
188
229
  **Structure & routing** — shape URLs, layouts, navigation, and request processing:
189
230
 
190
231
  | Skill | Description |
@@ -206,15 +247,16 @@ Grouped by concern — read when you need to…
206
247
 
207
248
  **Data & caching** — fetch, mutate, and cache:
208
249
 
209
- | Skill | Description |
210
- | ----------------- | ----------------------------------------------------------------------- |
211
- | `/loader` | Data loaders with `createLoader()` and `revalidate()` |
212
- | `/server-actions` | Mutations with `"use server"`, useActionState, validation, revalidation |
213
- | `/caching` | Segment caching with memory or KV stores |
214
- | `/use-cache` | Function-level caching with `"use cache"` directive |
215
- | `/cache-guide` | When to use `cache()` vs `"use cache"` — differences and decision guide |
216
- | `/document-cache` | Edge caching with Cache-Control headers |
217
- | `/prerender` | Pre-render route segments at build time (Passthrough live fallback) |
250
+ | Skill | Description |
251
+ | ----------------- | ------------------------------------------------------------------------------------------ |
252
+ | `/loader` | Data loaders with `createLoader()` and `revalidate()` |
253
+ | `/server-actions` | Mutations with `"use server"`, useActionState, validation, revalidation |
254
+ | `/caching` | Segment caching with memory or KV stores |
255
+ | `/use-cache` | Function-level caching with `"use cache"` directive |
256
+ | `/cache-guide` | When to use `cache()` vs `"use cache"` — differences and decision guide |
257
+ | `/document-cache` | Edge caching with Cache-Control headers |
258
+ | `/prerender` | Pre-render route segments at build time (Passthrough live fallback) |
259
+ | `/shell-manifest` | Replayed handles as cache metadata read by live loaders (frozen shell, batched live holes) |
218
260
 
219
261
  **Client & presentation** — build the client-side UX:
220
262
 
@@ -239,6 +281,12 @@ Grouped by concern — read when you need to…
239
281
  | `/bundle-analysis` | Audit your app's production bundle for server leaks and oversized chunks |
240
282
  | `/debug-manifest` | Inspect route manifest structure |
241
283
 
284
+ **Deployment**:
285
+
286
+ | Skill | Description |
287
+ | --------- | ----------------------------------------------------------------------------------------- |
288
+ | `/vercel` | Deploy to Vercel Functions (`preset: "vercel"`), Runtime Cache, and `createVercelTracing` |
289
+
242
290
  **Testing**:
243
291
 
244
292
  | Skill | Description |
@@ -154,6 +154,13 @@ first. Use `ctx.set(key, value)` to share data with children, who read it
154
154
  via `ctx.get(key)`. Caching wraps all segments together, so either all run
155
155
  or none do.
156
156
 
157
+ This pattern is also safe under partial action revalidation: on an action,
158
+ the route entry re-runs as a unit by default — route segment, loaders, and
159
+ `belongsToRoute` children (orphan layouts, entry parallels) all seed
160
+ revalidate-true, with handler-first ordering preserved. Handler-set data
161
+ stays consistent with no configuration. See `/rango` → "Passing data down
162
+ the tree" for the safest-first ladder.
163
+
157
164
  ### Typed context variables with createVar
158
165
 
159
166
  Use `createVar<T>()` to create a typed token for `ctx.set()`/`ctx.get()`.
@@ -240,9 +247,18 @@ Cacheable vars (the default) can be read freely inside cache scopes.
240
247
  > decides hit/miss/ttl/swr independently and never reads `revalidate()`. See
241
248
  > `/cache-guide` → "Two axes" and `/rango` → "The shape of rango".
242
249
 
243
- Handler-first guarantees apply within a single full render pass. For partial
244
- action revalidation, define named revalidation contracts and reuse them on both
245
- the producer route and the consumer child segments.
250
+ With no `revalidate()` configured, an entry needs no contract: on an action
251
+ the route handler and its children re-run together by default, so handler
252
+ data stays consistent on its own. Contracts matter in two cases:
253
+
254
+ 1. **You narrow the entry's revalidation** with a predicate that can return a
255
+ hard `false` (e.g. bare `ctx.isAction(X)`). A hard `false` on one side of a
256
+ producer/consumer pair desyncs it — the child re-runs by default and reads
257
+ `undefined`, or vice versa. Put the same named contract on the route and
258
+ its dependent children so they narrow together.
259
+ 2. **The producer is an outer entry** (a standalone `layout()` above this
260
+ route). Outer entries skip action revalidation by default, so the shared
261
+ contract is mandatory — see `/layout` → "Revalidation Contracts".
246
262
 
247
263
  ```typescript
248
264
  // revalidation-contracts.ts
@@ -505,6 +505,7 @@ const router = createRouter({
505
505
  ```typescript
506
506
  // On Cloudflare Workers, swap the tracing factory for native custom spans
507
507
  // (no @opentelemetry/api dependency); the telemetry slot is unchanged.
508
+ // On Vercel (Node runtime) use createVercelTracing() from @rangojs/router/vercel.
508
509
  import { createCloudflareTracing } from "@rangojs/router/cloudflare";
509
510
 
510
511
  const router = createRouter({
@@ -0,0 +1,185 @@
1
+ ---
2
+ name: shell-manifest
3
+ description: Shell manifest pattern — replayed handles as cache metadata that live loaders read, e.g. a prerendered product list with batched live prices
4
+ argument-hint:
5
+ ---
6
+
7
+ # Shell Manifest — cache metadata for live loaders
8
+
9
+ Use this when a cached or prerendered shell has dynamic holes, and the live
10
+ data layer needs to know **what the shell actually contains** — which
11
+ products, which slots, which keys. The frozen render describes itself
12
+ through a handle; loaders (always live) read that description and fetch
13
+ exactly the dynamic data the shell needs, in one batch.
14
+
15
+ Canonical case: a prerendered product list where prices must stay live.
16
+
17
+ ## The problem this solves
18
+
19
+ Any cached-shell-plus-live-holes design has a coordination gap: how does the
20
+ live layer know what the holes need?
21
+
22
+ - **Per-hole fetching** (each `<Price>` component fetching for itself) is the
23
+ N+1 default — N visible products, N queries.
24
+ - **A loader that re-queries the list** ("current top products") drifts from
25
+ a stale shell — right prices attached to wrong products.
26
+
27
+ The shell manifest closes the gap with a consistency guarantee: the loader
28
+ reads the ids the shell _actually rendered_, replayed from the same stored
29
+ artifact, so the holes can never desync from the shell and the query is
30
+ batched.
31
+
32
+ ## The mechanism (three features composed)
33
+
34
+ 1. **Handles record data at render time.** The handler pushes to a handle
35
+ (`ctx.use(Handle)`) while it renders — at build time for `Prerender`, on
36
+ the cache miss for `cache()`.
37
+ 2. **Replay on every hit.** Handle data is stored with the Flight payload
38
+ and replayed into the handle store on cache/prerender hits — handler code
39
+ does not re-run, but its pushes do.
40
+ 3. **Loaders read after the render barrier.** A DSL loader can
41
+ `await ctx.rendered()` (waits for all non-loader segments to settle —
42
+ fresh render or replay alike), then `ctx.use(Handle)` returns the
43
+ **collected** handle data.
44
+
45
+ Loaders are live by default, so the read happens on every request even when
46
+ the shell is a hit.
47
+
48
+ ## Canonical example: prerendered list, live prices
49
+
50
+ ```tsx
51
+ // handles/rendered-products.ts
52
+ import { createHandle } from "@rangojs/router";
53
+
54
+ // TData = string (one push per product id), collected to a flat string[]
55
+ export const RenderedProducts = createHandle<string, string[]>((segments) =>
56
+ segments.flat(),
57
+ );
58
+ ```
59
+
60
+ ```tsx
61
+ // routes/products.tsx — the list is baked at build time; prices are not
62
+ import { Prerender } from "@rangojs/router";
63
+ import { RenderedProducts } from "../handles/rendered-products";
64
+ import { Price } from "../components/price";
65
+
66
+ export const ProductList = Prerender(
67
+ async () => [{ category: "espresso" }, { category: "filter" }],
68
+ async (ctx) => {
69
+ const products = await db.productsByCategory(ctx.params.category);
70
+ const track = ctx.use(RenderedProducts);
71
+ for (const p of products) track(p.id);
72
+ return (
73
+ <ul>
74
+ {products.map((p) => (
75
+ <li key={p.id}>
76
+ {p.name} <Price id={p.id} />
77
+ </li>
78
+ ))}
79
+ </ul>
80
+ );
81
+ },
82
+ );
83
+ ```
84
+
85
+ ```ts
86
+ // loaders/prices.ts — one batched query for exactly the rendered products
87
+ import { createLoader } from "@rangojs/router";
88
+ import { RenderedProducts } from "../handles/rendered-products";
89
+
90
+ export const PriceLoader = createLoader(async (ctx) => {
91
+ "use server";
92
+ await ctx.rendered();
93
+ const ids = ctx.use(RenderedProducts);
94
+ return db.pricesFor(ids); // Map<string, number> keyed by product id
95
+ });
96
+ ```
97
+
98
+ ```tsx
99
+ // urls.tsx — wire the route and register the loader
100
+ path("/products/:category", ProductList, { name: "products" }, () => [
101
+ loader(PriceLoader),
102
+ ]);
103
+ ```
104
+
105
+ ```tsx
106
+ // components/price.tsx — live hole in the frozen shell
107
+ "use client";
108
+ import { useLoader } from "@rangojs/router/client";
109
+ import { PriceLoader } from "../loaders/prices";
110
+
111
+ export function Price({ id }: { id: string }) {
112
+ const { data } = useLoader(PriceLoader);
113
+ return <span>{formatPrice(data[id])}</span>;
114
+ }
115
+ ```
116
+
117
+ Request flow on a hit: stored payload replays (handler never runs) → handle
118
+ data lands in the store → render barrier resolves → `PriceLoader` reads the
119
+ replayed ids → one query → prices stream into `<Price>` components.
120
+
121
+ ## Works with runtime cache() too
122
+
123
+ `Prerender` is build-time caching; the replay mechanism is identical for the
124
+ runtime segment cache. Wrap the route in `cache()` instead and the handler
125
+ pushes on the miss, replays on every hit:
126
+
127
+ ```tsx
128
+ cache({ ttl: 600, tags: ["products"] }, () => [
129
+ path("/products/:category", ProductList, { name: "products" }, () => [
130
+ loader(PriceLoader),
131
+ ]),
132
+ ]);
133
+ ```
134
+
135
+ ## Contract and gotchas
136
+
137
+ - **The manifest is exactly as fresh as the shell.** Replayed handle data is
138
+ frozen with the payload. To change _which_ products render, invalidate the
139
+ shell (`updateTag("products")`, TTL expiry, rebuild) — never treat the
140
+ loader as the refresh path for the list itself. This is the point:
141
+ shell and holes cannot desync because they share one artifact.
142
+ - **No request-scoped data in a manifest handle.** The handle data is baked
143
+ into a shared artifact — the same cross-user rule as any cached content.
144
+ Ids, slugs, slot names, variant keys: yes. Anything derived from
145
+ `cookies()`/`headers()`: no.
146
+ - **`ctx.rendered()` is experimental and DSL-loaders-only.** It throws in
147
+ fetchable/standalone loader calls that run outside a route render.
148
+ - **The reading loader serializes after the shell.** `await ctx.rendered()`
149
+ deliberately gives up loader/render parallelism — on a miss the loader
150
+ waits for segment resolution; on a hit (the common case for a cached
151
+ shell) replay is immediate and the wait is negligible. A
152
+ `debugPerformance` waterfall shows this loader after the render bar; for
153
+ this pattern that is the contract, not a regression.
154
+ - **`ctx.use(Handle)` before `await ctx.rendered()` throws** in a loader,
155
+ with an error saying to await the barrier first.
156
+ - **Deferred handle values are resolved before storage** (resolve-by-default),
157
+ so the manifest read always sees plain values, never promises.
158
+
159
+ ## Testing
160
+
161
+ `runLoader` seeds the barrier and the collected handle value directly —
162
+ matched by handle reference (the same seeding style as loader deps):
163
+
164
+ ```ts
165
+ import { runLoader } from "@rangojs/router/testing";
166
+
167
+ const prices = await runLoader(PriceLoader, {
168
+ rendered: true,
169
+ handles: [[RenderedProducts, ["widget-a", "widget-b"]]],
170
+ env: { DB: fakeDb },
171
+ });
172
+ ```
173
+
174
+ This tests the loader's post-barrier logic. The real
175
+ push → store → replay → barrier wiring is covered at the e2e tier (dev +
176
+ production), like every cache-path behavior.
177
+
178
+ ## Related
179
+
180
+ - `/prerender` — `Prerender`/`Passthrough`, build flow, passthrough fallback
181
+ - `/caching` — segment `cache()`, stores, tags
182
+ - `/loader` — loader context, `ctx.rendered()`, streaming
183
+ - `/hooks` — `useHandle` for reading handle data in client components
184
+ - `/rango` → "Passing data down the tree" — this pattern is the frozen→live
185
+ counterpart of that ladder