@rangojs/router 0.0.0-experimental.104 → 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.
- package/dist/vite/index.js +9 -4
- package/package.json +1 -1
- package/skills/cache-guide/SKILL.md +177 -13
- package/skills/caching/SKILL.md +1 -1
- package/skills/composability/SKILL.md +27 -2
- package/skills/intercept/SKILL.md +1 -4
- package/skills/layout/SKILL.md +4 -7
- package/skills/loader/SKILL.md +127 -5
- package/skills/middleware/SKILL.md +10 -6
- package/skills/migrate-nextjs/SKILL.md +1 -1
- package/skills/observability/SKILL.md +135 -0
- package/skills/parallel/SKILL.md +3 -6
- package/skills/prerender/SKILL.md +1 -20
- package/skills/rango/SKILL.md +201 -27
- package/skills/route/SKILL.md +9 -4
- package/skills/server-actions/SKILL.md +53 -41
- package/skills/typesafety/SKILL.md +42 -0
- package/src/context-var.ts +5 -5
- package/src/index.rsc.ts +1 -0
- package/src/index.ts +1 -0
- package/src/route-definition/helpers-types.ts +4 -2
- package/src/router/revalidation.ts +43 -1
- package/src/types/handler-context.ts +48 -6
- package/src/types/index.ts +1 -0
- package/src/vite/utils/manifest-utils.ts +21 -5
package/dist/vite/index.js
CHANGED
|
@@ -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.
|
|
2043
|
+
version: "0.0.0-experimental.106",
|
|
2044
2044
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
2045
2045
|
keywords: [
|
|
2046
2046
|
"react",
|
|
@@ -3842,9 +3842,12 @@ function checkSelfGenWrite(state, filePath, consume) {
|
|
|
3842
3842
|
|
|
3843
3843
|
// src/vite/utils/manifest-utils.ts
|
|
3844
3844
|
function flattenLeafEntries(prefixTree, routeManifest, result) {
|
|
3845
|
-
function visit(node) {
|
|
3845
|
+
function visit(node, ancestorStaticPrefixes) {
|
|
3846
3846
|
const children = node.children || {};
|
|
3847
3847
|
if (Object.keys(children).length === 0 && node.routes && node.routes.length > 0) {
|
|
3848
|
+
if (ancestorStaticPrefixes.has(node.staticPrefix)) {
|
|
3849
|
+
return;
|
|
3850
|
+
}
|
|
3848
3851
|
const routes = {};
|
|
3849
3852
|
for (const name of node.routes) {
|
|
3850
3853
|
if (name in routeManifest) {
|
|
@@ -3853,13 +3856,15 @@ function flattenLeafEntries(prefixTree, routeManifest, result) {
|
|
|
3853
3856
|
}
|
|
3854
3857
|
result.push({ staticPrefix: node.staticPrefix, routes });
|
|
3855
3858
|
} else {
|
|
3859
|
+
const nextAncestors = new Set(ancestorStaticPrefixes);
|
|
3860
|
+
nextAncestors.add(node.staticPrefix);
|
|
3856
3861
|
for (const child of Object.values(children)) {
|
|
3857
|
-
visit(child);
|
|
3862
|
+
visit(child, nextAncestors);
|
|
3858
3863
|
}
|
|
3859
3864
|
}
|
|
3860
3865
|
}
|
|
3861
3866
|
for (const node of Object.values(prefixTree)) {
|
|
3862
|
-
visit(node);
|
|
3867
|
+
visit(node, /* @__PURE__ */ new Set());
|
|
3863
3868
|
}
|
|
3864
3869
|
}
|
|
3865
3870
|
function buildRouteToStaticPrefix(prefixTree, result) {
|
package/package.json
CHANGED
|
@@ -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** |
|
|
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()**:
|
|
152
|
-
|
|
153
|
-
(
|
|
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
|
|
169
|
-
|
|
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
|
|
302
|
+
**Behavior inside a `cache()` boundary:**
|
|
185
303
|
|
|
186
|
-
| Operation
|
|
187
|
-
|
|
|
188
|
-
| `ctx.get(cacheableVar)`
|
|
189
|
-
| `ctx.get(nonCacheableVar)`
|
|
190
|
-
| `ctx.set(var, value)` (cacheable)
|
|
191
|
-
| `ctx.header()
|
|
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
|
package/skills/caching/SKILL.md
CHANGED
|
@@ -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")
|
|
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
|
-
|
|
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 }) =>
|
|
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#")
|
|
111
|
+
actionId?.includes("src/actions/product.ts#") || undefined;
|
|
115
112
|
|
|
116
113
|
layout(ProductLayout, () => [
|
|
117
114
|
revalidate(revalidateProductShell), // producer reruns
|
package/skills/layout/SKILL.md
CHANGED
|
@@ -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")
|
|
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")
|
|
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#")
|
|
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")
|
|
294
|
+
revalidate(({ actionId }) => actionId?.includes("Cart") || undefined),
|
|
298
295
|
]),
|
|
299
296
|
|
|
300
297
|
// Parallel routes
|
package/skills/loader/SKILL.md
CHANGED
|
@@ -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")
|
|
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
|
-
|
|
293
|
-
|
|
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")
|
|
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 ->
|
|
36
|
+
global mw -> action executes -> route mw -> render pass
|
|
40
37
|
|
|
41
38
|
Request flow (no action):
|
|
42
|
-
global mw -> route mw ->
|
|
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#")
|
|
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")
|
|
305
|
+
revalidate(({ actionId }) => actionId?.includes("updateBlog") || undefined),
|
|
306
306
|
path("/blog/:slug", BlogPost, { name: "blogPost" }),
|
|
307
307
|
]);
|
|
308
308
|
|