@rangojs/router 0.17.0 → 0.18.0

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 (152) hide show
  1. package/README.md +3 -2
  2. package/dist/types/browser/cookie-name.d.ts +6 -0
  3. package/dist/types/browser/history-state.d.ts +16 -0
  4. package/dist/types/cache/cache-key-utils.d.ts +17 -2
  5. package/dist/types/cache/cache-policy.d.ts +17 -0
  6. package/dist/types/cache/cache-scope.d.ts +98 -14
  7. package/dist/types/cache/cache-tag.d.ts +92 -7
  8. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  9. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  10. package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
  11. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  12. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  13. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
  14. package/dist/types/cache/handle-snapshot.d.ts +4 -3
  15. package/dist/types/cache/index.d.ts +1 -0
  16. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  17. package/dist/types/cache/shell-memo.d.ts +178 -0
  18. package/dist/types/cache/shell-snapshot.d.ts +110 -21
  19. package/dist/types/cache/tag-invalidation.d.ts +30 -3
  20. package/dist/types/cache/types.d.ts +207 -43
  21. package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
  22. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  23. package/dist/types/router/instrument.d.ts +8 -0
  24. package/dist/types/router/middleware-types.d.ts +6 -1
  25. package/dist/types/router/segment-resolution/loader-cache.d.ts +40 -11
  26. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +21 -10
  27. package/dist/types/router/segment-resolution/mask-nested.d.ts +65 -24
  28. package/dist/types/rsc/full-payload.d.ts +14 -0
  29. package/dist/types/rsc/handler.d.ts +1 -1
  30. package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
  31. package/dist/types/rsc/shell-capture-constants.d.ts +53 -6
  32. package/dist/types/rsc/shell-capture.d.ts +146 -15
  33. package/dist/types/rsc/shell-serve.d.ts +140 -17
  34. package/dist/types/rsc/types.d.ts +7 -0
  35. package/dist/types/server/context.d.ts +109 -15
  36. package/dist/types/server/cookie-store.d.ts +21 -0
  37. package/dist/types/server/request-context.d.ts +159 -33
  38. package/dist/types/ssr/index.d.ts +9 -0
  39. package/dist/types/ssr/ssr-root.d.ts +9 -8
  40. package/dist/types/testing/flight.entry.d.ts +7 -0
  41. package/dist/types/testing/run-loader.d.ts +18 -3
  42. package/dist/types/testing/serve-shell-request.d.ts +132 -0
  43. package/dist/types/testing/shell-status.d.ts +23 -10
  44. package/dist/types/types/handler-context.d.ts +14 -1
  45. package/dist/types/urls/pattern-types.d.ts +22 -18
  46. package/dist/types/vite/router-discovery.d.ts +29 -2
  47. package/dist/vite/index.js +46 -9
  48. package/package.json +1 -1
  49. package/skills/breadcrumbs/SKILL.md +17 -7
  50. package/skills/cache-guide/SKILL.md +123 -51
  51. package/skills/caching/SKILL.md +253 -23
  52. package/skills/catalog.json +1 -1
  53. package/skills/client-urls/SKILL.md +11 -6
  54. package/skills/cloudflare/SKILL.md +15 -0
  55. package/skills/comparison/references/framework-comparison.md +44 -32
  56. package/skills/defer-hydration/SKILL.md +50 -30
  57. package/skills/deployment-caching/SKILL.md +5 -2
  58. package/skills/document-cache/SKILL.md +8 -2
  59. package/skills/handler-use/SKILL.md +2 -0
  60. package/skills/hooks/handle-and-actions.md +3 -1
  61. package/skills/hooks/state.md +3 -1
  62. package/skills/loader/SKILL.md +161 -39
  63. package/skills/middleware/SKILL.md +16 -9
  64. package/skills/migrate-nextjs/SKILL.md +67 -36
  65. package/skills/migrate-react-router/SKILL.md +2 -1
  66. package/skills/migrate-react-router/component-migration.md +3 -1
  67. package/skills/migrate-react-router/data-and-actions.md +3 -1
  68. package/skills/migrate-react-router/route-mapping.md +7 -4
  69. package/skills/observability/SKILL.md +64 -0
  70. package/skills/parallel/SKILL.md +13 -8
  71. package/skills/ppr/SKILL.md +655 -363
  72. package/skills/prerender/SKILL.md +27 -14
  73. package/skills/rango/SKILL.md +52 -33
  74. package/skills/route/SKILL.md +12 -7
  75. package/skills/router-setup/SKILL.md +4 -2
  76. package/skills/scripts/SKILL.md +8 -1
  77. package/skills/server-actions/SKILL.md +11 -0
  78. package/skills/shell-manifest/SKILL.md +21 -12
  79. package/skills/testing/SKILL.md +25 -21
  80. package/skills/testing/cache-prerender.md +15 -14
  81. package/skills/testing/loader.md +18 -1
  82. package/skills/testing/serve-shell-request.md +188 -0
  83. package/skills/theme/SKILL.md +52 -4
  84. package/skills/typesafety/env-and-bindings.md +10 -2
  85. package/skills/use-cache/SKILL.md +39 -16
  86. package/skills/vercel/SKILL.md +14 -2
  87. package/skills/view-transitions/SKILL.md +1 -1
  88. package/src/browser/cookie-name.ts +23 -0
  89. package/src/browser/history-state.ts +23 -0
  90. package/src/browser/rsc-router.tsx +5 -0
  91. package/src/cache/cache-key-utils.ts +22 -2
  92. package/src/cache/cache-policy.ts +22 -0
  93. package/src/cache/cache-runtime.ts +34 -7
  94. package/src/cache/cache-scope.ts +345 -33
  95. package/src/cache/cache-tag.ts +375 -11
  96. package/src/cache/cf/cf-base64.ts +36 -4
  97. package/src/cache/cf/cf-cache-constants.ts +12 -0
  98. package/src/cache/cf/cf-cache-store.ts +990 -348
  99. package/src/cache/cf/cf-cache-types.ts +32 -4
  100. package/src/cache/cf/cf-shell-frame.ts +258 -0
  101. package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
  102. package/src/cache/handle-snapshot.ts +4 -3
  103. package/src/cache/index.ts +2 -0
  104. package/src/cache/isolate-tag-memo.ts +308 -0
  105. package/src/cache/shell-memo.ts +341 -0
  106. package/src/cache/shell-snapshot.ts +217 -27
  107. package/src/cache/tag-invalidation.ts +104 -19
  108. package/src/cache/types.ts +211 -43
  109. package/src/cache/vercel/vercel-cache-store.ts +545 -61
  110. package/src/prerender/build-shell-capture.ts +65 -11
  111. package/src/route-definition/dsl-helpers.ts +4 -3
  112. package/src/router/handler-context.ts +38 -4
  113. package/src/router/instrument.ts +54 -2
  114. package/src/router/intercept-resolution.ts +19 -7
  115. package/src/router/loader-resolution.ts +63 -32
  116. package/src/router/match-api.ts +28 -9
  117. package/src/router/match-handlers.ts +5 -0
  118. package/src/router/match-middleware/cache-lookup.ts +17 -0
  119. package/src/router/match-middleware/cache-store.ts +31 -11
  120. package/src/router/middleware-types.ts +6 -1
  121. package/src/router/middleware.ts +23 -4
  122. package/src/router/prerender-match.ts +6 -0
  123. package/src/router/segment-resolution/fresh.ts +18 -9
  124. package/src/router/segment-resolution/helpers.ts +4 -2
  125. package/src/router/segment-resolution/loader-cache.ts +227 -56
  126. package/src/router/segment-resolution/loader-snapshot.ts +203 -51
  127. package/src/router/segment-resolution/mask-nested.ts +136 -42
  128. package/src/router/segment-resolution/revalidation.ts +27 -10
  129. package/src/rsc/full-payload.ts +25 -1
  130. package/src/rsc/handler.ts +14 -2
  131. package/src/rsc/progressive-enhancement.ts +3 -2
  132. package/src/rsc/response-cache-serve.ts +9 -6
  133. package/src/rsc/rsc-rendering.ts +601 -181
  134. package/src/rsc/shell-build-manifest.ts +27 -9
  135. package/src/rsc/shell-capture-constants.ts +60 -6
  136. package/src/rsc/shell-capture.ts +1001 -250
  137. package/src/rsc/shell-serve.ts +316 -34
  138. package/src/rsc/types.ts +7 -0
  139. package/src/server/context.ts +219 -28
  140. package/src/server/cookie-store.ts +87 -62
  141. package/src/server/request-context.ts +263 -38
  142. package/src/ssr/index.tsx +96 -37
  143. package/src/ssr/ssr-root.tsx +9 -8
  144. package/src/testing/flight.entry.ts +14 -0
  145. package/src/testing/run-loader.ts +63 -5
  146. package/src/testing/serve-shell-request.ts +378 -0
  147. package/src/testing/shell-status.ts +34 -12
  148. package/src/theme/ThemeProvider.tsx +7 -6
  149. package/src/types/handler-context.ts +14 -1
  150. package/src/urls/pattern-types.ts +22 -18
  151. package/src/vite/discovery/shell-prerender-phase.ts +9 -0
  152. package/src/vite/router-discovery.ts +14 -4
@@ -58,7 +58,8 @@ cache({ ttl: 60, swr: 300 }, () => [
58
58
  not stored: it renders on every request, hits included, with its parallels and
59
59
  loaders, so a per-request shell or a header it writes stays live. On a `ppr`
60
60
  route the whole chain bakes into the shell, so there `cache()` covers the whole
61
- chain.
61
+ chain, and a shell never outlives the route `cache()` entry it was captured
62
+ from (it is fresh and served no longer than that entry; `/ppr`).
62
63
 
63
64
  ```typescript
64
65
  layout(AccountShell, () => [
@@ -89,8 +90,11 @@ path, not only ProductChrome.
89
90
 
90
91
  The consumer rule: **want it cached? render it inline. Want it live? put it in a
91
92
  loader and read it with `useLoader()` in a client component.** Anything read
92
- with `cookies()`, `headers()`, or a non-cacheable variable belongs in a loader
93
- (loaders always run fresh). Reading it directly in a cached handler throws;
93
+ with `cookies()`, `headers()`, or a non-cacheable variable belongs in a loader:
94
+ a route `cache()` does not store loader values, so a loader runs on every
95
+ request. A loader bound with its own `cache()` is stored, so without a `key()`
96
+ its miss fails; any `key()` switches that check off, so it must include the
97
+ value (see `/loader` → "Cache Key"). Reading it directly in a cached handler throws;
94
98
  awaiting a loader with `ctx.use()` and rendering the result in a cached handler
95
99
  silently bakes per-request data into the shared entry (see "Cache purity &
96
100
  tainted objects" below).
@@ -163,7 +167,12 @@ cache(
163
167
  — document requests, client navigations, and intercept navigations are cached
164
168
  separately. The search part honors `cache.searchParams` (below).
165
169
  - **`key`** is a full override: it bypasses the default key, the store's
166
- `keyGenerator`, and the search-param filter.
170
+ `keyGenerator`, and the search-param filter. A nested `cache()` keys its
171
+ records within that partition (see "Keys nest" under "Nested Cache
172
+ Boundaries"). On a `ppr` route the same key
173
+ (or, without one, the store's `keyGenerator`) partitions the PPR shell: each
174
+ partition captures and serves its own shell (`/ppr`, "Request-partitioned
175
+ shells").
167
176
  - **`condition`** returning `false` skips both the cache read and write for that
168
177
  request (the boundary renders live).
169
178
  - `cache(() => [...])` with no options uses the store defaults.
@@ -221,15 +230,25 @@ function CampaignBanner() {
221
230
 
222
231
  Each tag attaches to one entry, so know which entry you are tagging:
223
232
 
224
- - Forms 1 and 2 tag the `cache()` segment entry. They are the only way to tag
225
- it: tags recorded by a `"use cache"` function (form 3) stay on that
226
- function's entry and do not attach to an enclosing `cache()` segment entry.
233
+ - Forms 1 and 2 tag the `cache()` segment entry.
227
234
  - Form 3 tags the `"use cache"` entry (plus any profile `tags`).
228
235
  - Form 4 tags the request's `ppr` shell entry (`/ppr`) or `/document-cache`
229
- entry, with no `"use cache"` needed. Called inside a `cache()` segment it
230
- still tags the document/shell, not the segment entry. On a route that is
231
- neither `ppr` nor document-cached, nothing reads the tag — a silent no-op, so
232
- do not expect a bare `cacheTag()` to tag an ordinary uncached page.
236
+ entry, with no `"use cache"` needed. On a route that is neither `ppr` nor
237
+ document-cached and has no `cache()`, nothing reads the tag — a silent
238
+ no-op, so do not expect a bare `cacheTag()` to tag an ordinary uncached page.
239
+ - A route `cache()` entry also carries the tags its own content recorded when
240
+ it was written: form 4 calls and form 3 reads in the handlers and server
241
+ components it covers. `updateTag()` of such a tag drops the entry, and a HIT
242
+ re-records the tags, so a ppr shell or document built from the HIT stays
243
+ evictable. `loading()` subtrees count too: a HIT replays their output. A
244
+ loader's tags reach the entry only when a handler consumes its value
245
+ (`await ctx.use(Loader)`); a loader nobody reads on the server runs per
246
+ request and stays off.
247
+ - A loader with its own `cache()` stores the tags its body recorded (form 4
248
+ calls and form 3 reads, including those of the loaders it reads with
249
+ `ctx.use()`) next to its `cache({ tags })`. They invalidate the loader's
250
+ entry, and a HIT records them again, so a route `cache()` entry, ppr shell
251
+ or document built over the HIT carries them.
233
252
 
234
253
  Invalidate with one of two server-only verbs (both variadic, imported from
235
254
  `@rangojs/router`):
@@ -258,6 +277,25 @@ function onProductsChanged() {
258
277
  | `updateTag(...tags)` | awaitable (`Promise<void>`) | server actions | immediate; next read is fresh |
259
278
  | `revalidateTag(...tags)` | background (`void`) | route handlers / webhooks | background (non-blocking); next read re-renders fresh |
260
279
 
280
+ The request that calls either verb reads its own writes. Before the call
281
+ returns, each built-in store marks the tags as invalidated for the rest of that
282
+ request, so an action that calls `revalidateTag()` and then renders gets fresh
283
+ `"use cache"` and `cache()` reads, as with `await updateTag()`. What
284
+ `revalidateTag()` does not wait for is the durable write (the KV marker, the
285
+ tag purge, Vercel's `expireTag`): other requests see the invalidation once it
286
+ lands. A `"use cache"` call that this request started before the
287
+ invalidation, and that carries one of the invalidated tags, is not reused
288
+ after it and does not store its value; neither does a stale entry's
289
+ background refresh. The tags are the call's own (its `cacheTag()` calls and
290
+ profile `tags`): a nested `"use cache"` function's tags do not reach the
291
+ enclosing entry, so invalidating one of them neither evicts the outer entry
292
+ nor stops an outer call already running
293
+ ([#980](https://github.com/rangojs/rango/issues/980)). Not covered
294
+ ([#977](https://github.com/rangojs/rango/issues/977)): such a call started
295
+ by another request, or a loader's own `cache()` that started before the
296
+ invalidation, still stores its value when it finishes, and reads after that
297
+ can get it.
298
+
261
299
  Both must run inside a request (action, handler, middleware, response route).
262
300
  Called from a queue consumer or cron job, there is no request context, so they
263
301
  warn and invalidate nothing. `updateTag()` rejects when a store's durable write
@@ -383,8 +421,17 @@ defaults (ttl 60 when the store sets none); it does **not** inherit the options
383
421
  of an enclosing `cache()` boundary. Inside `loader()` only the direct form
384
422
  `cache({...})` is valid — the wrapper form `cache(opts, () => [...])` throws.
385
423
  Handle pushes from the loader body (`Meta`, `Breadcrumbs`) are stored with the
386
- value and replayed on every hit, stale included. See `/loader` for the full
387
- loader reference.
424
+ value and replayed on every hit, stale included. The tags the body records
425
+ (`cacheTag()`, `"use cache"` reads, and those of the loaders it reads) are
426
+ stored with it too: `updateTag()` of one drops the entry, as `cache({ tags })`
427
+ does. See `/loader` for the full loader reference.
428
+
429
+ The entry's default key is the loader, host, path and params. It does not
430
+ inherit an enclosing `cache()` boundary's `key()` either, so it is shared
431
+ across users: a body that reads `cookies()`, `headers()` or a non-cacheable
432
+ variable fails on a miss unless the loader `cache()` has a `key()` or its store
433
+ a `keyGenerator`. Either one switches the check off, so it must itself include
434
+ the value (`/loader` → "Cache Key").
388
435
 
389
436
  ## Global Cache Configuration
390
437
 
@@ -537,8 +584,9 @@ const router = createRouter<AppBindings>({
537
584
  | Cold colo | MISS | HIT | Serve from KV, promote to L1 |
538
585
  | First render | MISS | MISS | Render, write to both L1 + KV |
539
586
 
540
- KV entries require `expirationTtl >= 60s`. Short-lived entries (< 60s total TTL)
541
- are only cached in L1.
587
+ KV entries require `expirationTtl >= 60s`. Short-lived data entries (< 60s total
588
+ TTL) are only cached in L1. A PPR shell is always written to KV, a short one
589
+ with the 60 s minimum; its reads still expire it at its own deadline.
542
590
 
543
591
  ### Resilience & latency budgets
544
592
 
@@ -558,6 +606,74 @@ read. A non-finite value (e.g. `Number(env.UNSET)`) falls back to the default.
558
606
  The tag-invalidation marker reads inherit these same budgets and **fail open** on
559
607
  a KV timeout — the entry is served rather than wrongly treated as invalidated.
560
608
 
609
+ A PPR shell HIT reads its entry prelude-first: the body budget (or the KV budget
610
+ on a KV read, counted from opening the value) covers only the entry's head and
611
+ prelude, the tag-marker read runs alongside the prelude read, and the capture
612
+ snapshot behind them is read off the commit path, bounded by `kvReadTimeoutMs`
613
+ (at least 1 s: the tail cannot render without it). A HIT whose snapshot read
614
+ times out, or whose snapshot is truncated or corrupt (also evicted), cannot
615
+ replay the captured segment record, so it degrades: the entry is replaced and
616
+ recaptured and the page reloads once into a cache-miss render (`/ppr`). The
617
+ snapshot holds only what a HIT reads: every HIT replays the handler layer from
618
+ the captured segment record, so the `"use cache"` items only handler code read
619
+ are not stored.
620
+
621
+ **Shell memo.** After a shell read, the same isolate serves that key's next
622
+ HITs from memory for `memo.shellMs` (default 2000; `{ shellMs: 0 }` turns it
623
+ off), with no Cache API or KV read. The tag-marker check still runs on every
624
+ HIT, and the isolate that runs `updateTag()`/`revalidateTag()` drops its own
625
+ copies, so that request and later ones on the same isolate never get the
626
+ invalidated shell. What another isolate can serve for up to one window: the
627
+ previous capture of a key another isolate just recaptured, and, without KV in
628
+ purge mode, a purged shell (the purge reaches the stored entry, not other
629
+ isolates' memos). `memo.shellMaxBytes` (default 16 MiB, shared by every
630
+ `CFCacheStore` in the isolate) caps what it holds; only fresh shells are kept.
631
+
632
+ **Tag-marker memo.** With KV, PPR shell reads check their tag markers through
633
+ a per-isolate memo, stale-while-revalidate: a marker read within
634
+ `memo.markerFreshMs` (default 1000) is used as is, one within
635
+ `memo.markerMaxStaleMs` (default 10000) is used while a background read
636
+ refreshes it, an older one waits for KV. The isolate also remembers each
637
+ shell's tag names and starts those marker reads with the Cache API match
638
+ (`ppr.tags` are known up front). `cache()`, `"use cache"` and response entries
639
+ keep reading their markers. `{ markerFreshMs: 0 }` turns the value memo off.
640
+
641
+ **The fresh-reads cookie.** A response whose request ran `updateTag()` or
642
+ `revalidateTag()` sets `<state cookie prefix>-fresh` (`rango-state-fresh` by
643
+ default, shared by every router on the host with that prefix; `HttpOnly`,
644
+ `SameSite=Lax`, `Path=/`, `Secure` on https, `Max-Age` 11 s with KV and 3 s
645
+ without at the defaults: the longest memo staleness plus 1 s). It is strictly
646
+ functional: its value is `1`, it identifies no one, and it only sends that
647
+ browser's reads past the memos. The same user's requests that carry it skip
648
+ both memos on every isolate, so a mutating user never sees a memoized shell or
649
+ marker from before their write. What they read past the memos is the store's
650
+ own consistency. `VercelCacheStore`: `expireTag` is global (about 300 ms), so
651
+ they read fresh in any region. `CFCacheStore` with KV: the shell's tag check
652
+ reads that colo's marker (its Cache API copy when `tagCacheTtl` is set, then
653
+ KV), so in the colo that ran the mutation they read fresh, while a request
654
+ another colo serves can still get the old shell until KV propagates the
655
+ marker there (up to about 60 s) and that colo's cached marker (`tagCacheTtl`)
656
+ expires. `CFCacheStore` without KV in purge mode (`tagPurge`) has no markers:
657
+ the purge is what removes the shell, and they see it once it has reached the
658
+ colo serving them. Other users can see a memoized shell or marker for up to
659
+ `markerMaxStaleMs` (with KV) or the shell window (`VercelCacheStore` in
660
+ another region, `CFCacheStore` in KV-less purge mode, where another isolate's
661
+ memo can keep serving the purged shell). Set `{ shellMs: 0, markerFreshMs: 0 }` where every user's next
662
+ request must see an invalidation (with KV, `{ shellMs: 0 }` alone still leaves
663
+ the marker memo); that also means no cookie. The cookie is not set when the
664
+ invalidation runs after the response headers were sent (a streaming loader or
665
+ render; dev warns): invalidate in a server action, route handler or
666
+ middleware. Any client can send it, which only makes its own requests read
667
+ the store.
668
+
669
+ ```typescript
670
+ new CFCacheStore({
671
+ ctx,
672
+ kv: env.CACHE_KV,
673
+ memo: { shellMs: 5000, markerFreshMs: 500 },
674
+ });
675
+ ```
676
+
561
677
  ```typescript
562
678
  new CFCacheStore({
563
679
  ctx,
@@ -623,13 +739,17 @@ the store adds SWR, tag invalidation, and the family split on top of it.
623
739
  import { getCache, waitUntil } from "@vercel/functions";
624
740
  import { VercelCacheStore } from "@rangojs/router/cache";
625
741
 
742
+ // A per-deploy namespace: Vercel does not reconcile entries across deploys.
743
+ // One handle per process: the store keeps its PPR shell and tag-marker
744
+ // memos per handle.
745
+ const runtimeCache = getCache({ namespace: process.env.VERCEL_DEPLOYMENT_ID });
746
+
626
747
  const router = createRouter({
627
748
  document: Document,
628
749
  urls: urlpatterns,
629
750
  cache: () => ({
630
751
  store: new VercelCacheStore({
631
- // A per-deploy namespace: Vercel does not reconcile entries across deploys
632
- cache: getCache({ namespace: process.env.VERCEL_DEPLOYMENT_ID }),
752
+ cache: runtimeCache,
633
753
  waitUntil,
634
754
  defaults: { ttl: 60, swr: 300 },
635
755
  }),
@@ -637,6 +757,22 @@ const router = createRouter({
637
757
  });
638
758
  ```
639
759
 
760
+ The store keeps the same PPR shell memo as `CFCacheStore` (`memo.shellMs`,
761
+ default 2000; `memo.shellMaxBytes`, default 16 MiB) and the same tag-marker
762
+ memo (`memo.markerFreshMs`, default 300; `memo.markerMaxStaleMs`, default
763
+ 2000), one per `cache` handle: create the handle once, as above, or the memos
764
+ never hit. The request that runs `updateTag()`/`revalidateTag()` misses on
765
+ the entries carrying those tags before `expireTag` lands. A shell read's
766
+ marker check sees the invalidation in the process that ran it once its
767
+ markers are written, and in the rest of the region once their marker memo
768
+ refreshes. The tag markers are a regional `cache.set` (only
769
+ `expireTag` is global), so another region serves a shell it memoized before
770
+ the invalidation until its window passes, where without the memo `expireTag`
771
+ removes it within about 300 ms; a platform `expireTag` issued outside rango is
772
+ likewise seen when the window passes. The fresh-reads cookie (`Max-Age` 3 s)
773
+ sends the mutating user's next requests past both memos, and `expireTag` is
774
+ global, so they read fresh in any region.
775
+
640
776
  Writes over 2 MB (the platform limit, `maxItemBytes`) are skipped, and tags
641
777
  beyond the per-item cap are dropped with a warning. The official client swallows
642
778
  `expireTag` failures, so treat `updateTag()` on Vercel as best-effort. See
@@ -654,6 +790,7 @@ another, request-scoped APIs are guarded inside a cache scope:
654
790
  | `cookies()` / `headers()` (read or write) | **throws** — request-scoped, would poison the entry |
655
791
  | Response writes: `ctx.headers.set()`, `setCookie()`, `setStatus()`, `onResponse()` | **throws** — response side effects lost on a hit |
656
792
  | `ctx.get(var)` where the var is `{ cache: false }` | **throws** on read |
793
+ | `ctx.theme`, `getRequestContext().theme` | **throws** — the theme is the visitor's cookie |
657
794
  | `ctx.set(var, value)` for a cacheable var | allowed (children are cached too) |
658
795
  | Any of the above **inside a registered loader** (`loader(...)`) | **allowed** — loaders always run fresh |
659
796
 
@@ -661,6 +798,12 @@ A loader body invoked from a handler with `await ctx.use(Loader)` may read
661
798
  `cookies()`/`headers()`, but its response writes still throw: on a hit the
662
799
  handler is skipped, so that loader never runs.
663
800
 
801
+ A loader bound with its **own** `cache()` (`loader(Def, () => [cache({...})])`)
802
+ stores its value, and its key does not inherit the route's. A miss whose body
803
+ read `cookies()`, `headers()` or a non-cacheable `ctx.get()` fails unless the
804
+ binding has a `key()` or its store a `keyGenerator`, which must then include
805
+ the value (`/loader` → "Cache Key").
806
+
664
807
  **Tainted objects.** Request-scoped objects (`ctx`, `env`, `request`) carry an
665
808
  internal taint symbol so they are excluded from `"use cache"` cache keys. The
666
809
  `cache()` scope is tracked in async-local state and deliberately ends inside
@@ -680,10 +823,12 @@ is **not** guarded. `ctx.use()` is a server-side escape hatch for non-rendered
680
823
  uses (set a ctx var, make a routing decision); never render its result inside a
681
824
  cached handler.
682
825
 
683
- This is the **consumption-lane rule**, and it holds identically for every
684
- shared artifact — `cache()`, `"use cache"`, and the PPR shell (`/ppr`):
685
- handler consumption = baked copy with identity reads permitted; client-side
686
- `useLoader` = live. It is stated once in `/rango` → Invariants.
826
+ This is the **consumption-lane rule**, and it holds for every shared
827
+ artifact — `cache()`, `"use cache"`, and the PPR shell (`/ppr`): handler
828
+ consumption = baked copy; client-side `useLoader` = live. The tiers differ on
829
+ identity reads inside a handler-consumed loader: `cache()` and `"use cache"`
830
+ permit them (the leak above), while a PPR shell capture refuses them and the
831
+ route stays uncached. It is stated once in `/rango` → Invariants.
687
832
 
688
833
  ```typescript
689
834
  // WRONG — throws: cookies() read directly in a cached handler
@@ -729,8 +874,9 @@ See `/cache-guide` for the full decision guide and the `cache()` vs `"use cache"
729
874
 
730
875
  ## Nested Cache Boundaries
731
876
 
732
- An inner boundary overrides the settings of the outer one for its subtree;
733
- `cache(false)` turns caching off for a subtree:
877
+ An inner boundary overrides the settings of the outer one for its subtree
878
+ (`key` composes instead, see below); `cache(false)` turns caching off for a
879
+ subtree:
734
880
 
735
881
  ```typescript
736
882
  cache({ ttl: 300 }, () => [
@@ -746,6 +892,80 @@ cache({ ttl: 300 }, () => [
746
892
  ]);
747
893
  ```
748
894
 
895
+ On a `ppr` route the opt-out also means no shell: `cache(false)`, or a
896
+ `condition()` that returns false, renders that route (or that request) like a
897
+ cache miss, with no shell served and none captured. A `ppr` route under a
898
+ layout's `cache()` therefore cannot opt out of that `cache()` and still get a
899
+ shell (`/ppr`).
900
+
901
+ ### Keys nest: a partition covers the whole subtree
902
+
903
+ A `key()` partitions every record under its `cache()`. A nested `cache()`
904
+ keys its records within that partition, so a request in one partition never
905
+ HITs another partition's inner record:
906
+
907
+ ```typescript
908
+ const tier = (ctx) =>
909
+ ctx.request.headers.get("x-tier") === "gold" ? "gold" : "silver";
910
+
911
+ cache({ ttl: 300, key: (ctx) => `tier:${tier(ctx)}` }, () => [
912
+ layout(TierLayout, () => [
913
+ // No key() of its own: the outer key() result, then this boundary's own
914
+ // default key ("tier%3Agold|doc%3Aexample.com%2Fpricing"), so /pricing
915
+ // and /faq keep their own records
916
+ cache({ ttl: 60 }, () => [
917
+ path("/pricing", PricingPage, { name: "pricing" }),
918
+ path("/faq", FaqPage, { name: "faq" }),
919
+ ]),
920
+
921
+ // Its own key() composes with the outer one ("tier%3Agold|v%3Ab")
922
+ cache(
923
+ { ttl: 60, key: (ctx) => `v:${ctx.searchParams.get("v") ?? "a"}` },
924
+ () => [path("/plans", PlansPage, { name: "plans" })],
925
+ ),
926
+ ]),
927
+ ]);
928
+ ```
929
+
930
+ The parts are joined by `|`, outermost first, each URI-encoded:
931
+
932
+ | Boundary the route sits in | Record key |
933
+ | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
934
+ | `cache({ ttl })`, no `key`, under a keyed one | The chain's `key()` results, then this boundary's default key (its store's `keyGenerator` result, else the default key) |
935
+ | `cache({ key })` under a keyed one | The chain's `key()` results |
936
+ | deeper nesting | Composes the same way; only the innermost boundary's missing `key` adds its default key |
937
+ | `cache(false)` | No caching, as before; a `cache()` re-enabled below it stays in the partition |
938
+ | a single keyed `cache()`, or no `key` anywhere | Its raw `key()` result, or the default key, as before |
939
+
940
+ - The encoding is unambiguous among composed keys: an encoded part holds no
941
+ `|`, so no two partitions, and no two inner keys, compose to the same
942
+ record key.
943
+ - A single keyed `cache()` stores its `key()` result as is, so a raw result
944
+ can equal a composed key: a header value `gold|doc%3Aexample.com%2Fpricing`
945
+ returned raw names the record gold's pricing page keeps under the inner
946
+ `cache()` in the example above. Never return raw
947
+ request input from `key()`: normalize it to the values you serve, prefix
948
+ it (`tier:${value}`), or encode it. A composed key holds no `:` (every
949
+ part is URI-encoded), so a prefixed result never equals one.
950
+ - An inner boundary without `key` keeps everything its default key tells
951
+ apart (path, params, search, document vs navigation), so its routes never
952
+ share a record, even when the outer `key()` names no route.
953
+ - A boundary with its own `key()` skips the store's `keyGenerator`, as a
954
+ single `key()` does.
955
+ - Each `key()` runs once per request, however many boundaries and lookups use
956
+ it.
957
+ - A response route's entry (`path.json()` and the other response routes) is
958
+ keyed the same way. A `ppr` route's shell is partitioned by the chain's
959
+ `key()` results alone, since the shell key already carries the URL (plus the
960
+ store `keyGenerator`'s result for an inner boundary without `key`).
961
+ - To share an inner cache across partitions, move it outside the keyed
962
+ `cache()`.
963
+
964
+ Before issue #970, the innermost boundary alone decided the key: an inner
965
+ `cache()` without `key` wrote under the default key and an inner `key()`
966
+ dropped the outer partition, so a silver visitor could HIT what a gold
967
+ visitor's request had cached.
968
+
749
969
  ## Custom Cache Store
750
970
 
751
971
  Create a dedicated store for specific routes:
@@ -765,6 +985,16 @@ A per-boundary store becomes reachable by `updateTag()`/`revalidateTag()` only
765
985
  once that boundary has been matched in the current process. For data you
766
986
  invalidate by tag, prefer the app-level store.
767
987
 
988
+ If you implement `SegmentCacheStore` yourself, `invalidateTags(tags)` is called
989
+ synchronously inside the invalidating request, and `revalidateTag()` does not
990
+ await it. For that request to read its own writes, record the tags and the
991
+ time as invalidated in request-scoped state your reads check (a `WeakMap`
992
+ keyed by the object `getRequestContext()` returns) before the first `await`,
993
+ then start the durable write. Only let that state turn hits into misses, and
994
+ only for entries written at or before that time. A store that skips this
995
+ still works, but the request that ran `revalidateTag()` can read invalidated
996
+ entries until the durable write lands.
997
+
768
998
  ## Complete Example
769
999
 
770
1000
  ```typescript
@@ -251,7 +251,7 @@
251
251
  },
252
252
  {
253
253
  "name": "testing",
254
- "description": "Test @rangojs/router apps — unit (loaders, middleware, server actions, handles, transition/clientUrls predicates), client components (renderRoute), RSC/Flight (renderServerTree, renderHandler), integration (dispatch), and e2e (dev+prod parity, progressive enhancement). Use when writing a unit test for a loader, middleware, action, or handler, setting up vitest for a Rango app, or testing a route end-to-end in dev and production.",
254
+ "description": "Test @rangojs/router apps — unit (loaders, middleware, server actions, handles, transition/clientUrls predicates), client components (renderRoute), RSC/Flight (renderServerTree, renderHandler, serveShellRequest for PPR shells), integration (dispatch), and e2e (dev+prod parity, progressive enhancement). Use when writing a unit test for a loader, middleware, action, or handler, setting up vitest for a Rango app, or testing a route end-to-end in dev and production.",
255
255
  "argumentHint": "[layer]",
256
256
  "path": "skills/testing/SKILL.md"
257
257
  },
@@ -268,8 +268,9 @@ handler parity — `ctx.use(Meta)({ title })` pushes exactly like a handler
268
268
  push. Reads moved to `ctx.get(handle)`, gated behind `await ctx.rendered()`.
269
269
  Delivery follows the race model: pushes that settle before the handler barrier
270
270
  ride the SSR handle snapshot; later ones stream and apply post-hydration
271
- (document loads) or progressively (navigations). See `/loader` for the full
272
- contract.
271
+ (document loads) or progressively (navigations). On a `ppr` shell HIT a loader
272
+ without `ssr: false` never ran during the capture, so its pushes are never in
273
+ the shell and always apply client-side. See `/loader` for the full contract.
273
274
 
274
275
  ## `ssr: false` — the SSR-completeness opt-in
275
276
 
@@ -309,10 +310,14 @@ Constraints:
309
310
  - Under a `ppr` group route the flag is the bake lane, exactly as on a server
310
311
  route (`/ppr` → The loader lane rule).
311
312
 
312
- Every document load pays the flagged loader's latency before first byte —
313
- that is the point, but it is a real cost. Keep flagged loaders fast (existence
314
- checks before expensive fetches) and flag the loaders that need it, not the
315
- route.
313
+ Every document render that is not a `ppr` shell HIT pays the flagged
314
+ loader's latency before first byte — that is the point, but it is a real cost.
315
+ On a shell HIT the shell flushes first: a flagged loader whose value has no
316
+ promises is served from the shell and does not run (unless the capture marked
317
+ it to run: see `/ppr` → On a shell HIT), and one whose value
318
+ carries promises runs on every HIT to fill its holes. Keep flagged loaders
319
+ fast (existence checks before expensive fetches) and flag the loaders that
320
+ need it, not the route.
316
321
 
317
322
  Pinned end to end by dev + production e2e in the router repository.
318
323
 
@@ -168,6 +168,21 @@ family runs L1-only (each colo captures and serves its own shell). A tagged
168
168
  shell with neither `kv` nor `tagPurge` logs a warning once and expires only by
169
169
  ttl+swr. See `/caching` and `/ppr`.
170
170
 
171
+ Shell reads are stale-while-revalidate through per-isolate memos: a warm
172
+ isolate serves a shell it read within `memo.shellMs` (default 2000) from
173
+ memory, and with `kv` it checks tag markers through a memo that is used as is
174
+ for `memo.markerFreshMs` (default 1000) and while it refreshes in the
175
+ background up to `memo.markerMaxStaleMs` (default 10000). After
176
+ `updateTag()`/`revalidateTag()` the response sets the fresh-reads cookie
177
+ (`rango-state-fresh` by default), so the mutating user's next requests skip
178
+ the memos. That makes them fresh in the colo that ran the mutation; other
179
+ colos converge once KV propagates the marker and their cached marker
180
+ (`tagCacheTtl`) expires. In purge mode without `kv`, another isolate's memo
181
+ keeps serving a purged shell until `memo.shellMs` passes. Set
182
+ `memo: { shellMs: 0, markerFreshMs: 0 }` to remove the memo delay for every
183
+ user's next request; cross-colo KV propagation (and `tagCacheTtl`) still
184
+ applies. Details: `/caching` → "The fresh-reads cookie".
185
+
171
186
  ## Tracing on Workers
172
187
 
173
188
  `createCloudflareTracing()` from `@rangojs/router/cloudflare` emits the
@@ -373,15 +373,17 @@ old-deployment request pinning.
373
373
  The `@rangojs/router/testing/*` entry points give you `runLoader`,
374
374
  `runMiddleware`, `runInRequestContext` (server actions), `dispatch` (base
375
375
  `./testing`), `renderHandler` and real Flight rendering (`renderServerTree`,
376
- `findClientBoundaries`, `findElements`) from `./testing/flight`, `renderRoute`
377
- from `./testing/dom`, a vitest preset from `./testing/vitest`, and a Playwright
378
- e2e harness with dev/prod parity helpers (`parityDescribe`, `expectParity`) from
379
- `./testing/e2e`. You can unit-test a loader, a middleware, an action, or an RSC
380
- handler in isolation. Next.js and Waku ship no official primitives for testing
381
- server components/handlers; TanStack documents testing patterns (build your own
382
- harness from `createRouter`/`createMemoryHistory`) but ships no testing package,
383
- and nothing at the RSC-handler level. See the [testing skill](../../testing/SKILL.md)
384
- and [testing.md](https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/testing.md).
376
+ `findClientBoundaries`, `findElements`) plus a real PPR shell capture and HIT
377
+ (`serveShellRequest`, `resetShellTestState`) from `./testing/flight`,
378
+ `renderRoute` from `./testing/dom`, a vitest preset from `./testing/vitest`, and
379
+ a Playwright e2e harness with dev/prod parity helpers (`parityDescribe`,
380
+ `expectParity`) from `./testing/e2e`. You can unit-test a loader, a middleware,
381
+ an action, or an RSC handler in isolation. Next.js and Waku ship no official
382
+ primitives for testing server components/handlers; TanStack documents testing
383
+ patterns (build your own harness from `createRouter`/`createMemoryHistory`) but
384
+ ships no testing package, and nothing at the RSC-handler level. See the
385
+ [testing skill](../../testing/SKILL.md) and
386
+ [testing.md](https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/testing.md).
385
387
 
386
388
  ### Bundle discipline
387
389
 
@@ -534,10 +536,13 @@ There are two distinct things named "revalidate", and the split is deliberate:
534
536
  tabs, and pagination. See
535
537
  [shallow-navigation.md](https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/design/shallow-navigation.md).
536
538
 
537
- Both are separate from `revalidateTag()`/`updateTag()`, which hard-purge tagged
538
- cache entries. `updateTag()` is awaitable for read-your-own-writes;
539
- `revalidateTag()` schedules the same invalidation in the background. Neither API
540
- selects a client segment to render. Next.js offers `router.refresh()` (and
539
+ Both are separate from `revalidateTag()`/`updateTag()`, which evict tagged
540
+ cache entries; the request that calls either reads its own writes.
541
+ `updateTag()` also awaits the durable write; `revalidateTag()` schedules it in
542
+ the background. The mutating user's next requests skip the stores' per-isolate
543
+ PPR shell memos via a fresh-reads cookie, and other users converge within the
544
+ memo window and the platform's own propagation. Neither API selects a client
545
+ segment to render. Next.js offers `router.refresh()` (and
541
546
  `refresh()` from `next/cache`) plus path/tag cache invalidation, but no per-segment
542
547
  render predicate and no typed action discrimination — and its invalidations clear
543
548
  the whole client cache rather than selecting a segment. TanStack's `shouldReload`/`router.invalidate()` is the nearest
@@ -620,18 +625,22 @@ this resource-aware policy layer.
620
625
 
621
626
  `createLoader(fn)` / `loader()` define **live-by-default** data units: they are
622
627
  excluded from an enclosing segment cache and resolve on every request unless the
623
- loader itself explicitly opts into `cache()`. They may safely read `cookies()`,
624
- `headers()`, request context, and `env` because loader execution is outside the
625
- cached shell. Loaders run in parallel, stream independently under `loading()`
626
- boundaries, compose server-side via `ctx.use(OtherLoader)`, and carry
627
- route-level authority: they can throw `notFound()`/`redirect()`, WRITE handles
628
- (`ctx.use(Meta)({ title })` — data-derived page metadata pushed from the data's
629
- producer), read handle data after the render settles (`ctx.get(handle)` behind
630
- `await ctx.rendered()`), and opt into `loader(Def, { ssr: false })`
631
- so a document render awaits them before first flush (deterministic SSR'd data,
632
- meta, and 404 status). Reads happen through `useLoader` in a client component
633
- (including its SSR pass) or `useFetchLoader` for standalone client fetches. "Fetchable" loaders are callable
634
- endpoints with their own middleware and GET/POST/PUT/PATCH/DELETE bodies.
628
+ loader itself explicitly opts into `cache()`. A loader without `ssr: false` may
629
+ safely read `cookies()`, `headers()`, request context, and `env` because its
630
+ execution is outside the cached shell; a loader with its own `cache()` must put
631
+ those reads in its `key()`, or its miss fails and stores nothing. On a `ppr`
632
+ route an `ssr: false` loader bakes into the shell, so an identity read there
633
+ refuses the capture. Loaders run in parallel, stream independently under
634
+ `loading()` boundaries, compose server-side via `ctx.use(OtherLoader)`, and
635
+ carry route-level authority: they can throw `notFound()`/`redirect()`, WRITE
636
+ handles (`ctx.use(Meta)({ title })` — data-derived page metadata pushed from the
637
+ data's producer), read handle data after the render settles (`ctx.get(handle)`
638
+ behind `await ctx.rendered()`), and opt into `loader(Def, { ssr: false })` so a
639
+ document render awaits them before first flush (deterministic SSR'd data, meta,
640
+ and 404 status). Reads happen through `useLoader` in a client component
641
+ (including its SSR pass) or `useFetchLoader` for standalone client fetches.
642
+ "Fetchable" loaders are callable endpoints with their own middleware and
643
+ GET/POST/PUT/PATCH/DELETE bodies.
635
644
 
636
645
  One sharp edge is worth stating because the distinction matters: a cached handler
637
646
  can call `await ctx.use(Loader)`, but if it renders that result inline, it bakes the
@@ -681,7 +690,7 @@ a fully prerendered route. Caching loader data is a separate, explicit opt-in on
681
690
  that loader.
682
691
 
683
692
  The mechanism is concrete: a prerendered route serves its baked Flight shell from
684
- the store, then `resolveLoadersOnly()` resolves the loaders through their own
693
+ the store, then the router resolves the loaders through their own
685
694
  freshness policy at request time and merges their segments into that replayed
686
695
  shell. With the default policy you get a cached/prerendered shell plus live data,
687
696
  automatically, with no per-route static-vs-dynamic decision. The loader is the
@@ -692,7 +701,8 @@ designated live data slot in a cache-first RSC tree.** Several properties follow
692
701
  that no loader-before-render has:
693
702
 
694
703
  - it is the **cache-safety escape hatch** — the only place request-coupled reads
695
- are allowed inside a cache scope, because it is the part guaranteed to re-run;
704
+ are allowed inside a cache scope, because it is the part guaranteed to re-run
705
+ (a loader without `ssr: false` and without its own unkeyed `cache()`);
696
706
  - it is **standalone and composable**, not route-coupled — one `createLoader()`
697
707
  read by many segments, composed via `ctx.use`, or exposed as a fetchable endpoint;
698
708
  - it **streams as a hole, not a gate** — concurrent, Suspense-resolved under
@@ -773,11 +783,13 @@ safe rule remains simple: read request-specific context at the live point of use
773
783
  normally a loader, rather than deriving it outside and carrying it into a cached
774
784
  shell.
775
785
 
776
- This is where the taint and loader designs meet. Loaders are the sanctioned dynamic
777
- holes: they run outside the enclosing segment cache and may read the current
778
- request, while the shared shell remains protected. Next.js also isolates runtime
779
- APIs from ordinary `"use cache"`, and React's optional taint APIs protect values at
780
- the server-to-client serialization boundary. Those are useful but different
786
+ This is where the taint and loader designs meet. Loaders are the sanctioned
787
+ dynamic holes: they run outside the enclosing segment cache and may read the
788
+ current request (except, on a `ppr` route, an `ssr: false` loader or a loader a
789
+ handler awaits, which the capture runs and refuses on such a read), while the
790
+ shared shell remains protected. Next.js also isolates runtime APIs from ordinary
791
+ `"use cache"`, and React's optional taint APIs protect values at the
792
+ server-to-client serialization boundary. Those are useful but different
781
793
  contracts; they do not provide Rango's route-aware tainted-argument keying,
782
794
  non-cacheable typed context variables, handle capture/replay, and loader escape
783
795
  path as one system. TanStack Start and Waku leave this boundary primarily to