@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
@@ -53,30 +53,34 @@ export interface PartialPrerenderProps {
53
53
  /**
54
54
  * Operational tags attached to the captured shell entry for
55
55
  * `updateTag()`/`revalidateTag()`-driven eviction. UNIONED with the tags the
56
- * capture render auto-collects (the shell's own non-loader request tags).
56
+ * capture collects: its doc record's tags (what the handler layer and the
57
+ * server components it rendered recorded) and its bake-lane loaders'.
57
58
  */
58
59
  tags?: string[];
59
60
  /**
60
- * Upper bound (serialized UTF-8 bytes) on the capture data snapshot riding
61
- * inside the shell entry. The snapshot duplicates every cache-store value
62
- * the capture pinned, so a page over a large cache() segment can push the
63
- * entry toward store value limits (Cloudflare KV caps a value at 25 MiB).
64
- * Over the cap the snapshot is skipped: the shell is still stored and
65
- * served, but pinned reads fall back to the live store, so drifted cached
66
- * content can hydration-mismatch and be repaired client-side (the
67
- * pre-snapshot behavior). Reported once per key. Defaults to 8 MiB.
61
+ * Upper bound (serialized UTF-8 bytes) on the loader pins in the capture
62
+ * data snapshot: the loader records and the cache values loaders read,
63
+ * duplicated into the shell entry. The doc record (the handler layer every
64
+ * HIT replays) is exempt. Over the cap the pins are dropped: the shell is
65
+ * still stored and every HIT still replays the handler layer, but the
66
+ * pinned loaders read the live store, so drifted cached content can
67
+ * hydration-mismatch and be repaired client-side. Reported once per key.
68
+ * Defaults to 8 MiB. The whole entry is bounded separately by the store's
69
+ * value limit (Cloudflare KV: 25 MiB); an entry over it is refused.
68
70
  */
69
71
  maxSnapshotBytes?: number;
70
72
  /**
71
- * Capture settle budget in MILLISECONDS (default 15000). Bounds the whole
72
- * background capture: the wait for deferred shell material — top-level
73
- * pushed handle promises (`ctx.use(Meta)(promise.then(...))` and friends)
74
- * are AWAITED and their settled values baked into the stored shell — AND
75
- * the fizz prerender deadline. Declare it to tighten the budget below the
76
- * default or when a route's shell material takes longer than 15s to
77
- * settle. A budget that expires with pushes still pending REFUSES the
78
- * capture (the route stays MISS with the once-per-key warning) — a shell
79
- * with missing head material is never stored. Capture is background work
73
+ * Capture settle budget in MILLISECONDS (default 15000). One deadline for
74
+ * the whole background capture: the match (the handlers, and the loaders
75
+ * they await), the wait for handler output (handle pushes and the promises
76
+ * nested in them, bake-lane loaders, and the doc record's encode, which
77
+ * awaits handler promises and async server components), AND the fizz
78
+ * prerender. Everything a handler produces is
79
+ * awaited and baked into the stored shell. Declare it to tighten the
80
+ * budget below the default or when a route's shell material takes longer
81
+ * than 15s to settle. A budget that expires before the doc record is
82
+ * written REFUSES the capture (the route stays MISS with the once-per-key
83
+ * warning): a shell with missing handler output is never stored. Capture is background work
80
84
  * (waitUntil), so a longer budget costs latency-to-HIT only, never a served
81
85
  * response; the platform waitUntil lifetime (workerd: ~30s past response
82
86
  * completion) is the physical ceiling — the default's derivation lives in
@@ -5,9 +5,36 @@
5
5
  * environment. Delegates to extracted modules for discovery, route types
6
6
  * generation, virtual module codegen, and bundle post-processing.
7
7
  */
8
- import type { Plugin } from "vite";
9
- import { VIRTUAL_ROUTES_MANIFEST_ID, type PluginOptions } from "./discovery/state.js";
8
+ import type { Plugin, ViteDevServer } from "vite";
9
+ import { VIRTUAL_ROUTES_MANIFEST_ID, type DiscoveryState, type PluginOptions } from "./discovery/state.js";
10
10
  export { VIRTUAL_ROUTES_MANIFEST_ID };
11
+ /**
12
+ * Create a minimal Vite server for router discovery.
13
+ *
14
+ * Both dev-mode prerender and build-mode discovery need a temp RSC server
15
+ * to import user router files via module runner. This factory centralizes
16
+ * the shared config and the mode-specific differences:
17
+ * - Dev: path-based IDs (no forceBuild), separate cacheDir
18
+ * - Build: hashed IDs (forceBuild), hashClientRefs for production bundles
19
+ *
20
+ * Returns the ViteDevServer instance. Callers access .environments.rsc as needed.
21
+ * @internal Exported for tests.
22
+ */
23
+ export declare function createTempRscServer(state: DiscoveryState, options?: {
24
+ forceBuild?: boolean;
25
+ cacheDir?: string;
26
+ /**
27
+ * Serve the REAL rango SSR entry (getVirtualEntrySSR) for
28
+ * "virtual:entry-ssr" instead of the discovery stub, so the dev
29
+ * /__rsc_shell endpoint can drive captureShellHTML in this server's SSR
30
+ * realm. Dev-correct by construction: the entry's bootstrap resolves to
31
+ * plugin-rsc's stable virtual browser-entry URL, which the MAIN dev
32
+ * server serves to the browser. Loaded lazily — discovery never imports
33
+ * the SSR entry, so the temp server stays as light as before until a
34
+ * shell capture actually runs.
35
+ */
36
+ realSsrEntry?: boolean;
37
+ }): Promise<ViteDevServer>;
11
38
  /**
12
39
  * Plugin that discovers router instances at dev/build time via the RSC environment.
13
40
  *
@@ -3802,7 +3802,7 @@ import { resolve } from "node:path";
3802
3802
  // package.json
3803
3803
  var package_default = {
3804
3804
  name: "@rangojs/router",
3805
- version: "0.17.0",
3805
+ version: "0.18.0",
3806
3806
  description: "Django-inspired RSC router with composable URL patterns",
3807
3807
  keywords: [
3808
3808
  "react",
@@ -6147,6 +6147,9 @@ import React from "react";
6147
6147
  // src/server/context.ts
6148
6148
  import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
6149
6149
 
6150
+ // src/rsc/shell-capture-constants.ts
6151
+ var SHELL_CAPTURE_MAX_WAIT_MS = 15e3;
6152
+
6150
6153
  // src/cache/cache-exec-scope.ts
6151
6154
  import { AsyncLocalStorage } from "node:async_hooks";
6152
6155
 
@@ -6170,19 +6173,23 @@ var LOADER_SCOPE_KEY = /* @__PURE__ */ Symbol.for("rangojs-router:loader-scope")
6170
6173
  var loaderScopeALS = globalThis[LOADER_SCOPE_KEY] ??= new AsyncLocalStorage2();
6171
6174
  var LOADER_BODY_SCOPE_KEY = /* @__PURE__ */ Symbol.for("rangojs-router:loader-body-scope");
6172
6175
  var loaderBodyScopeALS = globalThis[LOADER_BODY_SCOPE_KEY] ??= new AsyncLocalStorage2();
6176
+ function getLoaderBodyTags() {
6177
+ return loaderBodyScopeALS.getStore()?.tags;
6178
+ }
6179
+ function getCurrentLoaderBodyId() {
6180
+ return loaderBodyScopeALS.getStore()?.loaderId;
6181
+ }
6173
6182
  var PUSH_CALLBACK_SCOPE_KEY = /* @__PURE__ */ Symbol.for(
6174
6183
  "rangojs-router:push-callback-scope"
6175
6184
  );
6176
6185
  var pushCallbackScopeALS = globalThis[PUSH_CALLBACK_SCOPE_KEY] ??= new AsyncLocalStorage2();
6177
6186
 
6178
- // src/rsc/shell-capture-constants.ts
6179
- var SHELL_CAPTURE_MAX_WAIT_MS = 15e3;
6180
-
6181
6187
  // src/rsc/shell-serve.ts
6182
6188
  var DEV_SHELL_PROBE_TIMEOUT_MS = 1e4;
6183
6189
  function normalizeCaptureTimeout(value) {
6184
6190
  return typeof value === "number" && Number.isFinite(value) && value >= 1 ? Math.min(value, SHELL_CAPTURE_MAX_WAIT_MS) : void 0;
6185
6191
  }
6192
+ var SHELL_PRELUDE_CHUNK_BYTES = 32 * 1024;
6186
6193
 
6187
6194
  // src/vite/inject-client-debug.ts
6188
6195
  function isRouterInternalDebugId(id) {
@@ -9133,18 +9140,36 @@ var ALL_USE_ITEM_TYPES = new Set(
9133
9140
  );
9134
9141
 
9135
9142
  // src/server/request-context.ts
9136
- import { AsyncLocalStorage as AsyncLocalStorage4 } from "node:async_hooks";
9143
+ import { AsyncLocalStorage as AsyncLocalStorage5 } from "node:async_hooks";
9137
9144
  init_cookie_name();
9138
9145
 
9139
9146
  // src/server/handle-store.ts
9140
9147
  var LOADER_SLOT = Object.freeze({ loader: true });
9141
9148
 
9149
+ // src/cache/cache-tag.ts
9150
+ import { AsyncLocalStorage as AsyncLocalStorage3 } from "node:async_hooks";
9151
+ var cacheTagStorage = new AsyncLocalStorage3();
9152
+ var segmentOwnerStorage = new AsyncLocalStorage3();
9153
+ var recordedTagCapture = globalThis[/* @__PURE__ */ Symbol.for("rangojs-router:recorded-tag-capture")] ??= new AsyncLocalStorage3();
9154
+ var identityReads = globalThis[/* @__PURE__ */ Symbol.for("rangojs-router:identity-reads")] ??= /* @__PURE__ */ new WeakMap();
9155
+ function activeTagSet() {
9156
+ const capture = recordedTagCapture.getStore();
9157
+ const body = getLoaderBodyTags();
9158
+ if (capture && capture.outer === body) return capture.into;
9159
+ return body ?? capture?.into;
9160
+ }
9161
+ globalThis[/* @__PURE__ */ Symbol.for("rangojs-router:identity-read-recorder")] = (surface, verb) => {
9162
+ const set = activeTagSet();
9163
+ if (set === void 0 || identityReads.has(set)) return;
9164
+ identityReads.set(set, { surface, verb, bodyId: getCurrentLoaderBodyId() });
9165
+ };
9166
+
9142
9167
  // src/router/router-context.ts
9143
- import { AsyncLocalStorage as AsyncLocalStorage3 } from "async_hooks";
9144
- var routerContext = new AsyncLocalStorage3();
9168
+ import { AsyncLocalStorage as AsyncLocalStorage4 } from "async_hooks";
9169
+ var routerContext = new AsyncLocalStorage4();
9145
9170
 
9146
9171
  // src/server/request-context.ts
9147
- var requestContextStorage = new AsyncLocalStorage4();
9172
+ var requestContextStorage = new AsyncLocalStorage5();
9148
9173
 
9149
9174
  // src/route-definition/redirect.ts
9150
9175
  init_redirect_origin();
@@ -9727,6 +9752,8 @@ async function runShellPrerenderPhase(s, builder) {
9727
9752
  const candidates = s.shellCandidates;
9728
9753
  const startTotal = performance.now();
9729
9754
  console.log(`[rango] Shell-prerendering ${candidates.length} URL(s)...`);
9755
+ const keepAlive = setInterval(() => {
9756
+ }, 6e4);
9730
9757
  let restoreClientRequire;
9731
9758
  try {
9732
9759
  const rscEnv = tempServer.environments?.rsc;
@@ -9934,6 +9961,7 @@ async function runShellPrerenderPhase(s, builder) {
9934
9961
  } finally {
9935
9962
  delete globalThis.__loadPrerenderManifestModule;
9936
9963
  restoreClientRequire?.();
9964
+ clearInterval(keepAlive);
9937
9965
  }
9938
9966
  }
9939
9967
  function writeShellManifest(s, rscOutDir, staged) {
@@ -10603,7 +10631,16 @@ async function createTempRscServer(state, options = {}) {
10603
10631
  return createViteServer({
10604
10632
  root: state.projectRoot,
10605
10633
  configFile: false,
10606
- server: { middlewareMode: true },
10634
+ server: {
10635
+ middlewareMode: true,
10636
+ // Build mode: no file watching. buildStart installs the per-router tries
10637
+ // into this realm and runShellPrerenderPhase reuses it after the bundles
10638
+ // are written. A watched write in between (writeRouteTypesFiles' own
10639
+ // <router>.named-routes.gen.ts, which every router file imports) can
10640
+ // make Vite full-reload the RSC runner; the shell phase then matches in
10641
+ // a fresh realm with no tries and a root path("/*") wins (#947).
10642
+ ...options.forceBuild ? { watch: null } : {}
10643
+ },
10607
10644
  appType: "custom",
10608
10645
  logLevel: "silent",
10609
10646
  resolve: resolveConfig,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -103,13 +103,16 @@ Delivery follows the loader race model: a push that beats the handler barrier
103
103
  is in the SSR'd document; a push after a slow fetch streams and applies
104
104
  client-side (`useHandle` re-renders when it lands). To guarantee document
105
105
  delivery, register the loader as `loader(Def, { ssr: false })` —
106
- see `/loader` → "Writing Handles from Loaders". Under `cache()` loader crumbs
107
- are not stored with the cached segments; the loader re-pushes them on every
108
- hit, so they appear once even without an `href`. A loader with its own
109
- `cache()` keeps its crumbs on its own hits too: they are replayed from the
110
- loader's cache entry. A crumb from a loader it awaits appears once even when
111
- a sibling loader or the handler reads that loader too; that live run's crumb
112
- replaces the cached one in the trail.
106
+ see `/loader` → "Writing Handles from Loaders". On a `ppr` route the race is
107
+ decided at capture: a loader without `ssr: false` never runs there, so its
108
+ crumb is not in the shell and applies client-side on every HIT; with
109
+ `ssr: false` the crumb bakes into the shell (`/ppr` → "On a shell HIT").
110
+ Under `cache()` loader crumbs are not stored with the cached segments; the
111
+ loader re-pushes them on every hit, so they appear once even without an `href`.
112
+ A loader with its own `cache()` keeps its crumbs on its own hits too: they are
113
+ replayed from the loader's cache entry. A crumb from a loader it awaits appears
114
+ once even when a sibling loader or the handler reads that loader too; that live
115
+ run's crumb replaces the cached one in the trail.
113
116
 
114
117
  ## Async Content
115
118
 
@@ -134,6 +137,13 @@ path("/product/:id", async (ctx) => {
134
137
  Async content is a `Promise<ReactNode>`. Resolve it in your component
135
138
  with React's `use()` hook wrapped in `<Suspense>`.
136
139
 
140
+ A crumb the HANDLER pushes is handler output. In a route `cache()` entry and
141
+ in a `ppr` shell it is stored resolved: the cache write, or the shell capture
142
+ (within `ppr.captureTimeout`), waits for a `content` promise and for a
143
+ `.defer()` slot (below), and every HIT replays the crumb as it was resolved
144
+ then, without running the handler. For per-request crumb content, push it
145
+ from a loader without `ssr: false` (see "Pushing from Loaders").
146
+
137
147
  ### Deferred content (decide now, resolve from a deep component)
138
148
 
139
149
  When the handler should DECIDE to push a crumb early but the value is produced
@@ -18,15 +18,15 @@ of the request live on a hit. The runtime layers share the app-level store
18
18
  `revalidateTag`); `Prerender()`/`Static()` output is built into the server
19
19
  bundle instead.
20
20
 
21
- | Layer | Declared with | Stores | Still runs on a hit | Skill |
22
- | -------------------- | ---------------------------------------------- | ------------------------------------- | ---------------------------------------------------- | --------------------- |
23
- | Function / component | `"use cache"` | one function's return value | everything around the call | `/use-cache` |
24
- | Loader data | `loader(L, () => [cache({...})])` | one loader's result | other loaders, handlers, rendering | `/caching`, `/loader` |
25
- | Segments, runtime | `cache({...}, () => [...])` | rendered Flight segments of a subtree | middleware, segments above it, loaders, HTML render | `/caching` |
26
- | Segments, build time | `Prerender()` / `Static()` | Flight segments rendered at build | middleware, loaders, HTML render | `/prerender` |
27
- | HTML shell | `ppr` path option | HTML prelude + React postponed state | middleware, handlers, loaders; only the holes resume | `/ppr` |
28
- | Whole response (app) | `createDocumentCacheMiddleware()` + `s-maxage` | final response in the app store | middleware above it; nothing below | `/document-cache` |
29
- | Whole response (CDN) | `Cache-Control: s-maxage` read by the platform | final response outside the app | nothing — the app is not invoked | `/deployment-caching` |
21
+ | Layer | Declared with | Stores | Still runs on a hit | Skill |
22
+ | -------------------- | ---------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------ | --------------------- |
23
+ | Function / component | `"use cache"` | one function's return value | everything around the call | `/use-cache` |
24
+ | Loader data | `loader(L, () => [cache({...})])` | one loader's result | other loaders, handlers, rendering | `/caching`, `/loader` |
25
+ | Segments, runtime | `cache({...}, () => [...])` | rendered Flight segments of a subtree | middleware, segments above it, loaders, HTML render | `/caching` |
26
+ | Segments, build time | `Prerender()` / `Static()` | Flight segments rendered at build | middleware, loaders, HTML render | `/prerender` |
27
+ | HTML shell | `ppr` path option | HTML prelude + React postponed state + the handler layer | middleware and live loaders (no handler); holes resume | `/ppr` |
28
+ | Whole response (app) | `createDocumentCacheMiddleware()` + `s-maxage` | final response in the app store | middleware above it; nothing below | `/document-cache` |
29
+ | Whole response (CDN) | `Cache-Control: s-maxage` read by the platform | final response outside the app | nothing — the app is not invoked | `/deployment-caching` |
30
30
 
31
31
  Quick rules:
32
32
 
@@ -37,7 +37,10 @@ Quick rules:
37
37
  - **Whole response** public and identical for everyone → `/document-cache` or
38
38
  CDN caching (`/deployment-caching`).
39
39
  - A value that must be fresh on **every** request → a loader (never cached
40
- unless you opt in).
40
+ unless you opt in), read with `useLoader()`; on a `ppr` route, keep it off
41
+ `ssr: false`. A handler inside a `cache()` or `ppr` boundary that awaits
42
+ `ctx.use(Loader)` renders the value it got on the miss, and every hit
43
+ replays that output.
41
44
 
42
45
  The layers compose: `"use cache"` inside a `cache()` subtree, a cached loader
43
46
  under a `cache()` boundary, `cache()` segments replayed inside a `ppr` shell
@@ -53,25 +56,38 @@ execution model, and runtime control.
53
56
 
54
57
  ## Two axes — do not conflate
55
58
 
56
- Everything on this page is **axis 1: stored-value freshness** — _is a cached
57
- value still good?_ There is a second, orthogonal axis it is easy to mistake for
59
+ Everything on this page is **stored-value freshness** — _is a cached value
60
+ still good?_ There is a second, orthogonal concern it is easy to mistake for
58
61
  caching:
59
62
 
60
- 1. **Stored-value freshness** — _is a cached value still good?_
61
- → `"use cache"` (fn/component), `cache()` (segment), loader `cache()` (loader data).
62
- Entries expire by **TTL/SWR** and can be tagged (`cache({ tags })` or runtime
63
- `cacheTag(...tags)` — inside `"use cache"` it tags that entry; called during a
64
- request render outside `"use cache"` it tags the document/shell artifact).
65
- All built-in stores (`MemorySegmentCacheStore`, `CFCacheStore`,
66
- `VercelCacheStore`) index by tag; invalidate on demand with `updateTag(...tags)` (awaitable,
67
- read-your-own-writes) or `revalidateTag(...tags)` (background, non-blocking).
68
- Both hard-purge; the difference is awaitability, not stale-serving.
63
+ 1. **Stored-value freshness** — _is a cached value still good?_ → `"use cache"`
64
+ (fn/component), `cache()` (segment), loader `cache()` (loader data). Entries
65
+ expire by **TTL/SWR** and can be tagged (`cache({ tags })` or runtime
66
+ `cacheTag(...tags)` — inside `"use cache"` it tags that entry; called during
67
+ a request render outside `"use cache"` it tags what the render stores: the
68
+ enclosing route `cache()` entry, a cached loader's own entry when called in
69
+ its body, and the `ppr` shell or document-cache entry built from them; on a
70
+ `ppr` route it tags the shell only when shell material calls it). All
71
+ built-in stores (`MemorySegmentCacheStore`, `CFCacheStore`,
72
+ `VercelCacheStore`) index by tag; invalidate on demand with
73
+ `updateTag(...tags)` (awaitable) or `revalidateTag(...tags)` (background,
74
+ non-blocking). Both evict rather than mark stale, and the request that calls
75
+ either reads its own writes; `updateTag()` also waits for the durable write,
76
+ `revalidateTag()` leaves it in the background. `CFCacheStore` and
77
+ `VercelCacheStore` serve PPR shell reads through per-isolate memos: the
78
+ invalidating user skips them via the fresh-reads cookie, other users see the
79
+ invalidation once the memo refreshes and, on `CFCacheStore` with KV, once KV
80
+ propagates the marker to their colo (`/caching` → "Tag-Based Invalidation"
81
+ and "The fresh-reads cookie").
69
82
  2. **Client-update selection** — _should this segment re-run and stream to the
70
83
  client on this navigation/action?_
71
84
  → `revalidate()`. Covered in `/loader` and `/route`, **not here**.
72
85
 
73
86
  They are orthogonal and compose: a segment selected by `revalidate()` still
74
- consults its cache (hit → no recompute); a cache bust does **not** force a client
87
+ consults its cache (hit → no recompute), except that a server action's
88
+ revalidation render skips the route `cache()` lookup and re-renders those
89
+ segments fresh (a loader's own `cache()` and `"use cache"` are still read); a
90
+ cache bust does **not** force a client
75
91
  update, and `revalidate()` never reads, writes, or expires a cached value. If you
76
92
  know React Router, `revalidate()` is `shouldRevalidate`, not `Cache-Control`. See
77
93
  `/rango` → "Coming from another framework" for the cross-framework mapping.
@@ -111,7 +127,14 @@ else they allow:
111
127
  would be baked in), and response side effects (`ctx.headers.set()`,
112
128
  `setCookie()`, `setStatus()`, `onResponse()`) throw. `ctx.set()` of a cacheable var is
113
129
  **allowed** — children are cached too and can read it. **Loaders are exempt**
114
- (they always run fresh) — read request data inside a loader.
130
+ (a route `cache()` does not store their values) — read request data inside a
131
+ loader.
132
+ - **Loader `cache()` guard** (a loader bound with its own `cache()`) — its value
133
+ is stored under a key that names no user, so a miss whose body read
134
+ `cookies()`, `headers()`, the theme or a non-cacheable `ctx.get()` fails
135
+ unless the binding has a `key()` or its store a `keyGenerator`. Either one
136
+ switches the check off, so put what the body reads in it (`/loader` →
137
+ "Cache Key").
115
138
  - **`"use cache"` exec-guard** (function-level) — the same request-scoped APIs
116
139
  throw inside the cached function (`cookies()`, `headers()`, `ctx.set()`,
117
140
  `ctx.headers.set()` and other response writes); additionally, tainted
@@ -119,6 +142,12 @@ else they allow:
119
142
  the cached path: with no item-capable store configured the function runs
120
143
  uncached and nothing throws.
121
144
 
145
+ The `ppr` shell capture has its own, stricter guard: `cookies()`, `headers()`, a
146
+ theme read, a `{ cache: false }` variable read, and `ctx.dynamic()` refuse the
147
+ capture anywhere it waits — handlers, promises they pass or push, async server
148
+ components, `ssr: false` loaders, and loaders a handler awaits (no loader
149
+ exemption there). The route then serves uncached; see `/ppr`.
150
+
122
151
  ### Cross-deploy safety: version-segmented store keys
123
152
 
124
153
  `CFCacheStore` prefixes every **physical** store key (the CF Cache API URL and
@@ -203,7 +232,7 @@ local/dev behavior, not as proof that segment SWR is active.
203
232
  | **Side effects** | Response side effects throw inside the boundary | `ctx.headers.set()`, `ctx.set()`, etc. throw |
204
233
  | **Handle data** | Handler pushes replayed; loader pushes re-run live | Captured and replayed when it receives `ctx` |
205
234
  | **Loaders** | Always fresh — excluded from cache, opt-in per loader | Can be used inside loaders |
206
- | **Nesting** | Nest `cache()` boundaries with different TTLs | Compose by calling cached functions from uncached |
235
+ | **Nesting** | Inner TTLs override; `key()` partitions compose | Compose by calling cached functions from uncached |
207
236
 
208
237
  ### cache() Cache Key
209
238
 
@@ -216,8 +245,14 @@ honors `createRouter({ cache: { searchParams } })`.
216
245
 
217
246
  A custom `key` function replaces the whole default key (e.g., to key by user role
218
247
  or locale); it also bypasses the store's `keyGenerator` and the search-param
219
- filter. `condition` can disable caching entirely at runtime (e.g., skip for
220
- authenticated users).
248
+ filter. It runs once per request and may read `cookies()`. A nested `cache()`
249
+ keys its records within the enclosing `key()` partition (`/caching` → "Keys
250
+ nest"). Never return raw request input from `key()`: normalize it to the
251
+ values you serve or prefix it. On a `ppr` route, `key()` (and a store
252
+ `keyGenerator` that returns a non-default key) also partitions the shell: one
253
+ shell per value, and a partitioned request never reads a build-time shell.
254
+ Keep the values to a small set. `condition` can disable caching entirely at
255
+ runtime (e.g., skip for authenticated users).
221
256
 
222
257
  ### "use cache" Cache Key
223
258
 
@@ -294,6 +329,11 @@ export const urlpatterns = urls(({ path, cache }) => [
294
329
  ]);
295
330
  ```
296
331
 
332
+ On a `ppr` route, `cache(false)` or a `condition()` that returns false also
333
+ means no shell: that route (or that request) renders like a cache miss, with
334
+ no shell served or captured. A `ppr` route cannot opt out of a layout's
335
+ `cache()` and still get a shell.
336
+
297
337
  ## When to Use "use cache"
298
338
 
299
339
  Use the `"use cache"` directive when:
@@ -364,8 +404,20 @@ does **not** keep a value fresher than its parent:
364
404
  So `"use cache: short"` (60s) inside `cache({ ttl: 600 })` yields ~600s freshness
365
405
  on hits, **not** 60s. This is not a bug: setting `cache({ ttl: 600 })` declares
366
406
  "this subtree may be ~600s stale." **If a value must be fresher than its
367
- enclosing segment, put it in a loader** (always live). `debugPerformance` prints
368
- cache hits per layer, so the actual per-request behavior is observable.
407
+ enclosing segment, put it in a loader** read with `useLoader()` (without
408
+ `ssr: false` on a `ppr` route). `debugPerformance` prints cache hits per
409
+ layer, so the actual per-request behavior is observable.
410
+
411
+ Keys and tags cross the nesting too:
412
+
413
+ - An inner `cache()` without `key` keys its records within the outer `key()`
414
+ partition; one with its own `key` composes both (`/caching` → "Keys nest").
415
+ - Tags that `cacheTag()` calls and `"use cache"` reads record inside a
416
+ `cache()` subtree are stored on that `cache()` entry, so `updateTag()` of one
417
+ of them evicts it (and a `ppr` shell or document built from it) instead of
418
+ waiting for it to expire.
419
+ - A `ppr` shell never outlives the route `cache()` entry it was captured
420
+ from: its ttl and ttl + swr are capped to what that entry has left.
369
421
 
370
422
  ## Headers and Cookies
371
423
 
@@ -381,6 +433,10 @@ Neither mechanism caches response headers or cookies.
381
433
  segment outside the cache boundary, or a loader.
382
434
  - **"use cache"**: `cookies()` and `headers()` throw inside the cached function
383
435
  (both reads and writes), and so do `ctx.headers` mutations. Move them outside.
436
+ - **`ctx.theme`** (handler and middleware) and **`getRequestContext().theme`**
437
+ are the visitor's theme cookie, so a read throws in both scopes, like
438
+ `cookies()`. Read it with `useTheme()` in a client component, or in a
439
+ loader (see the `/theme` skill).
384
440
 
385
441
  ```typescript
386
442
  // Set headers that must appear on every response in middleware
@@ -413,14 +469,16 @@ specifies `cache: false`, the value is non-cacheable.
413
469
 
414
470
  **Behavior inside a `cache()` boundary:**
415
471
 
416
- | Operation | Inside a `cache()` boundary |
417
- | ----------------------------------------- | ------------------------------------------------------ |
418
- | `cookies()` / `headers()` (read or write) | Throws (request-scoped, would poison the shared entry) |
419
- | `ctx.get(cacheableVar)` | Allowed |
420
- | `ctx.get(nonCacheableVar)` | Throws (would be baked in) |
421
- | `ctx.set(var, value)` (cacheable) | Allowed |
422
- | `ctx.headers.set()` / cookie writes | Throws (response side effect would be lost on hit) |
423
- | Any of the above **inside a loader** | Allowed (loaders always run fresh) |
472
+ | Operation | Inside a `cache()` boundary |
473
+ | -------------------------------------------------- | ---------------------------------------------------------------------------------- |
474
+ | `cookies()` / `headers()` (read or write) | Throws (request-scoped, would poison the shared entry) |
475
+ | `ctx.theme`, `getRequestContext().theme` | Throws, like `cookies()` (#971) |
476
+ | `ctx.get(cacheableVar)` | Allowed |
477
+ | `ctx.get(nonCacheableVar)` | Throws (would be baked in) |
478
+ | `ctx.set(var, value)` (cacheable) | Allowed |
479
+ | `ctx.headers.set()` / cookie writes | Throws (response side effect would be lost on hit) |
480
+ | Any of the above **inside a loader** | Allowed (loaders always run fresh) |
481
+ | The reads above in a loader with its own `cache()` | The miss fails without a `key()`; any `key()` allows them, so it must include them |
424
482
 
425
483
  (Both scopes block the same request-scoped APIs — `cookies()`, `headers()`,
426
484
  response side effects, and non-cacheable `ctx.get()` — because each would leak
@@ -476,7 +534,8 @@ non-cacheable value in as an argument so it becomes part of the key. The pattern
476
534
  **read tainted context at the point of use, in the path that needs it (a loader or
477
535
  live segment) — never extract user data into a plain value and cache that.**
478
536
  Loaders are exempt because they run outside the cache scope and resolve fresh
479
- every request.
537
+ every request. A loader with its own `cache()` does not: its value is stored,
538
+ so it falls under the loader `cache()` guard above.
480
539
 
481
540
  ## Loaders Are Always Fresh
482
541
 
@@ -489,8 +548,16 @@ fresh on every request. This is enforced at two levels:
489
548
  loaders after yielding its cached UI segments (the loaders above the boundary
490
549
  run with their live segments), ensuring fresh data regardless of cache state.
491
550
 
492
- This means `cache()` gives you cached UI + fresh data by default. To also cache
493
- a loader's data, explicitly opt in with `loader(Fn, () => [cache({...})])`.
551
+ This means `cache()` gives you cached UI + fresh data by default, where the
552
+ data is read outside the cached output: `useLoader()` in a client component.
553
+ A handler inside the boundary that awaits `ctx.use(Loader)` renders the value
554
+ it got on the miss, and every hit replays that output. On a `ppr` route an
555
+ `ssr: false` loader is the other exception: it bakes into the shell (`/ppr`).
556
+ To also cache a loader's data, explicitly opt in with
557
+ `loader(Fn, () => [cache({...})])`.
558
+ That entry is keyed by loader, host, path and params, not by the route's
559
+ `key()`, so give it a `key()` that includes any request data its body reads;
560
+ without one the miss fails.
494
561
 
495
562
  ## cache() Placement Patterns
496
563
 
@@ -513,16 +580,16 @@ path("/dashboard", DashboardPage, { name: "dashboard" }, () => [
513
580
  ```
514
581
 
515
582
  On hit: DashboardPage, DashboardSidebar, StatsPanel, and ActivityFeed are all
516
- served from cache, and DashboardLoader runs fresh. On miss: all handlers run
517
- and the path's segments are cached together. DashboardPage runs inside the
518
- boundary, so the guards apply to it: a header write there throws. The wrapper
519
- form `cache({ ttl: 300 }, () => [layout(DashboardSidebar)])` inside a path
520
- does the same, and `cache(false)` there opts the path out of an enclosing
521
- `cache()`. The same form caches a response route:
583
+ served from cache, and DashboardLoader runs fresh. On miss: all handlers run and
584
+ the path's segments are cached together. DashboardPage runs inside the boundary,
585
+ so the guards apply to it: a header write there throws. The wrapper form
586
+ `cache({ ttl: 300 }, () => [layout(DashboardSidebar)])` inside a path does the
587
+ same, and `cache(false)` there opts the path out of an enclosing `cache()` (on a
588
+ `ppr` route, out of the shell too). The same form caches a response route:
522
589
  `path.json("/api/feed", handler, { name: "feed" }, () => [cache({ ttl: 60 })])`.
523
- A `cache()` inside a `layout()` or `middleware()` wrapper in the path caches
524
- the path too: `layout(DashboardSidebar, () => [cache({ ttl: 300 })])` is the
525
- same unit as the example above, not the sidebar alone.
590
+ A `cache()` inside a `layout()` or `middleware()` wrapper in the path caches the
591
+ path too: `layout(DashboardSidebar, () => [cache({ ttl: 300 })])` is the same
592
+ unit as the example above, not the sidebar alone.
526
593
 
527
594
  ### Bare cache() among a layout's children
528
595
 
@@ -566,8 +633,8 @@ RootLayout renders fresh every request, and a header it writes lands on every
566
633
  response. ProductPage, ReviewsPanel, and RelatedProducts are inside the cache
567
634
  boundary and served from cache on hit. This is useful when the root layout
568
635
  depends on request-specific data (user session, theme) but the product content
569
- is cacheable. On a `ppr` route the whole chain bakes into the shell, so there
570
- `cache()` covers RootLayout too.
636
+ is cacheable. On a `ppr` route the shell bakes RootLayout too: a shell HIT runs
637
+ no layout, and the shell expires no later than this `cache()` entry.
571
638
 
572
639
  ### Loader-level caching
573
640
 
@@ -589,7 +656,12 @@ This attaches the cache config directly to the loader entry. The loader's
589
656
  data is cached independently from the route's segment cache, together with
590
657
  the handle pushes its body made (replayed on every hit). Loader caching
591
658
  supports custom keys, tags, SWR, conditional bypass, and per-loader store
592
- overrides — see `/loader` for the full reference.
659
+ overrides — see `/loader` for the full reference. A body that reads
660
+ `cookies()`, `headers()` or a non-cacheable variable needs a `key()`, which
661
+ must include the value: without one the miss fails, and any `key()` switches
662
+ that check off. The entry also carries the tags its body recorded
663
+ (`cacheTag()` calls, `"use cache"` reads, and those of the loaders it reads
664
+ with `ctx.use()`), so `updateTag()` of any of them drops it.
593
665
 
594
666
  ## See Also
595
667