@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.
Files changed (55) hide show
  1. package/dist/vite/index.js +25 -6
  2. package/package.json +4 -2
  3. package/skills/cache-guide/SKILL.md +3 -1
  4. package/skills/caching/SKILL.md +41 -2
  5. package/skills/catalog.json +6 -0
  6. package/skills/composability/SKILL.md +32 -0
  7. package/skills/defer-hydration/SKILL.md +235 -0
  8. package/skills/loader/SKILL.md +5 -0
  9. package/skills/migrate-nextjs/SKILL.md +4 -2
  10. package/skills/observability/SKILL.md +8 -0
  11. package/skills/parallel/SKILL.md +4 -0
  12. package/skills/ppr/SKILL.md +110 -20
  13. package/skills/rango/SKILL.md +10 -0
  14. package/skills/route/SKILL.md +8 -0
  15. package/skills/typesafety/SKILL.md +1 -0
  16. package/skills/typesafety/generated-files-and-cli.md +30 -0
  17. package/skills/use-cache/SKILL.md +12 -2
  18. package/src/browser/partial-update.ts +7 -0
  19. package/src/cache/cache-key-utils.ts +29 -0
  20. package/src/cache/cache-scope.ts +2 -17
  21. package/src/cache/cache-tag.ts +60 -14
  22. package/src/cache/cf/cf-cache-store.ts +54 -20
  23. package/src/cache/document-cache.ts +17 -11
  24. package/src/cache/vercel/vercel-cache-store.ts +9 -19
  25. package/src/cloudflare/tracing.ts +7 -8
  26. package/src/index.rsc.ts +1 -0
  27. package/src/index.ts +12 -8
  28. package/src/redirect-origin.ts +14 -0
  29. package/src/route-definition/helpers-types.ts +5 -4
  30. package/src/route-map-builder.ts +41 -4
  31. package/src/router/find-match.ts +15 -1
  32. package/src/router/instrument.ts +9 -4
  33. package/src/router/lazy-includes.ts +8 -2
  34. package/src/router/loader-resolution.ts +14 -2
  35. package/src/router/match-handlers.ts +175 -133
  36. package/src/router/middleware.ts +40 -30
  37. package/src/router/router-interfaces.ts +9 -0
  38. package/src/router/segment-resolution/loader-snapshot.ts +98 -17
  39. package/src/router/telemetry-otel.ts +6 -8
  40. package/src/router/telemetry.ts +9 -1
  41. package/src/router/tracing.ts +14 -5
  42. package/src/router.ts +22 -14
  43. package/src/rsc/handler.ts +55 -32
  44. package/src/rsc/redirect-guard.ts +2 -1
  45. package/src/rsc/rsc-rendering.ts +35 -2
  46. package/src/rsc/shell-capture.ts +98 -20
  47. package/src/server/context.ts +47 -9
  48. package/src/server/cookie-store.ts +26 -5
  49. package/src/server/request-context.ts +22 -0
  50. package/src/ssr/index.tsx +145 -107
  51. package/src/testing/dispatch.ts +149 -37
  52. package/src/urls/path-helper-types.ts +9 -4
  53. package/src/vercel/tracing.ts +7 -7
  54. package/src/vite/inject-client-debug.ts +64 -12
  55. package/src/vite/router-discovery.ts +9 -1
@@ -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
- parallel({ "@wishlist": WishlistBadge }, () => [
320
- loader(WishlistLoader),
321
- loading(<BadgeSkeleton />),
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()` (the live lane).
338
- Reading `cookies()`/`headers()` where the value would bake — handler shell
339
- material or a bake-lane containerrefuses the capture by construction.
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 materialhandler/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. If the
391
- value is per-user: shell-cache only public/shared pages, put per-user content
392
- in loaders, or key per variant at the CDN tier.
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(...)` from a `"use cache"` function or
463
- `cache()` segment that ran as shell material. Loader tags never attach (the
464
- holes are already live). `ppr.tags` adds operational tags the render cannot
465
- know (a tenant id, a deploy marker).
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 loader.
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 adding
501
- the tag to `ppr.tags`. Ring-1/ring-3 tag invalidation does NOT drop the shell.
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
@@ -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
 
@@ -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, inside
350
- a `"use cache"` function, runtime `cacheTag(...tags)`. The built-in
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
+ }
@@ -31,7 +31,7 @@ import {
31
31
  encodeHandles,
32
32
  decodeHandles,
33
33
  } from "./handle-snapshot.js";
34
- import { sortedSearchString, sortedRouteParams } from "./cache-key-utils.js";
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}:${getCacheKeyBase(host, pathname, params, searchParams)}`;
101
+ return `${prefix}:${cacheKeyBase(host, pathname, searchParams, params)}`;
117
102
  }
118
103
 
119
104
  // ============================================================================
@@ -38,36 +38,82 @@ export function normalizeTags(tags: Iterable<string>): string[] {
38
38
  }
39
39
 
40
40
  /**
41
- * Tag the current "use cache" entry for later invalidation via
42
- * updateTag() / revalidateTag().
41
+ * Tag content for later invalidation via updateTag() / revalidateTag().
43
42
  *
44
- * Must be called inside a function marked with "use cache".
45
- * Tags are additive - multiple calls accumulate.
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 (!store) {
59
- throw new Error('cacheTag() must be called inside a "use cache" function.');
60
- }
61
- for (const tag of tags) {
62
- const normalized = normalizeTag(tag);
63
- if (normalized === null) {
64
- if (process.env.NODE_ENV !== "production") {
65
- console.warn(`[cacheTag] Ignoring empty or whitespace-only tag.`);
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
- continue;
101
+ store.add(normalized);
68
102
  }
69
- store.add(normalized);
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
- const id = this.namespace ?? "default";
344
- if (!warnedNoKvReadInvalidation.has(id)) {
345
- warnedNoKvReadInvalidation.add(id);
346
- console.warn(
347
- `[CFCacheStore] tagCacheTtl/onRevalidateTag is configured without a KV ` +
348
- `namespace, so tag invalidation has NO read-side effect: tagged reads ` +
349
- `are never treated as invalidated and serve stale data. Configure ` +
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
- const id = this.namespace ?? "default";
372
- if (!warnedTagInvalidationTtlFloor.has(id)) {
373
- warnedTagInvalidationTtlFloor.add(id);
374
- console.warn(
375
- `[CFCacheStore] tagInvalidationTtl ${value} is below Cloudflare KV's ` +
376
- `${KV_MIN_EXPIRATION_TTL}s expirationTtl floor; raising to ` +
377
- `${KV_MIN_EXPIRATION_TTL}. It must still exceed your largest entry ` +
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);