@rangojs/router 0.0.0-experimental.142 → 0.0.0-experimental.144
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 +25 -6
- package/package.json +4 -2
- package/skills/cache-guide/SKILL.md +3 -1
- package/skills/caching/SKILL.md +41 -2
- package/skills/catalog.json +6 -0
- package/skills/composability/SKILL.md +32 -0
- package/skills/defer-hydration/SKILL.md +235 -0
- package/skills/loader/SKILL.md +5 -0
- package/skills/migrate-nextjs/SKILL.md +4 -2
- package/skills/observability/SKILL.md +8 -0
- package/skills/parallel/SKILL.md +4 -0
- package/skills/ppr/SKILL.md +110 -20
- package/skills/rango/SKILL.md +10 -0
- package/skills/route/SKILL.md +8 -0
- package/skills/typesafety/SKILL.md +1 -0
- package/skills/typesafety/generated-files-and-cli.md +30 -0
- package/skills/use-cache/SKILL.md +12 -2
- package/src/browser/partial-update.ts +7 -0
- package/src/cache/cache-key-utils.ts +29 -0
- package/src/cache/cache-scope.ts +2 -17
- package/src/cache/cache-tag.ts +60 -14
- package/src/cache/cf/cf-cache-store.ts +54 -20
- package/src/cache/document-cache.ts +17 -11
- package/src/cache/vercel/vercel-cache-store.ts +9 -19
- package/src/cloudflare/tracing.ts +7 -8
- package/src/index.rsc.ts +1 -0
- package/src/index.ts +12 -8
- package/src/redirect-origin.ts +14 -0
- package/src/route-definition/helpers-types.ts +5 -4
- package/src/route-map-builder.ts +41 -4
- package/src/router/find-match.ts +15 -1
- package/src/router/instrument.ts +9 -4
- package/src/router/lazy-includes.ts +8 -2
- package/src/router/loader-resolution.ts +14 -2
- package/src/router/match-handlers.ts +175 -133
- package/src/router/middleware.ts +40 -30
- package/src/router/router-interfaces.ts +9 -0
- package/src/router/segment-resolution/loader-snapshot.ts +98 -17
- package/src/router/telemetry-otel.ts +6 -8
- package/src/router/telemetry.ts +9 -1
- package/src/router/tracing.ts +14 -5
- package/src/router.ts +22 -14
- package/src/rsc/handler.ts +55 -32
- package/src/rsc/redirect-guard.ts +2 -1
- package/src/rsc/rsc-rendering.ts +35 -2
- package/src/rsc/shell-capture.ts +98 -20
- package/src/server/context.ts +47 -9
- package/src/server/cookie-store.ts +26 -5
- package/src/server/request-context.ts +22 -0
- package/src/ssr/index.tsx +145 -107
- package/src/testing/dispatch.ts +149 -37
- package/src/urls/path-helper-types.ts +9 -4
- package/src/vercel/tracing.ts +7 -7
- package/src/vite/inject-client-debug.ts +64 -12
- package/src/vite/router-discovery.ts +9 -1
package/skills/ppr/SKILL.md
CHANGED
|
@@ -169,6 +169,31 @@ On a document GET to a ppr route the router runs:
|
|
|
169
169
|
point is after the chain, an unauthorized request NEVER sees shell bytes — put
|
|
170
170
|
auth middleware anywhere (global or route DSL) and it guards PPR for free.
|
|
171
171
|
|
|
172
|
+
## Verifying it works
|
|
173
|
+
|
|
174
|
+
The header exists on DOCUMENT responses only. A bare `curl` (no `Accept`)
|
|
175
|
+
content-negotiates a Flight payload (`text/x-component`) with NO
|
|
176
|
+
`x-rango-shell` header at all — which reads as "PPR is off" but is only the
|
|
177
|
+
wrong request shape:
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
curl -s -D - -o /dev/null -H "Accept: text/html" https://app.example.com/products/1 | grep -i x-rango-shell
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
- First document GET: `MISS`, plus a background capture.
|
|
184
|
+
- Production (workerd/node): the SECOND request is a `HIT`.
|
|
185
|
+
- Dev: expect a few extra MISSes — cold module transforms abort the capture
|
|
186
|
+
window (per-attempt breadcrumbs: start the server with
|
|
187
|
+
`INTERNAL_RANGO_DEBUG=1`). This self-heals; only a route that NEVER flips
|
|
188
|
+
has a real hole/eligibility problem (the once-per-key warning tells the two
|
|
189
|
+
apart).
|
|
190
|
+
- A HIT is one ordinary document: the frozen prelude first (view-source shows
|
|
191
|
+
your baked shell, with hole fallbacks in place), then
|
|
192
|
+
`<div hidden id="S:0">…` segments as the holes resume, per request.
|
|
193
|
+
- A ppr-declared route that CANNOT be honored (missing shell store family,
|
|
194
|
+
per-request nonce) serves plain axis 1 with NO header and warns once per
|
|
195
|
+
key — no header + a declared `ppr` means look for that warning.
|
|
196
|
+
|
|
172
197
|
## The hole doctrine (encode this in your head)
|
|
173
198
|
|
|
174
199
|
Holes are **render-defined**, decided by the shape of the tree, on three rules:
|
|
@@ -230,7 +255,14 @@ The physics caveat in one line: promise holes are holes because the I/O is
|
|
|
230
255
|
genuinely pending at capture. If the value can resolve near-instantly (memory
|
|
231
256
|
read, warmed cache), it may bake into the shell — when liveness must be
|
|
232
257
|
guaranteed rather than probable, use the live lane (`loading()`). The same
|
|
233
|
-
physics governs bake-lane nested promises
|
|
258
|
+
physics governs bake-lane nested promises, with one shape guarantee: a nested
|
|
259
|
+
promise that settles inside the window pins its VALUE, but the container key
|
|
260
|
+
KEEPS its promise shape on HITs (the snapshot rehydrates a
|
|
261
|
+
`Promise.resolve(pinned)`), so an unconditional `use(data.x)` consumer never
|
|
262
|
+
breaks — it just reads the pinned value. Note the timing consequence: whether
|
|
263
|
+
such a value is pinned or live can vary per capture (concurrent loader traffic
|
|
264
|
+
extends the quiet window), so treat "fast-resolving promise on the bake lane"
|
|
265
|
+
as PINNED for correctness purposes.
|
|
234
266
|
|
|
235
267
|
### Handles: "nesting = liveness"
|
|
236
268
|
|
|
@@ -278,7 +310,10 @@ Three hard edges (each e2e/unit-pinned):
|
|
|
278
310
|
throws during capture and the capture REFUSES (deterministic, once-per-key
|
|
279
311
|
warned) — identity can never bake into the shared shell. Give that loader's
|
|
280
312
|
entry `loading()` (the live lane is exempt) or move the identity-dependent
|
|
281
|
-
part into a nested promise.
|
|
313
|
+
part into a nested promise. The guard's scope is EXACTLY those two calls:
|
|
314
|
+
per-user state read from a middleware-provided object (`ctx.get("session")`)
|
|
315
|
+
does NOT refuse — it bakes silently as the capturing user's data (see
|
|
316
|
+
Pitfalls: the session-object bake trap).
|
|
282
317
|
- **A rejecting bake-lane loader refuses.** Error UI never bakes.
|
|
283
318
|
- **Baked containers show CAPTURE-time data** for the shell's lifetime on
|
|
284
319
|
document GETs (client navigations stay fresh — axis 1). That IS the bake
|
|
@@ -316,10 +351,16 @@ stay live. Your levers, in order of preference:
|
|
|
316
351
|
loader(BasketLoader),
|
|
317
352
|
loading(<BadgeSkeleton />), // hole the size of a badge, not a page
|
|
318
353
|
]),
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
354
|
+
// Descriptor form when the slot handler needs ctx (annotate it —
|
|
355
|
+
// StaticHandlerDefinition in the union blocks inference there):
|
|
356
|
+
parallel({
|
|
357
|
+
"@wishlist": {
|
|
358
|
+
handler: (ctx: HandlerContext) => (
|
|
359
|
+
<WishlistBadge listUrl={ctx.reverse("wishlist")} />
|
|
360
|
+
),
|
|
361
|
+
use: () => [loader(WishlistLoader), loading(<BadgeSkeleton />)],
|
|
362
|
+
},
|
|
363
|
+
}),
|
|
323
364
|
path("/", HomePage, { name: "home", ppr: true }),
|
|
324
365
|
]),
|
|
325
366
|
```
|
|
@@ -327,16 +368,27 @@ stay live. Your levers, in order of preference:
|
|
|
327
368
|
Slot-owned loaders are masked at capture and GUARANTEED fresh per serve —
|
|
328
369
|
use this where the bake lane's physics (a fast resolve bakes) or pinning
|
|
329
370
|
(capture-time data for the shell's lifetime) is not acceptable, at the cost
|
|
330
|
-
of a widget-sized fallback in the shell.
|
|
371
|
+
of a widget-sized fallback in the shell. The slot handler must hand the
|
|
372
|
+
loader to a CLIENT component (`useLoader` in a `"use client"` component)
|
|
373
|
+
for the freshness guarantee to reach the rendered value: server-side
|
|
374
|
+
`await ctx.use(...)` in the handler is the BAKED lane (the consumption-lane
|
|
375
|
+
rule, `/rango` → Invariants) — it executes at capture with identity reads
|
|
376
|
+
permitted, but the value it renders is a capture-time copy wherever it is
|
|
377
|
+
not shielded by the slot's masked LoaderBoundary.
|
|
331
378
|
|
|
332
379
|
4. **Shared layout data can also leave the loader lane entirely**: an
|
|
333
380
|
un-awaited handler promise under the consumer's `<Suspense>` (a physics
|
|
334
381
|
hole) or `cache()`/`"use cache"` to bake it with tag-invalidation.
|
|
335
382
|
|
|
336
383
|
The identity rule, stated once: per-user data on a PPR page lives in a NESTED
|
|
337
|
-
promise (a hole, fresh per request) or behind `loading()`
|
|
338
|
-
Reading `cookies()`/`headers()` where the value
|
|
339
|
-
|
|
384
|
+
promise (a hole, fresh per request) or behind `loading()` with CLIENT-side
|
|
385
|
+
consumption (the live lane). Reading `cookies()`/`headers()` where the value
|
|
386
|
+
would bake as SEGMENT material — handler/render code or a bake-lane loader
|
|
387
|
+
container — refuses the capture by construction. The one exemption is
|
|
388
|
+
handler-INVOKED loader bodies (`await ctx.use(loader)`): they execute at
|
|
389
|
+
capture with identity reads permitted, and the value bakes as a shared
|
|
390
|
+
capture-time copy — mirroring `cache()` semantics (the consumption-lane
|
|
391
|
+
rule; semantic-matrix row PPR3).
|
|
340
392
|
|
|
341
393
|
## Execution matrix
|
|
342
394
|
|
|
@@ -387,9 +439,14 @@ PPR-ineligible by construction; the live lane (`loading()`) stays exempt.
|
|
|
387
439
|
**(c) Residual hazard — middleware-derived per-user state.** A `ctx` variable
|
|
388
440
|
set by an upstream auth middleware and rendered by shell material is
|
|
389
441
|
photographed into the SHARED shell (the capture inherits post-middleware
|
|
390
|
-
state). That is scope fidelity working as designed — for shared values.
|
|
391
|
-
|
|
392
|
-
|
|
442
|
+
state). That is scope fidelity working as designed — for shared values. The
|
|
443
|
+
same hazard reaches BAKE-LANE LOADERS: a loader reading a middleware-provided
|
|
444
|
+
session object (`ctx.get("session")`) never calls `cookies()` itself, so the
|
|
445
|
+
guard cannot see it — whatever it returns as settled container data is
|
|
446
|
+
photographed as the CAPTURING user's state. If the
|
|
447
|
+
value is per-user: shell-cache only public/shared pages, keep per-user content
|
|
448
|
+
in nested pending promises or live-lane (`loading()`) loaders — NOT in a
|
|
449
|
+
bake-lane container — or key per variant at the CDN tier.
|
|
393
450
|
|
|
394
451
|
## What always stays on axis 1
|
|
395
452
|
|
|
@@ -459,10 +516,11 @@ multi-tenant shells never collide).
|
|
|
459
516
|
HTML; `revalidate()` is a DATA lever that never touches it.
|
|
460
517
|
|
|
461
518
|
A captured shell auto-carries the UNION of the non-loader tags recorded during
|
|
462
|
-
the capture render — every `cacheTag(...)`
|
|
463
|
-
`cache()` segment
|
|
464
|
-
|
|
465
|
-
|
|
519
|
+
the capture render — every `cacheTag(...)` that ran as shell material, whether
|
|
520
|
+
from a `"use cache"` function, a `cache()` segment, or a render-callable
|
|
521
|
+
`cacheTag()` in a plain server component (no `"use cache"`/`cache()` in its
|
|
522
|
+
tree). Loader tags never attach (the holes are already live). `ppr.tags` adds
|
|
523
|
+
operational tags the render cannot know (a tenant id, a deploy marker).
|
|
466
524
|
|
|
467
525
|
| Lever | Reaches the frozen shell? | Reaches the holes? |
|
|
468
526
|
| --------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------- |
|
|
@@ -470,6 +528,12 @@ know (a tenant id, a deploy marker).
|
|
|
470
528
|
| `updateTag` / `revalidateTag` on a LOADER tag | no — loader tags never attach to a shell | drops that loader's cached value (if it `cache()`s) |
|
|
471
529
|
| `revalidate()` (named revalidation contract) | **no** — re-runs segments/loaders for the PAYLOAD, never HTML | yes — the hole re-renders with fresh data |
|
|
472
530
|
|
|
531
|
+
A server action's automatic invalidation refreshes the CLIENT only — it re-runs
|
|
532
|
+
the holes and streams a fresh payload, but does NOT evict the server shell.
|
|
533
|
+
Shell-baked data stays stale until the shell's TTL unless you tag-invalidate it
|
|
534
|
+
(`updateTag` on a shell tag). Data baked into the shell WITHOUT a tag cannot be
|
|
535
|
+
evicted by tag at all — move always-fresh data under a `loading()` hole.
|
|
536
|
+
|
|
473
537
|
## Pitfalls
|
|
474
538
|
|
|
475
539
|
- **A bake-lane loader that reads `cookies()`/`headers()`**: the capture is
|
|
@@ -483,7 +547,27 @@ know (a tenant id, a deploy marker).
|
|
|
483
547
|
- **Per-user value in shell material**: baked into the shared shell —
|
|
484
548
|
deterministically, not by race (handler promises deep-settle at the ring-3
|
|
485
549
|
write on cached chains; awaited/resolved values bake everywhere). Put
|
|
486
|
-
per-user data in a
|
|
550
|
+
per-user data in a nested pending promise or a live-lane (`loading()`)
|
|
551
|
+
loader — a BAKE-lane loader container bakes just like handler material.
|
|
552
|
+
- **The session-object bake trap (the guard cannot save you here)**: the
|
|
553
|
+
capture guard sees `cookies()`/`headers()` calls ONLY. A bake-lane loader
|
|
554
|
+
reading a middleware-provided session object (`ctx.get("session")`) refuses
|
|
555
|
+
nothing — and its FAST-RESOLVE branch is the killer:
|
|
556
|
+
|
|
557
|
+
```typescript
|
|
558
|
+
const CartLoader = createLoader(async (ctx) => {
|
|
559
|
+
const basketId = ctx.get("session")!.get("basketId");
|
|
560
|
+
if (!basketId) return { cart: Promise.resolve(null) }; // SETTLED → BAKES
|
|
561
|
+
return { cart: fetchBasket(basketId) }; // pending → hole
|
|
562
|
+
});
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
If the capturing request is anonymous (it usually is), `cart: null` bakes
|
|
566
|
+
and is snapshot-pinned: every logged-in user gets the anonymous badge on
|
|
567
|
+
every HIT. The branch asymmetry makes it nondeterministic per capture. Any
|
|
568
|
+
loader whose data is per-user belongs on the live lane — for a header
|
|
569
|
+
widget, a parallel slot with its own `loading()` (playbook lever 3).
|
|
570
|
+
|
|
487
571
|
- **Theme on a HIT is capture-then-corrected**: the resume tree replays the
|
|
488
572
|
CAPTURE's `initialTheme` (resume requires it to match the frozen prelude);
|
|
489
573
|
the visitor's cookie theme is applied pre-paint by the FOUC script and
|
|
@@ -497,8 +581,11 @@ cache"` value baked into the shell is PINNED at capture (the capture data
|
|
|
497
581
|
tag-invalidated. This is deliberate — parity beats freshness inside the shell.
|
|
498
582
|
If a shell region needs to be fresh, put it under a hole — `loading()` for
|
|
499
583
|
loader data, or an un-awaited promise under the consumer's `<Suspense>`
|
|
500
|
-
(holes are never pinned) — or make the SHELL itself invalidatable by
|
|
501
|
-
|
|
584
|
+
(holes are never pinned) — or make the SHELL itself invalidatable by tagging
|
|
585
|
+
it: call `cacheTag(...)` from the shell-material render code (the render-time
|
|
586
|
+
lever), or add the tag to `ppr.tags` (operational tags the render cannot know —
|
|
587
|
+
a tenant id, a deploy marker). Ring-1/ring-3 tag invalidation does NOT drop the
|
|
588
|
+
shell.
|
|
502
589
|
- **Uncached nondeterminism in the shell is a hydration hazard**: a raw
|
|
503
590
|
`Date.now()` / `Math.random()` / uncached `fetch` rendered directly in shell
|
|
504
591
|
material (outside any cache ring) drifts between capture and hit and the
|
|
@@ -520,6 +607,9 @@ cache"` value baked into the shell is PINNED at capture (the capture data
|
|
|
520
607
|
|
|
521
608
|
## Related
|
|
522
609
|
|
|
610
|
+
- `/defer-hydration` — keep the full body HTML in the shell while moving a
|
|
611
|
+
heavy subtree's hydration off the initial main-thread task (gated boundary,
|
|
612
|
+
content-as-fallback)
|
|
523
613
|
- `/document-cache` — whole-response edge caching (no live holes)
|
|
524
614
|
- `/caching` and `/cache-guide` — segment/function caching (axis 1 data)
|
|
525
615
|
- `/shell-manifest` — replayed handles as cache metadata read by live loaders
|
package/skills/rango/SKILL.md
CHANGED
|
@@ -117,6 +117,15 @@ stated, greppable contract.
|
|
|
117
117
|
- `path()`/`include()` are always visible in `urls()`; config helpers are extractable.
|
|
118
118
|
- **Cache decides freshness; `revalidate()` decides client-update.** Orthogonal; compose.
|
|
119
119
|
- Loaders resolve fresh every request (even inside `cache()`) and never run twice/request.
|
|
120
|
+
- **The consumption-lane rule.** For every shared artifact (`cache()`,
|
|
121
|
+
`"use cache"`, the PPR shell): server-side handler consumption
|
|
122
|
+
(`await ctx.use(loader)`) yields a BAKED copy — identity reads
|
|
123
|
+
(`cookies()`/`headers()`) are permitted there and the capture-time value
|
|
124
|
+
freezes into the shared artifact (a documented footgun; see `/caching` →
|
|
125
|
+
"Cache purity & tainted objects"). Client-side consumption (`useLoader` in
|
|
126
|
+
a `"use client"` component) is the LIVE lane. DSL `loader()` segments
|
|
127
|
+
follow their lane machinery (live under renderable `loading()`, bake
|
|
128
|
+
otherwise). Pinned by semantic-matrix row PPR3.
|
|
120
129
|
- Inside `"use cache"`: `cookies()`/`headers()` and `ctx` side-effects
|
|
121
130
|
(`set`/`header`/`setTheme`/`onResponse`/`setLocationState`) throw; `ctx.use(Handle)`
|
|
122
131
|
is captured on miss and replayed on hit. (The non-cacheable read guard is a
|
|
@@ -273,6 +282,7 @@ Grouped by concern — read when you need to…
|
|
|
273
282
|
| `/scripts` | Inject third-party scripts (GTM/analytics) into head/body via the `Script` handle; nonce auto-applied to document-rendered scripts |
|
|
274
283
|
| `/tailwind` | Set up Tailwind CSS v4 with `?url` imports |
|
|
275
284
|
| `/view-transitions` | React View Transitions on layouts, routes, and parallel slots |
|
|
285
|
+
| `/defer-hydration` | Full body HTML in the PPR shell + hydration off the critical path (gated Suspense boundary, content-as-fallback) |
|
|
276
286
|
| `/breadcrumbs` | Built-in Breadcrumbs handle for breadcrumb navigation |
|
|
277
287
|
| `/react-compiler` | Enable React Compiler (opt-in) the vite-rsc way; client-only scope |
|
|
278
288
|
|
package/skills/route/SKILL.md
CHANGED
|
@@ -479,6 +479,14 @@ urls(({ path, layout }) => [
|
|
|
479
479
|
])
|
|
480
480
|
```
|
|
481
481
|
|
|
482
|
+
For composing whole route MODULES, reach for `include()` — and prefer the
|
|
483
|
+
code-split form `include("/shop", () => import("./shop-patterns"))` for any
|
|
484
|
+
group that is a natural unit: it keeps the group off the cold-start path, and
|
|
485
|
+
measured first-hit cost scales with routes-per-chunk, so many small groups
|
|
486
|
+
beat one giant one. Sizing rules and the numbers behind them:
|
|
487
|
+
[skills/composability](../composability/SKILL.md) → "Sizing async include
|
|
488
|
+
groups (measured)".
|
|
489
|
+
|
|
482
490
|
## View Transitions
|
|
483
491
|
|
|
484
492
|
A route can configure its own `transition()` — the wrap goes around the route's component itself (routes are leaves; they have no separate default outlet channel). If the route component renders a `<ParallelOutlet />` directly, that slot remains inside the route's VT subtree, so prefer mounting parallel slots in a layout when combining intercept modals with route-level transitions. See [skills/view-transitions](../view-transitions/SKILL.md) for examples and the wrap-location rules across layouts, routes, and slots.
|
|
@@ -25,6 +25,7 @@ below. Read the one for your case.
|
|
|
25
25
|
| Typed `search` schemas, `RouteSearchParams`/`RouteParams`, loader return types | Search params & loader typing | [`./params-and-search.md`](./params-and-search.md) |
|
|
26
26
|
| Typed `env`/bindings, `Rango.Vars`, `createVar()`, handle typing, loader/handle ref props, location state typing | Environment, context, and state typing | [`./env-and-bindings.md`](./env-and-bindings.md) |
|
|
27
27
|
| Multi-app / multi-router tsconfig setup, avoiding `GeneratedRouteMap` collisions | Multi-project setup & full walkthrough | [`./generated-files-and-cli.md`](./generated-files-and-cli.md) |
|
|
28
|
+
| Slow typecheck with many `include()` modules (instantiation blowup), wide `UrlPatterns<any>` annotations | Typecheck cost at route scale | [`./generated-files-and-cli.md`](./generated-files-and-cli.md) |
|
|
28
29
|
|
|
29
30
|
## Companion files
|
|
30
31
|
|
|
@@ -120,6 +120,36 @@ Do not document or use a public `router.routeNames` API unless one is
|
|
|
120
120
|
intentionally added. Today, the public extraction surface is `router.routeMap`;
|
|
121
121
|
the generated file and `$$routeNames` are build machinery.
|
|
122
122
|
|
|
123
|
+
### Typecheck cost when composing many include modules
|
|
124
|
+
|
|
125
|
+
`urls()` infers a route registry from everything in its array — including the
|
|
126
|
+
module types behind every `include()` thunk, recursively. In an app composing
|
|
127
|
+
MANY include modules (dozens of groups, or factory-produced groups), that
|
|
128
|
+
inference chain can explode: measured on a 26k-route app with 50 nested
|
|
129
|
+
include modules, root inference hit 4.05M type instantiations / 20 s check
|
|
130
|
+
time; the same app checks at ~140k / 3.6 s after widening.
|
|
131
|
+
|
|
132
|
+
The fix is to annotate the intermediate modules' exports with the wide
|
|
133
|
+
`UrlPatterns` type, which stops per-route literal types from propagating
|
|
134
|
+
upward:
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
import { urls, type UrlPatterns } from "@rangojs/router";
|
|
138
|
+
|
|
139
|
+
export const shopPatterns: UrlPatterns<any> = urls(({ path }) => [
|
|
140
|
+
// ...hundreds of routes
|
|
141
|
+
]);
|
|
142
|
+
export default shopPatterns;
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Nothing is lost: named-route typing (`Handler<"name">`, `ctx.reverse`,
|
|
146
|
+
`href`) comes from the generated `router.named-routes.gen.ts`, not from the
|
|
147
|
+
inferred `urls()` type. Keep full inference on modules whose
|
|
148
|
+
`Rango.PathResponse` payloads you assert (e.g. `path.json` response routes);
|
|
149
|
+
widen the big mechanical groups. Use `UrlPatterns<any>` (not
|
|
150
|
+
`UrlPatterns<unknown>` — `unknown` env breaks handler assignability).
|
|
151
|
+
Diagnose with `tsc --extendedDiagnostics` and watch the Instantiations count.
|
|
152
|
+
|
|
123
153
|
## Multi-Project tsconfig Setup
|
|
124
154
|
|
|
125
155
|
For monorepos or multi-app setups, each app should have its own TypeScript
|
|
@@ -153,6 +153,13 @@ const locale = cookies().get("locale")?.value ?? "en";
|
|
|
153
153
|
const data = await getCachedData(locale); // locale is now in the cache key
|
|
154
154
|
```
|
|
155
155
|
|
|
156
|
+
The guard does not reach into LOADER bodies consumed inside the cached
|
|
157
|
+
function (`await ctx.use(loader)`): loaders always run fresh, so their reads
|
|
158
|
+
are exempt — but the CONSUMED VALUE is captured into the shared cache entry
|
|
159
|
+
like any other computed data. Same rule across `cache()` and the PPR shell:
|
|
160
|
+
handler/cached-scope consumption = baked copy, client-side `useLoader` = live
|
|
161
|
+
(the consumption-lane rule, `/rango` → Invariants).
|
|
162
|
+
|
|
156
163
|
### Side-Effect Guards
|
|
157
164
|
|
|
158
165
|
These ctx methods **throw** inside a `"use cache"` function because their effects
|
|
@@ -346,8 +353,11 @@ export async function getProducts() {
|
|
|
346
353
|
Writes to the same `SegmentCacheStore` as `cache()` DSL, `Static()`, and `Prerender()`.
|
|
347
354
|
One store, one configuration.
|
|
348
355
|
|
|
349
|
-
Cache entries (and `cacheProfiles`) can be tagged via `cache({ tags })` or
|
|
350
|
-
a `"use cache"` function
|
|
356
|
+
Cache entries (and `cacheProfiles`) can be tagged via `cache({ tags })` or runtime
|
|
357
|
+
`cacheTag(...tags)`. `cacheTag` has two forms: inside a `"use cache"` function it
|
|
358
|
+
tags that entry; called during a request render outside `"use cache"` it tags the
|
|
359
|
+
request's document/shell artifact (rides `_requestTags`) instead of throwing. The
|
|
360
|
+
built-in
|
|
351
361
|
`MemorySegmentCacheStore` and `CFCacheStore` index by tag. Invalidate on demand
|
|
352
362
|
with `updateTag(...tags)` (awaitable, read-your-own-writes; for server actions) or
|
|
353
363
|
`revalidateTag(...tags)` (background, non-blocking; for route handlers/webhooks).
|
|
@@ -395,6 +395,13 @@ export function createPartialUpdater(
|
|
|
395
395
|
return;
|
|
396
396
|
}
|
|
397
397
|
if (mode.type === "action") {
|
|
398
|
+
// An action refetch that lands on missing segments (navigated away /
|
|
399
|
+
// consolidation / HMR) drops rather than refetch-all: the action flow
|
|
400
|
+
// is storeOnly / skipLoadingState, so a full refetch here would fight
|
|
401
|
+
// it. Keep the stale-but-consistent tree; log so the drop is visible.
|
|
402
|
+
debugLog(
|
|
403
|
+
`[Browser] Action refetch: ${missingCount} segments missing; dropping (stale-but-consistent tree kept).`,
|
|
404
|
+
);
|
|
398
405
|
return;
|
|
399
406
|
}
|
|
400
407
|
console.warn(
|
|
@@ -58,3 +58,32 @@ export function sortedRouteParams(
|
|
|
58
58
|
if (!params) return "";
|
|
59
59
|
return encodeKV(Object.entries(params), { sort: true });
|
|
60
60
|
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Host-namespaced cache key base: `${host}${pathname}[:params][?search]`.
|
|
64
|
+
*
|
|
65
|
+
* The ONE composition of the host-namespacing rule, shared by the segment tier
|
|
66
|
+
* (cache-scope.ts) and the document tier (document-cache.ts) so the rule cannot
|
|
67
|
+
* drift between them. Host prefixing matters because VercelCacheStore /
|
|
68
|
+
* MemorySegmentCacheStore key by the raw string (only CFCacheStore adds host
|
|
69
|
+
* internally) -- on a single function serving multiple domains an
|
|
70
|
+
* un-namespaced key bleeds tenant A's cached response to tenant B.
|
|
71
|
+
*
|
|
72
|
+
* Output is BYTE-STABLE by contract: changing the composition silently
|
|
73
|
+
* invalidates every persisted cache entry on upgrade. Callers append their own
|
|
74
|
+
* tier-specific suffixes (`:rsc`/`:html`, segment hash) after this base.
|
|
75
|
+
*/
|
|
76
|
+
export function cacheKeyBase(
|
|
77
|
+
host: string,
|
|
78
|
+
pathname: string,
|
|
79
|
+
searchParams?: URLSearchParams,
|
|
80
|
+
params?: Record<string, string>,
|
|
81
|
+
): string {
|
|
82
|
+
const paramStr = sortedRouteParams(params);
|
|
83
|
+
const searchStr = searchParams ? sortedSearchString(searchParams) : "";
|
|
84
|
+
|
|
85
|
+
let key = `${host}${pathname}`;
|
|
86
|
+
if (paramStr) key += `:${paramStr}`;
|
|
87
|
+
if (searchStr) key += `?${searchStr}`;
|
|
88
|
+
return key;
|
|
89
|
+
}
|
package/src/cache/cache-scope.ts
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
encodeHandles,
|
|
32
32
|
decodeHandles,
|
|
33
33
|
} from "./handle-snapshot.js";
|
|
34
|
-
import {
|
|
34
|
+
import { cacheKeyBase } from "./cache-key-utils.js";
|
|
35
35
|
import {
|
|
36
36
|
DEFAULT_ROUTE_TTL,
|
|
37
37
|
isFiniteNonNegativeSeconds,
|
|
@@ -85,21 +85,6 @@ function validatedSwr(value: number | undefined): number | undefined {
|
|
|
85
85
|
return isValidCacheSeconds(value, "swr") ? value : undefined;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
function getCacheKeyBase(
|
|
89
|
-
host: string,
|
|
90
|
-
pathname: string,
|
|
91
|
-
params?: Record<string, string>,
|
|
92
|
-
searchParams?: URLSearchParams,
|
|
93
|
-
): string {
|
|
94
|
-
const paramStr = sortedRouteParams(params);
|
|
95
|
-
const searchStr = searchParams ? sortedSearchString(searchParams) : "";
|
|
96
|
-
|
|
97
|
-
let key = `${host}${pathname}`;
|
|
98
|
-
if (paramStr) key += `:${paramStr}`;
|
|
99
|
-
if (searchStr) key += `?${searchStr}`;
|
|
100
|
-
return key;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
88
|
function getDefaultRouteCacheKey(
|
|
104
89
|
pathname: string,
|
|
105
90
|
params?: Record<string, string>,
|
|
@@ -113,7 +98,7 @@ function getDefaultRouteCacheKey(
|
|
|
113
98
|
// Intercept navigations get their own cache namespace
|
|
114
99
|
const prefix = isIntercept ? "intercept" : isPartial ? "partial" : "doc";
|
|
115
100
|
|
|
116
|
-
return `${prefix}:${
|
|
101
|
+
return `${prefix}:${cacheKeyBase(host, pathname, searchParams, params)}`;
|
|
117
102
|
}
|
|
118
103
|
|
|
119
104
|
// ============================================================================
|
package/src/cache/cache-tag.ts
CHANGED
|
@@ -38,36 +38,82 @@ export function normalizeTags(tags: Iterable<string>): string[] {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* Tag
|
|
42
|
-
* updateTag() / revalidateTag().
|
|
41
|
+
* Tag content for later invalidation via updateTag() / revalidateTag().
|
|
43
42
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
43
|
+
* cacheTag() serves two forms depending on what is active when it runs:
|
|
44
|
+
*
|
|
45
|
+
* 1. Inside a "use cache" function — the DEFAULT. The tags go to the current
|
|
46
|
+
* cache entry; `revalidateTag(tag)` drops that entry. Tags are additive
|
|
47
|
+
* (multiple calls accumulate), and normalizeTag() is the single chokepoint so
|
|
48
|
+
* a padded write matches an unpadded invalidate.
|
|
49
|
+
*
|
|
50
|
+
* 2. Render-callable (#648) — no "use cache" scope active, but a request context
|
|
51
|
+
* is present. The tags record onto the request's DOCUMENT artifact
|
|
52
|
+
* (ctx._requestTags) instead of throwing. The collection layers already exist:
|
|
53
|
+
* PPR shell capture unions _requestTags into the shell entry, the document
|
|
54
|
+
* cache tags the full-page entry with it, and prerender build contexts seed
|
|
55
|
+
* their own set — so a server component that renders into a shell makes
|
|
56
|
+
* `revalidateTag("campaign:spring")` evict that shell with ZERO cache()/"use
|
|
57
|
+
* cache" in its tree. This is PPR's DERIVATIVE invalidation: PPR is
|
|
58
|
+
* execution-PRESERVING (everything still runs underneath; only document bytes
|
|
59
|
+
* are shortcut), so its tags ride this existing instrument rather than a
|
|
60
|
+
* first-class ppr key/tag API. The shell-expiry invariant holds by
|
|
61
|
+
* construction — baked ⇒ evicts (bake-lane loaders execute during capture and
|
|
62
|
+
* record here), hole ⇒ fresh (masked loaders behind a renderable loading()
|
|
63
|
+
* never execute during capture, so nothing under a hole can tag the shell).
|
|
64
|
+
*
|
|
65
|
+
* Inside a cache() DSL segment the render-callable form records at the DOCUMENT
|
|
66
|
+
* level, not the segment (only the "use cache" runtime enters the tag scope) — a
|
|
67
|
+
* documented semantic, not a filtered one. An empty/whitespace-only tag is
|
|
68
|
+
* dropped in both forms (the render-callable form silently, via normalizeTags in
|
|
69
|
+
* recordRequestTags; the scope form with a dev warning).
|
|
70
|
+
*
|
|
71
|
+
* With neither a scope nor a request context, cacheTag() throws.
|
|
46
72
|
*
|
|
47
73
|
* @example
|
|
48
74
|
* ```typescript
|
|
75
|
+
* // Form 1 — inside "use cache":
|
|
49
76
|
* async function getProduct(ctx) {
|
|
50
77
|
* "use cache";
|
|
51
78
|
* cacheTag(`product:${ctx.params.id}`, "products");
|
|
52
79
|
* return db.getProduct(ctx.params.id);
|
|
53
80
|
* }
|
|
81
|
+
*
|
|
82
|
+
* // Form 2 — render-callable, tags the shell/document from a server component:
|
|
83
|
+
* function CampaignBanner() {
|
|
84
|
+
* cacheTag("campaign:spring");
|
|
85
|
+
* return <aside>Spring sale</aside>;
|
|
86
|
+
* }
|
|
54
87
|
* ```
|
|
55
88
|
*/
|
|
56
89
|
export function cacheTag(...tags: string[]): void {
|
|
57
90
|
const store = cacheTagStorage.getStore();
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
91
|
+
if (store) {
|
|
92
|
+
// Form 1: "use cache" scope wins — tag the cache entry (unchanged).
|
|
93
|
+
for (const tag of tags) {
|
|
94
|
+
const normalized = normalizeTag(tag);
|
|
95
|
+
if (normalized === null) {
|
|
96
|
+
if (process.env.NODE_ENV !== "production") {
|
|
97
|
+
console.warn(`[cacheTag] Ignoring empty or whitespace-only tag.`);
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
66
100
|
}
|
|
67
|
-
|
|
101
|
+
store.add(normalized);
|
|
68
102
|
}
|
|
69
|
-
|
|
103
|
+
return;
|
|
70
104
|
}
|
|
105
|
+
|
|
106
|
+
const reqCtx = _getRequestContext();
|
|
107
|
+
if (reqCtx?._requestTags) {
|
|
108
|
+
// Form 2: render-callable — tag the request's document artifact. See the
|
|
109
|
+
// JSDoc above for the composition doctrine and the baked/hole invariant.
|
|
110
|
+
recordRequestTags(tags, reqCtx);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
throw new Error(
|
|
115
|
+
'cacheTag() must be called inside a "use cache" function or during a request render.',
|
|
116
|
+
);
|
|
71
117
|
}
|
|
72
118
|
|
|
73
119
|
export function recordRequestTags(
|
|
@@ -137,6 +137,16 @@ const warnedNoKvReadInvalidation = new Set<string>();
|
|
|
137
137
|
*/
|
|
138
138
|
const warnedTagInvalidationTtlFloor = new Set<string>();
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Stores (by namespace) already warned that tag invalidation is writing KV
|
|
142
|
+
* markers with no expiry (tagInvalidationTtl unset), so the unbounded-growth
|
|
143
|
+
* warning fires once per process rather than once per invalidateTags call
|
|
144
|
+
* (CFCacheStore is constructed per request; invalidateTags runs per marker
|
|
145
|
+
* batch). Distinct from the floor warning: that one only fires for a positive
|
|
146
|
+
* below-floor value, never for the unset (no-expiry) default that this bounds.
|
|
147
|
+
*/
|
|
148
|
+
const warnedNoTagInvalidationTtl = new Set<string>();
|
|
149
|
+
|
|
140
150
|
// ============================================================================
|
|
141
151
|
// Types
|
|
142
152
|
// ============================================================================
|
|
@@ -340,19 +350,30 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
340
350
|
// kv - yet every tagged read still serves stale data with no other signal.
|
|
341
351
|
// Surface that misconfiguration.
|
|
342
352
|
if (!this.kv && (this.tagCacheTtl > 0 || this.onRevalidateTag)) {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
`
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
`{ kv } for distributed tag invalidation.`,
|
|
351
|
-
);
|
|
352
|
-
}
|
|
353
|
+
this.warnOncePerNamespace(
|
|
354
|
+
warnedNoKvReadInvalidation,
|
|
355
|
+
`[CFCacheStore] tagCacheTtl/onRevalidateTag is configured without a KV ` +
|
|
356
|
+
`namespace, so tag invalidation has NO read-side effect: tagged reads ` +
|
|
357
|
+
`are never treated as invalidated and serve stale data. Configure ` +
|
|
358
|
+
`{ kv } for distributed tag invalidation.`,
|
|
359
|
+
);
|
|
353
360
|
}
|
|
354
361
|
}
|
|
355
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Warn about a namespace-scoped misconfiguration once per namespace per
|
|
365
|
+
* isolate. `seen` is the module-level Set for that message family -- Sets
|
|
366
|
+
* are module-level (not instance fields) so re-constructed stores in the
|
|
367
|
+
* same isolate don't re-warn.
|
|
368
|
+
* @internal
|
|
369
|
+
*/
|
|
370
|
+
private warnOncePerNamespace(seen: Set<string>, message: string): void {
|
|
371
|
+
const id = this.namespace ?? "default";
|
|
372
|
+
if (seen.has(id)) return;
|
|
373
|
+
seen.add(id);
|
|
374
|
+
console.warn(message);
|
|
375
|
+
}
|
|
376
|
+
|
|
356
377
|
/**
|
|
357
378
|
* Validate a consumer-supplied tagInvalidationTtl against CF KV's expirationTtl
|
|
358
379
|
* floor. A finite value below KV_MIN_EXPIRATION_TTL is raised to it (with a
|
|
@@ -368,16 +389,13 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
368
389
|
if (value == null) return undefined;
|
|
369
390
|
if (!Number.isFinite(value) || value <= 0) return undefined;
|
|
370
391
|
if (value < KV_MIN_EXPIRATION_TTL) {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
`TTL+SWR or invalidated entries can resurrect when the marker expires.`,
|
|
379
|
-
);
|
|
380
|
-
}
|
|
392
|
+
this.warnOncePerNamespace(
|
|
393
|
+
warnedTagInvalidationTtlFloor,
|
|
394
|
+
`[CFCacheStore] tagInvalidationTtl ${value} is below Cloudflare KV's ` +
|
|
395
|
+
`${KV_MIN_EXPIRATION_TTL}s expirationTtl floor; raising to ` +
|
|
396
|
+
`${KV_MIN_EXPIRATION_TTL}. It must still exceed your largest entry ` +
|
|
397
|
+
`TTL+SWR or invalidated entries can resurrect when the marker expires.`,
|
|
398
|
+
);
|
|
381
399
|
return KV_MIN_EXPIRATION_TTL;
|
|
382
400
|
}
|
|
383
401
|
return value;
|
|
@@ -2254,6 +2272,22 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2254
2272
|
const failedTags = new Set<string>();
|
|
2255
2273
|
const errors: unknown[] = [];
|
|
2256
2274
|
if (this.kv) {
|
|
2275
|
+
// Markers written with no expiry (tagInvalidationTtl unset) never expire,
|
|
2276
|
+
// so high-cardinality tags accumulate KV keys unboundedly with no reaper.
|
|
2277
|
+
// Warn once per namespace at the batch entry point (not per marker write,
|
|
2278
|
+
// which would fire once per tag). Kept separate from the floor warning:
|
|
2279
|
+
// that path only fires for a positive below-floor value, never the unset
|
|
2280
|
+
// default sanitizeTagInvalidationTtl passes through as undefined.
|
|
2281
|
+
if (!this.tagInvalidationTtl) {
|
|
2282
|
+
this.warnOncePerNamespace(
|
|
2283
|
+
warnedNoTagInvalidationTtl,
|
|
2284
|
+
`[CFCacheStore] invalidateTags is writing KV markers with no expiry ` +
|
|
2285
|
+
`(tagInvalidationTtl is unset): high-cardinality tags accumulate KV ` +
|
|
2286
|
+
`keys unboundedly (storage + list-scan cost) with no reaper. Set ` +
|
|
2287
|
+
`tagInvalidationTtl above your largest entry TTL+SWR to bound marker ` +
|
|
2288
|
+
`growth; setting it too small resurrects invalidated entries.`,
|
|
2289
|
+
);
|
|
2290
|
+
}
|
|
2257
2291
|
await Promise.all(
|
|
2258
2292
|
tags.map(async (tag) => {
|
|
2259
2293
|
const markerKey = this.tagMarkerKey(tag);
|