@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
@@ -0,0 +1,188 @@
1
+ # Testing a PPR shell — serveShellRequest
2
+
3
+ **Layer:** RSC unit (react-server project) · **Import:** `@rangojs/router/testing/flight` · **DSL it tests:** the `ppr` path option (see `/ppr`)
4
+
5
+ `serveShellRequest(router, url, options?)` serves one GET through your router's production request handler — built as `router.fetch` builds it, with the router's `nonce`, `version`, `cache` config and middleware — and settles every background task the request scheduled before it resolves. So the first request for a `ppr` route is a MISS whose real capture has stored its shell (`putShell` landed), and the next request with the same store is a real HIT: shell read, version gates, the tail replaying the handler output the capture baked (no handler runs), live loaders. A `partial` request is a real client navigation with its `x-rango-ppr-replay` decision. Flight is real.
6
+
7
+ The one stub is the HTML step (the SSR module), because `react-dom/server` does not load under the react-server condition:
8
+
9
+ | SSR step | Under `serveShellRequest` |
10
+ | ------------------ | ------------------------------------------------------------------------------------------------------ |
11
+ | `renderHTML` | passes the Flight stream through: a document body is its Flight payload |
12
+ | `captureShellHTML` | stores, as the prelude, the Flight text the capture rendered before it went quiet; `postponed` is null |
13
+ | `resumeShellHTML` | passes the tail's Flight through: a HIT body is the stored prelude, then the tail's Flight payload |
14
+
15
+ So `prelude` is the shell as captured (Flight text, not HTML): a value the shell froze appears there, and a hole (a live loader under `loading()`) does not.
16
+
17
+ ## API
18
+
19
+ ### Options — `ServeShellRequestOptions`
20
+
21
+ | Field | Type | Meaning |
22
+ | ------------ | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
23
+ | `cacheStore` | `SegmentCacheStore` | The store for this request, in place of the store your `createRouter({ cache })` config returns. The rest of that config (`enabled`, `searchParams`) still applies. Omit it to use the router's own store. |
24
+ | `env` | `TEnv` | Bindings, as `router.fetch(request, { env })` passes them. A function `cache` config receives them. |
25
+ | `headers` | `HeadersInit` | Request headers (e.g. `cookie`). A document request defaults `accept` to `text/html`. |
26
+ | `partial` | `true \| { from?: string; segments?: string[] }` | Serve the navigation request the browser sends instead of a document GET: from `from` (default the site root) with `segments` mounted (default none). |
27
+
28
+ ### Returns — `ServeShellRequestResult`
29
+
30
+ | Field | Type | Meaning |
31
+ | -------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
32
+ | `shellStatus` | `"HIT" \| "MISS" \| null` | `x-rango-shell`; null when the serve path did not consider the request (not a `ppr` route, a nonce'd request, a partial request). |
33
+ | `replayStatus` | `PprReplayStatus \| null` | `x-rango-ppr-replay` on a partial request: `{ outcome: "HIT", freshness }` or `{ outcome: "BYPASS", reason }`. |
34
+ | `prelude` | `string \| undefined` | The prelude a HIT served (the capture's Flight text). `undefined` unless `shellStatus` is `"HIT"`. |
35
+ | `flight` | `string \| undefined` | The Flight payload this request rendered: a HIT's tail, a document render, or a partial response. `undefined` when no Flight rendered (a redirect, a middleware response). |
36
+ | `key` | `string` | The shell key the serve path resolved for a document it read (MISS or HIT), request partition included. Otherwise (no `ppr`, a partial request) the URL's key without a partition. |
37
+ | `readEntry` | `() => Promise<ShellCacheEntry \| null>` | Reads the document entry under `key` from the request's store (a passive `getShell`). A read: on a store with a shell memo it warms the memo, so call it after the reads you count. |
38
+ | `response` | `Response` | Status and headers. Its body is already read. |
39
+ | `body` | `string` | The body text. |
40
+
41
+ ### `resetShellTestState(): Promise<void>`
42
+
43
+ A worker keeps some PPR state across requests, and so does the test process across tests: the capture's stampede guard and backoff (a refused capture backs its URL off for later tests too), the capture's and the serve path's once-per-key warnings, the build-shell manifest memo, and `CFCacheStore`'s isolate memos (shells, tag markers, tag hints), which every `CFCacheStore` shares by namespace and URL — a later test's first request can be a HIT from an earlier test's shell. `resetShellTestState()` clears all of it. Call it in `beforeEach`, never while a request is in flight. `VercelCacheStore`'s memos live on the `cache` handle you pass it: a new handle per test starts empty.
44
+
45
+ ## Recipe
46
+
47
+ ```ts
48
+ // test/product-shell.rsc-test.tsx — in the react-server project (./setup.md)
49
+ import { beforeEach, expect, it } from "vitest";
50
+ import { createRouter, updateTag, urls } from "@rangojs/router";
51
+ import { MemorySegmentCacheStore } from "@rangojs/router/cache";
52
+ import { runInRequestContext } from "@rangojs/router/testing";
53
+ import {
54
+ resetShellTestState,
55
+ serveShellRequest,
56
+ } from "@rangojs/router/testing/flight";
57
+ import { ProductPage } from "../src/pages/product"; // renders the product name
58
+ import { StockLoader } from "../src/loaders/stock"; // live stock count
59
+
60
+ const router = createRouter().routes(
61
+ urls(({ path, loader, loading }) => [
62
+ path("/product/:id", ProductPage, { name: "product", ppr: { tags: ["catalog"] } }, () => [
63
+ loader(StockLoader),
64
+ loading(<p>Checking stock…</p>),
65
+ ]),
66
+ ]),
67
+ );
68
+
69
+ beforeEach(() => resetShellTestState());
70
+
71
+ it("serves the captured shell and fills the stock hole live", async () => {
72
+ const cacheStore = new MemorySegmentCacheStore();
73
+
74
+ const miss = await serveShellRequest(router, "/product/1", { cacheStore });
75
+ expect(miss.shellStatus).toBe("MISS"); // a normal render; the capture stored the shell
76
+
77
+ const hit = await serveShellRequest(router, "/product/1", { cacheStore });
78
+ expect(hit.shellStatus).toBe("HIT");
79
+ expect(hit.prelude).toContain("Widget"); // frozen at capture
80
+ expect(hit.prelude).not.toContain("in stock"); // the hole
81
+ expect(hit.flight).toContain("in stock"); // rendered by this request
82
+
83
+ await runInRequestContext(() => updateTag("catalog"), { cacheStore });
84
+ const recapture = await serveShellRequest(router, "/product/1", { cacheStore });
85
+ expect(recapture.shellStatus).toBe("MISS");
86
+ });
87
+
88
+ it("replays the captured segments on a client navigation", async () => {
89
+ const cacheStore = new MemorySegmentCacheStore();
90
+ await serveShellRequest(router, "/product/2", { cacheStore });
91
+
92
+ const nav = await serveShellRequest(router, "/product/2", {
93
+ cacheStore,
94
+ partial: { from: "/" },
95
+ });
96
+ expect(nav.replayStatus).toEqual({ outcome: "HIT", freshness: "fresh" });
97
+ });
98
+ ```
99
+
100
+ A store built per request from a function `cache` config gets the request's execution context (`ctx`), so its own `waitUntil` writes (`CFCacheStore({ ctx })`) settle with the call. A store built outside the request with its own `waitUntil` (`VercelCacheStore`'s option) is not settled by the call. For `CFCacheStore`, stub the `caches` global and pass a KV double in `env`; for `VercelCacheStore`, pass a `cache` double. A store's shell memo then behaves as in production (count your double's reads across two HITs).
101
+
102
+ ## A HIT runs no handler
103
+
104
+ A HIT replays what the capture's handlers produced — their elements, the promises they passed or pushed, their handle values — and runs only middleware and loaders. Count runs to pin it:
105
+
106
+ ```ts
107
+ let pageRuns = 0;
108
+ // the page handler does `pageRuns += 1` and renders `page-run-${pageRuns}`
109
+
110
+ it("a HIT runs no handler", async () => {
111
+ const cacheStore = new MemorySegmentCacheStore();
112
+ await serveShellRequest(router, "/product/3", { cacheStore }); // MISS + capture
113
+ const baked = pageRuns; // the capture's run
114
+
115
+ const hit = await serveShellRequest(router, "/product/3", { cacheStore });
116
+ expect(hit.shellStatus).toBe("HIT");
117
+ expect(pageRuns).toBe(baked);
118
+ expect(hit.flight).toContain(`page-run-${baked}`);
119
+ });
120
+ ```
121
+
122
+ ## Request-partitioned shells
123
+
124
+ A route whose `cache()` record is partitioned by the request (`cache({ key })`, or the store's `keyGenerator`) has a shell per partition. Serve each partition with the request headers or cookies its key reads; the result's `key` is the key the serve path resolved, partition included, and `readEntry()` reads it:
125
+
126
+ ```ts
127
+ // cache({ ttl: 300, key: (ctx) => `tier:${ctx.request.headers.get("x-tier")}` }, () => [
128
+ // path("/pricing", PricingPage, { ppr: true }),
129
+ // ])
130
+ const tier = (name: string) => ({ cacheStore, headers: { "x-tier": name } });
131
+
132
+ await serveShellRequest(router, "/pricing", tier("gold")); // MISS + capture
133
+ const silver = await serveShellRequest(router, "/pricing", tier("silver"));
134
+ expect(silver.shellStatus).toBe("MISS"); // its own capture, never gold's shell
135
+ const gold = await serveShellRequest(router, "/pricing", tier("gold"));
136
+ expect(gold.shellStatus).toBe("HIT");
137
+ expect(gold.key).toBe(shellCacheKey("/pricing", undefined, "tier:gold"));
138
+ expect(await gold.readEntry()).not.toBeNull();
139
+ ```
140
+
141
+ Under nested keyed `cache()` boundaries the partition is their `key()` results composed, outermost first (see `/caching`, "Keys nest"). Pass them to `shellCacheKey` as an array and it composes them the same way:
142
+
143
+ ```ts
144
+ // cache({ key: tierKey }, () => [
145
+ // layout(TierLayout, () => [
146
+ // cache({ key: (ctx) => `v:${variantOf(ctx)}` }, () => [path("/plans", PlansPage, { ppr: true })]),
147
+ // ]),
148
+ // ])
149
+ expect(result.key).toBe(
150
+ shellCacheKey("/plans", undefined, ["tier:gold", "v:a"]),
151
+ );
152
+ ```
153
+
154
+ A partial request has no HTML step, so its `key` is the URL's key without a partition: read a partitioned route's entry from a document request's result.
155
+
156
+ ## Handles and loader data
157
+
158
+ The result carries no structured `handles` map (unlike `renderHandler`): a handle value and loader data ride the payload, so you assert them as text in `prelude` and `flight`. A value the handler pushed is in the prelude and in every HIT's tail; a live loader's data is only in the tail. No public helper decodes a payload string into values: `normalizeFlight` and the `flightMatchers` (`toMatchFlight`) work on the text too.
159
+
160
+ ```ts
161
+ // The handler pushed ctx.use(Meta)({ title: "Widget - Shop" })
162
+ expect(hit.prelude).toContain('"title":"Widget - Shop"');
163
+ expect(hit.flight).toContain('"title":"Widget - Shop"');
164
+ ```
165
+
166
+ ## Caveats
167
+
168
+ What the HTML stub cannot reproduce, so keep it in e2e:
169
+
170
+ - The prelude's `<body` sanity gate. A route whose production capture refuses for a root postpone (a live loader read with no `loading()` or `<Suspense>` above it) stores a shell here.
171
+ - The capture deadline. `ppr.captureTimeout` (default 15s) bounds the whole capture: the match, the handler output, then the shell render. A capture that runs out of it stores nothing and is not retried in place, here as in production, so the call waits one deadline: give such a route a short `captureTimeout` in a test (Vitest's default test timeout is 5s). One difference: when the handler output settled but the shell render does not go quiet in what is left, the stub returns no shell, while production goes on to its abort and can still store the prelude rendered by then.
172
+ - SSR render errors. In production a fizz render error during the capture refuses it (#915). The stub renders no HTML and reports none, so a shell whose client component throws during SSR is stored here. A server component that throws still refuses the capture, as in production.
173
+ - Fizz resume of the holes, bootstrap scripts, nonce injection, and browser resume.
174
+
175
+ Other caveats:
176
+
177
+ - A HIT's tail carries replayed segments as Flight fragments inside JSON strings, so their quotes are escaped (`\"`). Match plain text, or unescape before matching JSON-shaped text.
178
+ - Call `resetShellTestState()` in `beforeEach` (see above). Without it, a URL a previous test left backed off, or a shell memoized by another `CFCacheStore`, changes what the next test sees.
179
+ - `readEntry()` reads the document entry under `key`. A partial request that finds no entry captures a navigation-only entry, which is stored apart from it.
180
+ - `updateTag()` needs a request context with the store: call it through `runInRequestContext(fn, { cacheStore })` or your app's own endpoint (`dispatch`). A store refuses a capture that starts in the invalidation's millisecond (the invalidation wins) and backs the key off; `serveShellRequest` starts each request in a later millisecond than the call, so an invalidation made before the call cannot collide with it.
181
+ - GET only. Server actions are not served; test their effects with `runInRequestContext`.
182
+ - Same setup as every Flight test: the react-server condition and `rangoTestAliases()` (see [`./setup.md`](./setup.md)).
183
+
184
+ ## See also
185
+
186
+ - `/ppr` — the DSL this tests; `/caching` — stores and tags
187
+ - Siblings: [`./cache-prerender.md`](./cache-prerender.md) (the shell-status helpers and the e2e recipe), [`./render-handler.md`](./render-handler.md)
188
+ - Long-form prose: [docs/testing.md](https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/testing.md) — section "serveShellRequest — a real PPR capture and HIT"
@@ -86,10 +86,13 @@ from `@rangojs/router/theme` in `<head>` instead. It takes the resolved config
86
86
  are rejected with a console warning. Both are typed optional because they only
87
87
  exist when `theme` is configured.
88
88
 
89
+ `ctx.theme` is the visitor's theme cookie, so a handler reads it only on a
90
+ route whose output is rendered per request:
91
+
89
92
  ```typescript
90
93
  import type { Middleware } from "@rangojs/router";
91
94
 
92
- // In a handler
95
+ // In a handler on a route without ppr or cache()
93
96
  path("/settings", (ctx) => {
94
97
  const currentTheme = ctx.theme; // "light" | "dark" | "system" | undefined
95
98
  return <SettingsPage theme={currentTheme} />;
@@ -105,9 +108,41 @@ export const themeFromQuery: Middleware = async (ctx, next) => {
105
108
  };
106
109
  ```
107
110
 
108
- Loaders do not get `ctx.theme` or `ctx.setTheme`. A loader that needs the
109
- theme reads the cookie directly (loaders run fresh on every request, so the
110
- read is safe):
111
+ ### On `ppr` and `cache()` routes
112
+
113
+ A `ppr` shell and a `cache()` entry are shared by every visitor, so a handler
114
+ there must not read the visitor's theme. `ctx.theme` (handler and middleware)
115
+ and `getRequestContext().theme` are guarded like `cookies()`:
116
+
117
+ | Where the theme is read | What happens |
118
+ | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
119
+ | a handler on a `ppr` route | The shell capture is refused (warned once per URL); every request stays a MISS |
120
+ | inside a `cache()` boundary | Throws on a cache miss, like `cookies()` |
121
+ | inside a `"use cache"` function (handler or middleware) | Throws; read it outside and pass it in as an argument |
122
+ | after `ctx.dynamic()` on `ppr` | Allowed: that render is never captured |
123
+ | middleware `ctx.set()` of the theme, read by a handler | Not guarded: the first visitor's theme bakes into the shell or `cache()` entry, like a session object |
124
+ | a route with no `ppr` or `cache()` above it, or middleware | Allowed: rendered per request |
125
+ | a loader with its own `cache()` and no `key()` | The miss fails and stores nothing (#972), like a `cookies()` read there; key the theme |
126
+
127
+ The theme getters are read-only and non-enumerable: `{ ...ctx }` and
128
+ `Object.assign({}, ctx)` do not carry `theme`, and assigning it throws.
129
+
130
+ `ctx.setTheme()` throws in a handler on these routes too, like any response
131
+ write there; call it from middleware.
132
+
133
+ Read the theme where it is per request instead:
134
+
135
+ - `useTheme()` in a client component. The `<html>` class is right before
136
+ paint (the theme script sets it). On a `ppr` HIT the shell carries the
137
+ no-cookie default (`defaultTheme`), whoever captured it: a visitor with no
138
+ stored theme sees the default, and one with a stored theme sees it after
139
+ hydration.
140
+ - A live loader (no `ssr: false`). Loaders do not get `ctx.theme` or
141
+ `ctx.setTheme`; read the cookie directly. A live loader runs on every
142
+ request, HITs included, and on a `ppr` route needs `loading()` or an inline
143
+ `<Suspense>` above its reader. A bake-lane loader (`ssr: false`) runs at
144
+ capture, where this `cookies()` read refuses the capture, and so does a
145
+ loader a handler awaits with `ctx.use()`.
111
146
 
112
147
  ```typescript
113
148
  import { cookies, createLoader } from "@rangojs/router";
@@ -118,6 +153,19 @@ export const ThemedLoader = createLoader(async () => {
118
153
  });
119
154
  ```
120
155
 
156
+ If you bind it with its own `cache()`, the entry is shared across users, so the
157
+ miss fails unless it has a `key()`. Any `key()` switches that check off, so
158
+ make it include the theme:
159
+
160
+ ```typescript
161
+ loader(ThemedLoader, () => [
162
+ cache({
163
+ ttl: 300,
164
+ key: () => `themed:${cookies().get("theme")?.value ?? "system"}`,
165
+ }),
166
+ ]);
167
+ ```
168
+
121
169
  ## Client
122
170
 
123
171
  ```tsx
@@ -103,7 +103,8 @@ interface PaginationData {
103
103
  }
104
104
  export const Pagination = createVar<PaginationData>();
105
105
 
106
- // Non-cacheable var — reading inside cache() or "use cache" throws at runtime
106
+ // Non-cacheable var — reading inside cache(), "use cache", or a PPR shell
107
+ // capture throws at runtime
107
108
  interface SessionData {
108
109
  userId: string;
109
110
  }
@@ -115,7 +116,14 @@ export const Session = createVar<SessionData>({ cache: false });
115
116
  Write-level escalation is also supported: `ctx.set(Var, value, { cache: false })`
116
117
  marks a specific write as non-cacheable even if the var itself is cacheable.
117
118
  "Least cacheable wins" — if either says `cache: false`, the value throws on
118
- read inside `cache()` or `"use cache"`.
119
+ read inside `cache()` or `"use cache"`. On a `ppr` route the read also throws
120
+ anywhere the shell capture waits for it (a handler, a promise it passes or
121
+ pushes, an async server component, an `ssr: false` loader, a loader a handler
122
+ awaits) and refuses the capture: the route keeps rendering without a shell,
123
+ and a warning fires once per key. A loader without `ssr: false` that no
124
+ handler awaits may read it, unless it is bound with its own `cache()` and no
125
+ `key()`: that miss fails, since the entry is shared (`/loader` → "Cache
126
+ Key").
119
127
 
120
128
  ### Producer (handler, layout, or middleware)
121
129
 
@@ -142,6 +142,11 @@ Different functions always produce different cache keys, even for the same route
142
142
  This is important for intercepted routes -- the path handler and intercept handler
143
143
  each have their own `functionId` and therefore their own cache entries.
144
144
 
145
+ The key is only what the function declares: its id and arguments. A call made
146
+ inside a route `cache()` with a `key()` does not inherit that key, so a value
147
+ the route partitions by (a tier header, a session) must be passed in as an
148
+ argument. The same holds for a loader's own `cache()` (`/loader` → "Cache Key").
149
+
145
150
  ### Route context is folded into the key
146
151
 
147
152
  The tainted `ctx` object is excluded from arg serialization (see below), but
@@ -261,7 +266,10 @@ into the shared cache entry like any other computed data. (`cookies()` and
261
266
  `headers()` still throw there: that guard follows the cached body's whole
262
267
  async chain, loaders included.) Same rule across `cache()` and the PPR shell:
263
268
  handler/cached-scope consumption = baked copy, client-side `useLoader` = live
264
- (the consumption-lane rule, `/rango` → Invariants).
269
+ (the consumption-lane rule, `/rango` → Invariants). The PPR shell is stricter
270
+ about the loader's own reads: during a capture, `cookies()`, `headers()`, and a
271
+ `{ cache: false }` variable read inside a handler-consumed loader refuse the
272
+ capture.
265
273
 
266
274
  ### Side-Effect Guards
267
275
 
@@ -504,20 +512,31 @@ built into the server bundle, see `/prerender`.)
504
512
  Entries are tagged by the profile's `tags` plus any runtime `cacheTag(...tags)`
505
513
  calls in the function body. `cacheTag` has two forms: inside a `"use cache"`
506
514
  function it tags that entry; called during a request render outside
507
- `"use cache"` it tags the request's `ppr` shell / document-cache entry instead
508
- (see `/caching` → "Tag-Based Invalidation"). All built-in stores
509
- (`MemorySegmentCacheStore`, `CFCacheStore`, `VercelCacheStore`) index by tag.
510
- Invalidate on demand with `updateTag(...tags)` (awaitable,
511
- read-your-own-writes; for server actions) or `revalidateTag(...tags)`
512
- (background, non-blocking; for route handlers/webhooks). Both hard-purge; the
513
- difference is awaitability, not stale-serving. For `CFCacheStore`, cross-colo
514
- invalidation needs a `kv` namespace (markers live in that same namespace) or
515
- `tagPurge`. The separate `revalidate()` export is the client-update axis (which
516
- segments re-render on a navigation or action), not a cache bust.
517
-
518
- A `"use cache"` entry's tags do not propagate to an enclosing `cache()` segment
519
- entry: invalidating them re-runs the function on the next miss of the outer
520
- boundary, not before (see `/cache-guide` → "Nesting rule").
515
+ `"use cache"` it tags what the render stores: the enclosing route `cache()`
516
+ entry, a cached loader's own entry when called in its body, and the `ppr`
517
+ shell or document-cache entry built from them (on a `ppr` route, only when
518
+ shell material calls it). See `/caching` → "Tag-Based Invalidation". All
519
+ built-in stores (`MemorySegmentCacheStore`, `CFCacheStore`,
520
+ `VercelCacheStore`) index by tag. Invalidate on demand with
521
+ `updateTag(...tags)` (awaitable; for server actions) or
522
+ `revalidateTag(...tags)` (background, non-blocking; for route
523
+ handlers/webhooks). Both evict rather than mark stale, and the request that
524
+ calls either reads its own writes; `updateTag()` also waits for the durable
525
+ write. PPR shell reads on `CFCacheStore`/`VercelCacheStore` go through
526
+ per-isolate memos, which the invalidating user skips via the fresh-reads
527
+ cookie. For `CFCacheStore`, cross-colo invalidation needs a `kv` namespace
528
+ (markers live in that same namespace) or `tagPurge`. The separate
529
+ `revalidate()` export is the client-update control (which segments re-render
530
+ on a navigation or action), not a cache bust.
531
+
532
+ A route `cache()` entry stores the tags of the `"use cache"` reads inside it,
533
+ so `updateTag()` of one evicts the enclosing entry and any `ppr` shell or
534
+ document built from it. A loader's own `cache()` entry does the same for the
535
+ reads in its body. The exception is a `"use cache"` function called inside
536
+ another `"use cache"` function: its tags do not reach the enclosing entry, so
537
+ invalidating one of them neither evicts the outer entry nor keeps this request
538
+ from joining or storing an outer call that started before the invalidation
539
+ ([#980](https://github.com/rangojs/rango/issues/980)).
521
540
 
522
541
  ## Interaction with Other Caching
523
542
 
@@ -531,7 +550,11 @@ boundary, not before (see `/cache-guide` → "Nesting rule").
531
550
  | `Prerender()` | Route segment tree | Build-time | Pre-render known params, optional live fallback |
532
551
 
533
552
  Inside a `ppr` shell, a `"use cache"` value that renders as shell material is
534
- pinned at capture time for the life of that shell (see `/ppr` → Pitfalls).
553
+ pinned at capture time for the life of that shell (see `/ppr` → Pitfalls). A
554
+ hole's own read (in a loader without `ssr: false` under `loading()` or an
555
+ inline `<Suspense>`) returns the current entry, not the shell's copy, unless a
556
+ loader the capture ran (an `ssr: false` loader, or one a handler awaits) read
557
+ the same entry.
535
558
 
536
559
  ## Dev Mode and tests
537
560
 
@@ -89,12 +89,24 @@ import { getCache, waitUntil } from "@vercel/functions";
89
89
 
90
90
  const defaults = { ttl: 60, swr: 300 };
91
91
  const memoryStore = new MemorySegmentCacheStore({ defaults });
92
+ // One handle per process: getCache() resolves the platform cache on every
93
+ // call, and VercelCacheStore keeps its PPR shell and tag-marker memos per
94
+ // handle. After updateTag(), the same process misses at once, the rest of the
95
+ // region once its marker memo refreshes (memo.markerMaxStaleMs, default 2000),
96
+ // and the mutating user at once (the fresh-reads cookie skips the memos).
97
+ // Another region serves a memoized shell until memo.shellMs (default 2000)
98
+ // passes, since the tag markers are regional and only expireTag is global. A
99
+ // platform expireTag issued outside rango writes no marker, so every memo
100
+ // serves the shell until its window passes.
101
+ const runtimeCache = process.env.VERCEL
102
+ ? getCache({ namespace: process.env.VERCEL_DEPLOYMENT_ID })
103
+ : undefined;
92
104
 
93
105
  function resolveCache() {
94
- if (process.env.VERCEL) {
106
+ if (runtimeCache) {
95
107
  return {
96
108
  store: new VercelCacheStore({
97
- cache: getCache({ namespace: process.env.VERCEL_DEPLOYMENT_ID }),
109
+ cache: runtimeCache,
98
110
  waitUntil,
99
111
  defaults,
100
112
  }),
@@ -245,7 +245,7 @@ interface TransitionConfig {
245
245
  Timing follows the route's rendering contract:
246
246
 
247
247
  - On an ordinary route it runs after the route handler during fresh resolution, so `get()` can read handler- and middleware-set context. Cache/prerender hits replay the stored decision.
248
- - On a `ppr` route it is automatically hoisted before route handlers and runs on every match, including runtime-cache, prerender, document-shell, and partial-navigation replay. It can read URL/params/action metadata, `env`, and middleware-set context, but not values set by route handlers. This is what keeps the handler-free PPR fast path available without a second API.
248
+ - On a `ppr` route it is automatically hoisted before route handlers and runs on every match, including runtime-cache, prerender, document-shell, and partial-navigation replay. It can read URL/params/action metadata, `env`, and middleware-set context, but not values set by route handlers, because a shell HIT never runs a handler.
249
249
 
250
250
  Its context mirrors the `revalidate()` predicate args — the same navigation/action metadata — plus `get`/`env` for request-context reads:
251
251
 
@@ -14,6 +14,29 @@
14
14
  /** Default prefix when `stateCookiePrefix` is unset or empty after sanitization. */
15
15
  export const DEFAULT_STATE_COOKIE_PREFIX = "rango-state";
16
16
 
17
+ /**
18
+ * The fresh-reads cookie: the state cookie PREFIX plus this suffix
19
+ * (`rango-state-fresh` by default). A response whose request ran
20
+ * updateTag()/revalidateTag() sets it (HttpOnly, SameSite=Lax, Path=/,
21
+ * Max-Age = the stores' longest memo staleness), and the same user's requests
22
+ * carrying it skip the stores' isolate memos (src/cache/isolate-tag-memo.ts).
23
+ * Keyed by prefix, not by router, so a mutation through one router also
24
+ * reaches the memos another router on the host reads (they often share a
25
+ * store); a router with its own `stateCookiePrefix` gets its own cookie.
26
+ */
27
+ const FRESH_READS_COOKIE_SUFFIX = "-fresh";
28
+
29
+ /**
30
+ * The fresh-reads cookie name for a router's resolved state cookie name
31
+ * (`{prefix}_{routerId}`; the prefix never contains `_`, see
32
+ * router/state-cookie-name.ts).
33
+ */
34
+ export function freshReadsCookieName(stateCookieName: string): string {
35
+ const cut = stateCookieName.indexOf("_");
36
+ const prefix = cut < 0 ? stateCookieName : stateCookieName.slice(0, cut);
37
+ return `${prefix}${FRESH_READS_COOKIE_SUFFIX}`;
38
+ }
39
+
17
40
  /** Internal response header carrying the keepClientCache() directive. */
18
41
  export const KEEP_CACHE_HEADER = "x-rango-keep-cache";
19
42
 
@@ -82,6 +82,29 @@ export function pushHistoryWithIdx(
82
82
  }
83
83
  }
84
84
 
85
+ /**
86
+ * The forced-MISS reload marker (`SHELL_MISS_PARAM` in rsc/shell-serve.ts,
87
+ * which the browser bundle must not import): a degraded PPR shell HIT
88
+ * reloads the page with it, and the server renders a marked request like a
89
+ * cache miss.
90
+ */
91
+ export const SHELL_MISS_MARKER: string = "_rsc_shell";
92
+
93
+ /**
94
+ * Drop the forced-MISS marker from the address bar at boot, keeping the
95
+ * history entry's state (initBrowserApp calls it before any location read).
96
+ * Left in place, a refresh or a shared link would stay off the shell and the
97
+ * app's search params would carry it. The server strips the same marker from
98
+ * the request before rendering (shell-serve.ts withoutShellMissMarker), so
99
+ * the page was rendered for the clean URL and the hydrating client agrees.
100
+ */
101
+ export function stripShellMissMarker(): void {
102
+ const url = new URL(window.location.href);
103
+ if (!url.searchParams.has(SHELL_MISS_MARKER)) return;
104
+ url.searchParams.delete(SHELL_MISS_MARKER);
105
+ window.history.replaceState(window.history.state, "", url.href);
106
+ }
107
+
85
108
  /**
86
109
  * Merge server-set location state into the current history entry.
87
110
  * Replaces the current history state and dispatches notification event
@@ -39,6 +39,7 @@ import {
39
39
  } from "./intercept-utils.js";
40
40
  import { createAppShellRef } from "./app-shell.js";
41
41
  import { bootLog, IS_BROWSER_DEBUG } from "./logging.js";
42
+ import { stripShellMissMarker } from "./history-state.js";
42
43
  import { setActiveInterceptTargets } from "../client-urls/navigation.js";
43
44
 
44
45
  // Vite HMR types are provided by vite/client
@@ -169,6 +170,10 @@ export async function initBrowserApp(
169
170
  } = options;
170
171
 
171
172
  bootLog("initBrowserApp start");
173
+ // Before any location read (the history key, the navigation store, the
174
+ // event controller): the server rendered the marked request for the clean
175
+ // URL, so the client starts from the same one and hydration agrees.
176
+ stripShellMissMarker();
172
177
  bootLog("flight decode: awaiting initial payload from document stream");
173
178
  const initialPayload =
174
179
  await deps.createFromReadableStream<RscPayload>(rscStream);
@@ -2,8 +2,8 @@
2
2
  * Shared Cache Key Utilities
3
3
  *
4
4
  * Deterministic normalization of search params and route params
5
- * for cache key generation. Used by cache-runtime, cache-scope,
6
- * document-cache, and loader-cache.
5
+ * for cache key generation, and the composition of nested cache() keys.
6
+ * Used by cache-runtime, cache-scope, document-cache, and loader-cache.
7
7
  */
8
8
 
9
9
  import { encodeKV } from "../encode-kv.js";
@@ -95,3 +95,23 @@ export function cacheKeyBase(
95
95
  if (searchStr) key += `?${searchStr}`;
96
96
  return key;
97
97
  }
98
+
99
+ /**
100
+ * The record key, or shell partition, of nested keyed cache() scopes (#970)
101
+ * from its parts, outermost first: the chain's `key()` results and, for an
102
+ * inner scope without its own `key()`, that scope's default key
103
+ * (CacheScope.resolveKeyFrom). One part is returned raw, so a single keyed
104
+ * scope keeps its key byte for byte. Two or more are joined by `|`, each
105
+ * URI-encoded (like partitionShellKey, rsc/shell-capture-constants.ts): an
106
+ * encoded part holds no `|`, so every tuple maps to its own key, of any
107
+ * depth. Raw, `("a|b", "c")` and `("a", "b|c")` named one record. A composed
108
+ * key holds no `:` either, so a single raw result with one (a prefixed
109
+ * `tier:...`, a `doc:` default key) never equals it; an unprefixed raw result
110
+ * can. Shared with the testing helper `shellCacheKey`, which composes a
111
+ * nested partition the same way.
112
+ */
113
+ export function composeCacheKeys(parts: readonly string[]): string {
114
+ return parts.length === 1
115
+ ? parts[0]
116
+ : parts.map((part) => encodeURIComponent(part)).join("|");
117
+ }
@@ -83,6 +83,28 @@ export function resolveSwrWindow(
83
83
  return 0;
84
84
  }
85
85
 
86
+ /**
87
+ * Minimum age (ms) of a stale PPR shell before a HIT schedules its recapture.
88
+ * A shell capped to a route cache() record inside its swr window is stale
89
+ * from the start (rsc/shell-capture.ts capShellWindow), so without a floor
90
+ * every HIT of it recaptured, and a capture's store write is one KV write per
91
+ * key: Cloudflare KV accepts one per second per key. Inside the floor the
92
+ * stale shell is still served. Per isolate: each isolate's stale HIT past the
93
+ * floor can schedule one (stampede-guarded per isolate).
94
+ */
95
+ export const SHELL_MIN_RECAPTURE_INTERVAL_MS = 1_000;
96
+
97
+ /**
98
+ * Whether a stale shell HIT schedules its recapture (see the floor above).
99
+ * Fails open: an entry without a numeric `createdAt` recaptures.
100
+ */
101
+ export function staleShellRecaptureDue(
102
+ entry: { readonly createdAt?: number },
103
+ now: number,
104
+ ): boolean {
105
+ return !(now - (entry.createdAt as number) < SHELL_MIN_RECAPTURE_INTERVAL_MS);
106
+ }
107
+
86
108
  /**
87
109
  * Compute staleAt and expiresAt timestamps from TTL and SWR window.
88
110
  *