@rangojs/router 0.0.0-experimental.105 → 0.0.0-experimental.106

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.
@@ -2040,7 +2040,7 @@ import { resolve } from "node:path";
2040
2040
  // package.json
2041
2041
  var package_default = {
2042
2042
  name: "@rangojs/router",
2043
- version: "0.0.0-experimental.105",
2043
+ version: "0.0.0-experimental.106",
2044
2044
  description: "Django-inspired RSC router with composable URL patterns",
2045
2045
  keywords: [
2046
2046
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.105",
3
+ "version": "0.0.0-experimental.106",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -9,6 +9,98 @@ argument-hint:
9
9
  Both mechanisms share the same backing store, cache profiles, and tag-based
10
10
  invalidation. They differ in scope, cache key, execution model, and runtime control.
11
11
 
12
+ ## Two axes — do not conflate
13
+
14
+ Everything on this page is **axis 1: stored-value freshness** — _is a cached
15
+ value still good?_ There is a second, orthogonal axis it is easy to mistake for
16
+ caching:
17
+
18
+ 1. **Stored-value freshness** — _is a cached value still good?_
19
+ → `"use cache"` (fn/component), `cache()` (segment), loader `cache()` (loader data).
20
+ Entries are coupled across the one store by **tags** (`revalidateTag`).
21
+ 2. **Client-update selection** — _should this segment re-run and stream to the
22
+ client on this navigation/action?_
23
+ → `revalidate()`. Covered in `/loader` and `/route`, **not here**.
24
+
25
+ They are orthogonal and compose: a segment selected by `revalidate()` still
26
+ consults its cache (hit → no recompute); a cache bust does **not** force a client
27
+ update, and `revalidate()` never reads, writes, or expires a cached value. If you
28
+ know React Router, `revalidate()` is `shouldRevalidate`, not `Cache-Control`. See
29
+ `/rango` → "Coming from another framework" for the cross-framework mapping.
30
+
31
+ ## Correctness & invalidation
32
+
33
+ rango's caches are built so a hit can't serve wrong or stale-shaped data. These
34
+ guarantees are mostly automatic — worth knowing so you don't reimplement
35
+ protection the framework already gives you (or assume one it deliberately
36
+ doesn't).
37
+
38
+ ### Cross-deploy safety: version-segmented store keys
39
+
40
+ `CFCacheStore` prefixes every **physical** store key (the CF Cache API URL and
41
+ the KV key) with the build version — auto-generated from the
42
+ `@rangojs/router:version` virtual module, overridable via the store's `version`
43
+ option. A new deploy reads under a new prefix, so it can **never** read a
44
+ previous build's entries: no cross-deploy shape drift, and no dead client-chunk
45
+ references baked into cached RSC.
46
+
47
+ The tradeoff to know: **loader/data caches use the same store**, so they're
48
+ version-segmented too. Every deploy is therefore a _cold data cache_ — SWR can't
49
+ soften it, because no stale entry exists under the new key. For high-traffic,
50
+ frequently-deploying, data-bound apps that's a deploy-time origin warm-up. Decide
51
+ deliberately: accept it (correctness over hit-rate), or split the policy — let
52
+ the render/edge cache auto-version while a separate data store gets a stable
53
+ `version` so its entries survive deploys. (Per-process stores like
54
+ `MemorySegmentCacheStore` are cold on every restart anyway; this matters for
55
+ persistent stores.) See `/caching` for store setup.
56
+
57
+ ### Client cache: forward/back is mutation-aware
58
+
59
+ The browser keeps a history (forward/back) cache of rendered segments. Any
60
+ client-side mutation (a server action) marks those entries **stale** and
61
+ broadcasts it to other tabs. On back/forward (popstate) the router looks up the
62
+ entry, sees it's stale, and revalidates — so your `revalidate()` predicates re-run
63
+ and the segment refreshes (SWR: the stale view paints instantly, fresh data
64
+ streams in). It's the client-side analog of the server-cache correctness problem,
65
+ solved on the partial-render axis.
66
+
67
+ ### Request-scoped data: the `cache: false` taint
68
+
69
+ `createVar({ cache: false })` (or a `ctx.set(var, v, { cache: false })` write)
70
+ taints a value as request-scoped; reading it **directly** with `ctx.get()` inside
71
+ a `cache()` boundary throws — the guard against the catastrophic "serve user A's
72
+ data to user B" bug. The guarantee is precise and intentionally narrow — see
73
+ "Context Variable Cache Safety" below for exactly what it does and does not catch.
74
+
75
+ ## Stale-while-revalidate
76
+
77
+ SWR is a first-class cache behavior when the backing store supports it: while an
78
+ entry is within its SWR window the cache serves the **stale value instantly** and
79
+ refreshes it in the **background** (`waitUntil`), so users never wait on a
80
+ recompute for a merely-aging entry.
81
+
82
+ - **`"use cache"`** resolves to the `default` profile `{ ttl: 900, swr: 1800 }`,
83
+ so function/component caching gets a 30-minute SWR window **out of the box**.
84
+ Tune or add profiles via `createRouter({ cacheProfiles: { … } })`
85
+ (`"use cache: short"` → the `short` profile).
86
+ - **`cache()` DSL and loader caches** take an explicit `swr` in seconds (or
87
+ inherit `store.defaults.swr`): `cache({ ttl: 60, swr: 300 })` → fresh ≤60s,
88
+ stale-served 60–360s, miss after 360s in stores that implement SWR for that
89
+ layer.
90
+ - **Client forward/back** is SWR after a mutation — see "Correctness &
91
+ invalidation" → Client cache.
92
+ - **Edge / document layer** uses the HTTP `stale-while-revalidate` directive; see
93
+ `/document-cache`.
94
+
95
+ SWR softens normal TTL expiry, **not** a cross-deploy cold cache — a new build
96
+ has no stale entry to serve (see version-segmented store keys above).
97
+
98
+ Store support is layer-specific. `CFCacheStore` supports SWR for segment,
99
+ document/response, and `"use cache"` item entries. `MemorySegmentCacheStore`
100
+ supports SWR for response and `"use cache"` item entries, but its route-segment
101
+ entries expire at TTL and never background-revalidate. Use the memory store for
102
+ local/dev behavior, not as proof that segment SWR is active.
103
+
12
104
  ## Key Differences
13
105
 
14
106
  | | `cache()` DSL | `"use cache"` directive |
@@ -18,7 +110,7 @@ invalidation. They differ in scope, cache key, execution model, and runtime cont
18
110
  | **Cache key** | Request type + pathname + params (+ optional custom) | Function identity + serialized non-tainted args |
19
111
  | **Execution on hit** | All-or-nothing: entire handler skipped | Partial: function body skipped, calling code runs |
20
112
  | **Runtime control** | `condition` to disable, custom `key` function | None — if the directive is present, it caches |
21
- | **Side effects** | No guards needed handler doesn't run on hit | `ctx.header()`, `ctx.set()`, etc. throw at runtime |
113
+ | **Side effects** | Response side effects throw inside the boundary | `ctx.header()`, `ctx.set()`, etc. throw at runtime |
22
114
  | **Handle data** | Captured and replayed | Captured and replayed |
23
115
  | **Loaders** | Always fresh — excluded from cache, opt-in per loader | Can be used inside loaders |
24
116
  | **Nesting** | Nest `cache()` boundaries with different TTLs | Compose by calling cached functions from uncached |
@@ -144,13 +236,38 @@ On cache hit for the route, the handler doesn't run and `getProductData` is neve
144
236
  called. On cache miss, the handler runs and `getProductData` may itself return a
145
237
  cached value from a previous call with the same slug.
146
238
 
239
+ ### Nesting rule: the outer window bounds the inner
240
+
241
+ A cache's window bounds everything rendered inside it (loaders excepted). An
242
+ inner shorter TTL only takes effect when the **enclosing** cache recomputes — it
243
+ does **not** keep a value fresher than its parent:
244
+
245
+ - Outer `cache()` **fresh hit** → the subtree is served from stored RSC, so inner
246
+ `"use cache"` functions are **not consulted** (frozen at the outer's age — no
247
+ code inside the boundary runs on a hit).
248
+ - Outer **miss / SWR revalidation** → inner caches are consulted, each per its own
249
+ ttl/swr. With SWR on the outer, a stale subtree serves instantly and refreshes
250
+ in the background, so under traffic it keeps refreshing rather than rotting to
251
+ the worst case.
252
+ - **Loaders are the exception** — excluded from the segment cache, re-resolved
253
+ live even on an outer hit.
254
+
255
+ So `"use cache: short"` (60s) inside `cache({ ttl: 600 })` yields ~600s freshness
256
+ on hits, **not** 60s. This is not a bug: setting `cache({ ttl: 600 })` declares
257
+ "this subtree may be ~600s stale." **If a value must be fresher than its
258
+ enclosing segment, put it in a loader** (always live). `debugPerformance` prints
259
+ cache hits per layer, so the actual per-request behavior is observable.
260
+
147
261
  ## Headers and Cookies
148
262
 
149
263
  Neither mechanism caches response headers or cookies.
150
264
 
151
- - **cache()**: Headers set by handlers are naturally absent on hit because no
152
- handler runs. If you need headers on every response, set them in middleware
153
- (which runs before cache lookup).
265
+ - **cache()**: Response-level side effects throw inside the cache boundary even
266
+ on a miss: `ctx.header()`, `ctx.setCookie()`, `ctx.deleteCookie()`,
267
+ `ctx.setStatus()`, `ctx.onResponse()`, and direct `ctx.headers` mutation. On a
268
+ hit the handler would be skipped, so allowing the write on a miss would produce
269
+ inconsistent responses. If you need headers or cookies on every response, set
270
+ them in middleware or a live segment outside the cache boundary.
154
271
  - **"use cache"**: cookies() and headers() throw inside the cached function
155
272
  (both reads and writes). ctx.header() also throws. Move them outside.
156
273
 
@@ -165,8 +282,9 @@ middleware(async (ctx, next) => {
165
282
  ## Context Variable Cache Safety
166
283
 
167
284
  Context variables created with `createVar()` are cacheable by default and can
168
- be read freely inside `cache()` and `"use cache"` scopes. Non-cacheable vars
169
- throw at read time to prevent request-specific data from being captured.
285
+ be read freely inside cached scopes. A non-cacheable var throws when read
286
+ **directly** with `ctx.get()` inside a `cache()` boundary where the value would
287
+ otherwise be serialized into the stored segment.
170
288
 
171
289
  There are two ways to mark a value as non-cacheable:
172
290
 
@@ -181,19 +299,65 @@ ctx.set(Theme, derivedTheme, { cache: false });
181
299
  "Least cacheable wins": if either the var definition or the `ctx.set()` call
182
300
  specifies `cache: false`, the value is non-cacheable.
183
301
 
184
- **Behavior inside cache scopes:**
302
+ **Behavior inside a `cache()` boundary:**
185
303
 
186
- | Operation | Inside `cache()` / `"use cache"` |
187
- | ----------------------------------- | -------------------------------- |
188
- | `ctx.get(cacheableVar)` | Allowed |
189
- | `ctx.get(nonCacheableVar)` | Throws |
190
- | `ctx.set(var, value)` (cacheable) | Allowed |
191
- | `ctx.header()`, `ctx.cookie()`, etc | Throws (response side effects) |
304
+ | Operation | Inside a `cache()` boundary |
305
+ | --------------------------------- | -------------------------------------------------- |
306
+ | `ctx.get(cacheableVar)` | Allowed |
307
+ | `ctx.get(nonCacheableVar)` | Throws (would be baked in) |
308
+ | `ctx.set(var, value)` (cacheable) | Allowed |
309
+ | `ctx.header()` / cookie writes | Throws (response side effect would be lost on hit) |
310
+
311
+ (Inside a `"use cache"` function the scoping differs: `ctx.set`, `ctx.header()`,
312
+ `cookies()`, and `headers()` **throw** via the cache-exec guard. Inside
313
+ `cache()`, response side effects throw via the cache-boundary guard, while the
314
+ `ctx.get(nonCacheableVar)` taint check above is tied to the `cache()` boundary —
315
+ see "Headers and Cookies" and the precise guarantee below.)
192
316
 
193
317
  Write is dumb — `ctx.set()` stores the cache metadata but does not enforce.
194
318
  Enforcement happens at read time (`ctx.get()`), where ALS detects the cache
195
319
  scope and rejects non-cacheable reads.
196
320
 
321
+ ### The guarantee is precise — a direct read inside `cache()`, not propagating
322
+
323
+ The guard fires on a **direct** `ctx.get(taintedVar)` **inside a `cache()`
324
+ boundary** (the scope `isInsideCacheScope` detects). The taint lives on the
325
+ variable; a value **derived** from it and read **outside** the boundary is not
326
+ tracked:
327
+
328
+ ```typescript
329
+ // CAUGHT — direct read of a tainted var inside a cache() boundary
330
+ cache({ ttl: 60 }, () => [
331
+ path("/dashboard", (ctx) => {
332
+ const user = ctx.get(User); // throws: non-cacheable read inside cache()
333
+ return <Dashboard user={user} />;
334
+ }, { name: "dashboard" }),
335
+ ]);
336
+
337
+ // NOT CAUGHT — read outside the boundary, derived value cached
338
+ layout((ctx) => {
339
+ const name = ctx.get(User).name; // allowed — this layout is not cached
340
+ ctx.set(UserName, name); // now a plain (cacheable) string
341
+ return <Outlet />;
342
+ }, () => [
343
+ cache({ ttl: 60 }, () => [
344
+ // a child reads ctx.get(UserName) and silently caches user-derived data
345
+ ]),
346
+ ]);
347
+ ```
348
+
349
+ So do **not** read this as "you can't cache user data" — that overstates it and
350
+ breeds the false confidence that makes the derived leak _more_ likely. The guard
351
+ is deliberately non-propagating (propagation would cost a wrapper per derivation
352
+ on the hot path), and it is scoped to the `cache()` segment boundary — `"use
353
+ cache"` functions guard request data differently (tainted `ctx`/`env`/`req` args
354
+ are excluded from the cache key, and `cookies()` / `headers()` throw inside them;
355
+ see "Headers and Cookies"). The pattern that stays safe is also the natural one:
356
+ **read tainted context at the point of use, in the path that needs it (a loader or
357
+ live segment) — never extract user data into a plain value and cache that.**
358
+ Loaders are exempt because they run outside the cache scope and resolve fresh
359
+ every request.
360
+
197
361
  ## Loaders Are Always Fresh
198
362
 
199
363
  Loaders are **never cached** by route-level `cache()`. Even on a full cache hit
@@ -245,7 +245,7 @@ export const urlpatterns = urls(({ path, layout, cache, loader, revalidate }) =>
245
245
  path("/shop/product/:slug", ProductPage, { name: "product" }, () => [
246
246
  loader(ProductLoader, () => [cache({ ttl: 120 })]),
247
247
  loader(CartLoader, () => [
248
- revalidate(({ actionId }) => actionId?.includes("Cart") ?? false),
248
+ revalidate(({ actionId }) => actionId?.includes("Cart") || undefined),
249
249
  ]),
250
250
  ]),
251
251
  ]),
@@ -55,7 +55,9 @@ import { cache, revalidate, loading, errorBoundary, middleware } from "@rangojs/
55
55
  // Shared caching configuration
56
56
  const withCaching = () => [
57
57
  cache({ ttl: 600_000 }),
58
- revalidate(({ actionId }) => !!actionId),
58
+ // Defer on navigation (|| undefined) so each route keeps its own param/search
59
+ // revalidation default; only force a re-run when an action ran.
60
+ revalidate(({ actionId }) => (actionId ? true : undefined)),
59
61
  ];
60
62
 
61
63
  // Shared loading and error handling
@@ -71,6 +73,29 @@ const withAuth = () => [
71
73
  ];
72
74
  ```
73
75
 
76
+ > **Factories compose logic, not just values.** A `revalidate()` predicate in a
77
+ > shared factory applies its logic to _every_ route that composes it, so a
78
+ > footgun here is amplified across the app. Two rules:
79
+ >
80
+ > 1. Use `|| undefined` (defer), not `?? false` (hard short-circuit), in shared
81
+ > predicates — a hard `false` ends the chain and overrides each consuming
82
+ > route's own default, and a downstream revalidator never runs. See `/loader`
83
+ > → "`|| undefined` (defer) vs `?? false` (hard)".
84
+ > 2. Match actions with `ctx.isAction(Action)`, not an inline
85
+ > `actionId.includes("…")` buried in a factory: it resolves the action from an
86
+ > imported reference, so a rename is a compile error in one place instead of
87
+ > silent drift across every consumer.
88
+ >
89
+ > Remember the axis: a factory's `revalidate()` controls client-update
90
+ > selection, while its `cache()` controls stored-value freshness. They are
91
+ > independent even when bundled in the same factory (`/cache-guide` → "Two axes").
92
+
93
+ > **Keep factories small and intention-named.** The anti-pattern that kills
94
+ > readability is over-bundling — a `withDefaults()` that secretly adds five
95
+ > things — and factory-of-factories nesting (leaning on `.flat(3)`). Surprising
96
+ > config stays inline; extract only the boring, repeated parts; compose by
97
+ > _naming concerns_ (`withAuth()`, `withCaching()`), not by hiding them.
98
+
74
99
  ## Using Factories in Routes
75
100
 
76
101
  Place factory calls inside `path()` or `layout()` use callbacks. The returned arrays are flattened automatically (up to 3 levels):
@@ -107,7 +132,7 @@ import { authMiddleware } from "./middleware/auth";
107
132
 
108
133
  export const withPublicDefaults = () => [
109
134
  cache({ ttl: 300 }),
110
- revalidate(({ actionId }) => !!actionId),
135
+ revalidate(({ actionId }) => (actionId ? true : undefined)),
111
136
  ];
112
137
 
113
138
  export const withProtectedDefaults = () => [
@@ -8,9 +8,6 @@ argument-hint: [@slot-name] [route-to-intercept]
8
8
 
9
9
  Intercept routes render a different component during soft navigation (client-side) while preserving the background route. Hard navigation (direct URL) shows the full page.
10
10
 
11
- Canonical semantics reference:
12
- [docs/execution-model.md](../../docs/internal/execution-model.md)
13
-
14
11
  ## Basic Intercept
15
12
 
16
13
  ```typescript
@@ -111,7 +108,7 @@ consumer when they share `ctx.set()` data:
111
108
 
112
109
  ```typescript
113
110
  export const revalidateProductShell = ({ actionId }) =>
114
- actionId?.includes("src/actions/product.ts#") ?? false;
111
+ actionId?.includes("src/actions/product.ts#") || undefined;
115
112
 
116
113
  layout(ProductLayout, () => [
117
114
  revalidate(revalidateProductShell), // producer reruns
@@ -8,9 +8,6 @@ argument-hint: [component]
8
8
 
9
9
  Layouts wrap child routes and persist during navigation within their scope.
10
10
 
11
- Canonical semantics reference:
12
- [docs/execution-model.md](../../docs/internal/execution-model.md)
13
-
14
11
  ## Basic Layout
15
12
 
16
13
  ```typescript
@@ -206,7 +203,7 @@ layout(<ShopLayout />, () => [
206
203
 
207
204
  // Or revalidate based on conditions
208
205
  layout(<CartLayout />, () => [
209
- revalidate(({ actionId }) => actionId?.includes("Cart") ?? false),
206
+ revalidate(({ actionId }) => actionId?.includes("Cart") || undefined),
210
207
 
211
208
  path("/cart", CartPage, { name: "cart" }),
212
209
  ])
@@ -225,7 +222,7 @@ them on both producer and consumer segments:
225
222
  ```typescript
226
223
  // revalidation-contracts.ts
227
224
  export const revalidateCartData = ({ actionId }) =>
228
- actionId?.includes("src/actions/cart.ts#addToCart") ?? false;
225
+ actionId?.includes("src/actions/cart.ts#addToCart") || undefined;
229
226
  ```
230
227
 
231
228
  ```typescript
@@ -247,7 +244,7 @@ You can also package them as importable handoff helpers:
247
244
  import { revalidate } from "@rangojs/router";
248
245
 
249
246
  export const revalidateAuthData = ({ actionId }) =>
250
- actionId?.includes("src/actions/auth.ts#") ?? false;
247
+ actionId?.includes("src/actions/auth.ts#") || undefined;
251
248
  export const revalidateAuth = () => [revalidate(revalidateAuthData)];
252
249
  ```
253
250
 
@@ -294,7 +291,7 @@ export const shopPatterns = urls(({ path, layout, parallel, loader, revalidate }
294
291
  }, () => [
295
292
  // Layout loaders
296
293
  loader(CartLoader, () => [
297
- revalidate(({ actionId }) => actionId?.includes("Cart") ?? false),
294
+ revalidate(({ actionId }) => actionId?.includes("Cart") || undefined),
298
295
  ]),
299
296
 
300
297
  // Parallel routes
@@ -244,15 +244,23 @@ path("/product/:slug", ProductPage, { name: "product" }, () => [
244
244
  revalidate(() => false), // Never revalidate
245
245
  ]),
246
246
 
247
- // Loader that revalidates after cart actions
247
+ // Loader that revalidates after cart actions (defer otherwise — keeps the
248
+ // permissive loader defaults for navigation and other actions intact)
248
249
  loader(CartLoader, () => [
249
- revalidate(({ actionId }) => actionId?.includes("Cart") ?? false),
250
+ revalidate(({ actionId }) => actionId?.includes("Cart") || undefined),
250
251
  ]),
251
252
  ]);
252
253
  ```
253
254
 
254
255
  ### `revalidate()` return shapes
255
256
 
257
+ > **Scope: `revalidate()` is a partial-render concern, not a cache concern.**
258
+ > It decides whether a segment (here, a loader) re-runs and streams to the
259
+ > client on a navigation or action — never whether a cached value is stale. The
260
+ > cache decides hit/miss/ttl/swr independently and never reads `revalidate()`.
261
+ > Caching a loader is a separate, opt-in step (`loader(Fn, () => [cache({...})])`).
262
+ > See `/cache-guide` → "Two axes" and `/rango` → "The shape of rango".
263
+
256
264
  A `revalidate(fn)` callback can return one of four shapes. The chain
257
265
  processes revalidators in order; each call's return controls how the
258
266
  chain continues:
@@ -282,6 +290,58 @@ revalidate(() => null); // explicit defer
282
290
  If every revalidator on a segment defers, the segment-type default
283
291
  (e.g. params-changed for routes, `false` for parallels) is used.
284
292
 
293
+ #### `|| undefined` (defer) vs `?? false` (hard) — pick deliberately
294
+
295
+ A boolean return — including `false` — is a **hard** decision: it short-circuits
296
+ the chain and overrides the segment default. `undefined` **defers** to the
297
+ running suggestion / segment default. They are not interchangeable:
298
+
299
+ ```typescript
300
+ // Defer: "revalidate on match, otherwise let the default/downstream decide."
301
+ revalidate(({ actionId }) => actionId?.includes("Cart") || undefined);
302
+
303
+ // Hard: "revalidate ONLY on match, suppress everything else."
304
+ revalidate(({ actionId }) => actionId?.includes("Cart") ?? false);
305
+ ```
306
+
307
+ This matters most for loaders, whose defaults are permissive: a loader defaults
308
+ to revalidating on **any** action (`POST`) and on **param/search changes**
309
+ during navigation. So `?? false` on a loader silently suppresses both — the
310
+ loader will not refetch when you navigate to a different `:id`. Use
311
+ `|| undefined` when you want to _add_ a revalidation signal on top of the
312
+ sensible defaults, and reserve `?? false` for the rare case where you genuinely
313
+ want the loader to refetch on nothing but your matched action.
314
+
315
+ When **composing multiple revalidators** on one segment (see below), defer is
316
+ mandatory: the first hard `?? false` ends the chain and the later contracts
317
+ never run.
318
+
319
+ #### Matching actions: `ctx.isAction()`
320
+
321
+ To revalidate after specific server actions, match them by **reference** with
322
+ `ctx.isAction()` rather than hand-written `actionId` substrings. A rename or
323
+ moved file then becomes a type error instead of silently failing to match:
324
+
325
+ ```typescript
326
+ import { addToCart, removeFromCart } from "../actions/cart";
327
+ import * as CartActions from "../actions/cart";
328
+
329
+ loader(CartLoader, () => [
330
+ revalidate((ctx) => ctx.isAction(addToCart) || undefined), // one action
331
+ ]);
332
+ revalidate((ctx) => ctx.isAction(addToCart, removeFromCart) || undefined); // several
333
+ revalidate((ctx) => ctx.isAction(CartActions) || undefined); // any action in the module
334
+ ```
335
+
336
+ `isAction()` is a method on the revalidate predicate's **context argument** —
337
+ there is no standalone `isAction` import; you always reach it through the callback
338
+ parameter (`revalidate((ctx) => ctx.isAction(...))`). It returns a raw boolean, so
339
+ pair it with `|| undefined` for the usual "revalidate on match, else defer"
340
+ intent. It returns `false` on plain navigation and on non-matches, and resolves
341
+ the reference the same way the router derives `actionId` (`$id` in production,
342
+ `$$id` in dev), so it matches in both modes. The raw `actionId` string stays
343
+ available on the same context as an escape hatch.
344
+
285
345
  ### Revalidation Contracts for Loader Dependencies
286
346
 
287
347
  If a loader reads `ctx.get()` data produced by an outer handler/layout, share
@@ -289,8 +349,12 @@ the same named revalidation contract across producer and consumer segments.
289
349
 
290
350
  ```typescript
291
351
  // revalidation-contracts.ts
292
- export const revalidateAccountScope = ({ actionId }) =>
293
- actionId?.includes("src/actions/account.ts#") ?? false;
352
+ import * as AccountActions from "./actions/account";
353
+
354
+ // Match by reference with ctx.isAction() (rename-safe), and defer (|| undefined)
355
+ // so these contracts compose — a hard `false` would short-circuit the rest.
356
+ export const revalidateAccountScope = (ctx) =>
357
+ ctx.isAction(AccountActions) || undefined;
294
358
 
295
359
  layout(AccountLayout, () => [
296
360
  revalidate(revalidateAccountScope), // producer reruns
@@ -333,6 +397,64 @@ follows the same rule: at build time, loaders are skipped entirely (there is no
333
397
  real request context), and at runtime the worker resolves them fresh against
334
398
  the live database.
335
399
 
400
+ ### Parallel and streaming — latency overlaps first paint
401
+
402
+ Loaders do not block the page. As the render pass begins — the pass that route
403
+ middleware wraps, so loaders run right after middleware, not in a later
404
+ phase — every matched loader is kicked off **concurrently** (their promises start in the
405
+ same tick), and each result is **streamed** to the client as its own RSC Flight
406
+ chunk rather than awaited up front. Pair a loader with `loading()` (or a
407
+ client `<Suspense>`) and the shell paints immediately while the data streams in.
408
+
409
+ This is why **"cached UI still pays full data latency" is the wrong intuition**:
410
+ on a `cache()` hit the UI segments stream instantly from cache while the live
411
+ loaders resolve fresh **in parallel** — data latency _overlaps_ first paint
412
+ instead of being added on top of it. (Without a `loading()` / `<Suspense>`
413
+ boundary a parallel loader blocks its parent, so add one to keep the overlap.)
414
+
415
+ If you come from a framework where the loader is a blocking step that runs
416
+ before the response is built, this is the shift to internalize: here the
417
+ response starts streaming first and loader data fills in.
418
+
419
+ ### See it: `debugPerformance`
420
+
421
+ Turn on the per-request performance timeline early — it is the fastest way to
422
+ confirm loaders overlap rather than serialize, and to find the real bottleneck
423
+ locally instead of guessing:
424
+
425
+ ```typescript
426
+ const router = createRouter({ document: Document, debugPerformance: true });
427
+ ```
428
+
429
+ Or enable it per-request from middleware (e.g. only when `?debug` is present) by
430
+ calling `ctx.debugPerformance()` **before** `await next()`. Each HTML request
431
+ then prints a shared-axis waterfall (and emits a `Server-Timing` header):
432
+
433
+ ```
434
+ [RSC Perf] GET /product/widget (24.53ms)
435
+ start dur span timeline
436
+ 0.08ms 3.20ms route-matching |#####...................................|
437
+ 3.40ms 8.70ms ssr-render-html |.....##############.....................|
438
+ 3.42ms 11.90ms loader:…#ProductLoader |.....###################................|
439
+ 3.45ms 11.40ms loader:…#ReviewsLoader |.....##################.................|
440
+ 0.00ms 24.53ms handler:total |########################################|
441
+ ```
442
+
443
+ How to read it:
444
+
445
+ - **Humans:** scan the `#` bars on the shared axis. Bars that start at the same
446
+ offset and run side by side are executing **in parallel** — loaders should
447
+ overlap `ssr-render-html` / `render:total`, not sit alone to the right of
448
+ everything. A lone `loader:*` bar past the render bar is serialized latency to
449
+ chase. `handler:total` is the whole request; `render:total` is the render pass.
450
+ - **LLMs / programmatic:** read each row as `{ start, dur, label }`. A loader
451
+ overlaps paint when its `[start, start+dur]` interval intersects
452
+ `render:total` / `ssr-render-html`. Flag a regression when a `loader:*`
453
+ interval is **disjoint from and starts after** `render:total`, or when its
454
+ `dur` approaches `handler:total` — that loader is on the critical path instead
455
+ of overlapping it. Two `loader:*` rows with near-equal `start` confirm
456
+ parallel execution.
457
+
336
458
  ### Opting a Loader into Caching
337
459
 
338
460
  To cache a specific loader's data, attach a `cache()` child:
@@ -648,7 +770,7 @@ export const CartLoader = createLoader(async (ctx) => {
648
770
  export const urlpatterns = urls(({ path, layout, loader, loading, cache, revalidate }) => [
649
771
  layout(<ShopLayout />, () => [
650
772
  loader(CartLoader, () => [
651
- revalidate(({ actionId }) => actionId?.includes("Cart") ?? false),
773
+ revalidate(({ actionId }) => actionId?.includes("Cart") || undefined),
652
774
  ]),
653
775
 
654
776
  path("/shop/product/:slug", ProductPage, { name: "product" }, () => [
@@ -10,9 +10,6 @@ Middleware runs before/after route handlers using the onion model.
10
10
 
11
11
  ## Execution Model
12
12
 
13
- Canonical semantics reference:
14
- [docs/execution-model.md](../../docs/internal/execution-model.md)
15
-
16
13
  There are two levels of middleware with different execution scopes:
17
14
 
18
15
  ### Global middleware (`router.use()`)
@@ -36,15 +33,22 @@ Registered inside `urls()` callback. Wraps **rendering only** -- it does NOT wra
36
33
 
37
34
  ```
38
35
  Request flow (with action):
39
- global mw -> action executes -> route mw -> layout -> handler -> loaders
36
+ global mw -> action executes -> route mw -> render pass
40
37
 
41
38
  Request flow (no action):
42
- global mw -> route mw -> layout -> handler -> loaders
39
+ global mw -> route mw -> render pass
43
40
 
44
41
  Progressive enhancement (no-JS form POST):
45
42
  global mw -> action executes -> route mw -> full page re-render
46
43
  ```
47
44
 
45
+ The **render pass** resolves handler, layouts, parallels, and loaders together —
46
+ it is not a handler-then-loaders sequence. Handler-first ordering is guaranteed
47
+ only between a route handler and its child/orphan layouts and parallels (so
48
+ `ctx.set` is visible); loaders run **concurrently** and stream their results, so
49
+ their latency overlaps rendering rather than blocking it. See `/loader` →
50
+ "Parallel and streaming".
51
+
48
52
  The contract is: **route middleware wraps rendering regardless of transport** (JS-enabled RSC stream or no-JS HTML). During PE re-render, route middleware observes action-set state (cookies, context variables) the same way it does during JS-enabled post-action revalidation.
49
53
 
50
54
  Revalidation is still partial. Route middleware wraps the render pass that
@@ -64,7 +68,7 @@ and consumer segments, even when middleware is present in the chain.
64
68
 
65
69
  ```typescript
66
70
  export const revalidateCartData = ({ actionId }) =>
67
- actionId?.includes("src/actions/cart.ts#") ?? false;
71
+ actionId?.includes("src/actions/cart.ts#") || undefined;
68
72
 
69
73
  layout(CartLayout, () => [
70
74
  middleware(cartRenderMiddleware),
@@ -302,7 +302,7 @@ re-rendering. This is about the segment tree, not cache invalidation:
302
302
  ```typescript
303
303
  // Re-run this layout when a blog action fires
304
304
  layout(BlogLayout, () => [
305
- revalidate(({ actionId }) => actionId?.includes("updateBlog") ?? false),
305
+ revalidate(({ actionId }) => actionId?.includes("updateBlog") || undefined),
306
306
  path("/blog/:slug", BlogPost, { name: "blogPost" }),
307
307
  ]);
308
308