@rangojs/router 0.16.0 → 0.17.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 (245) hide show
  1. package/README.md +254 -89
  2. package/dist/testing/vitest.js +252 -0
  3. package/dist/types/browser/navigation-transaction.d.ts +6 -0
  4. package/dist/types/browser/partial-update.d.ts +11 -0
  5. package/dist/types/browser/react/ScrollRestoration.d.ts +6 -6
  6. package/dist/types/browser/rsc-router.d.ts +1 -1
  7. package/dist/types/browser/scroll-restoration.d.ts +7 -0
  8. package/dist/types/cache/cache-exec-scope.d.ts +18 -1
  9. package/dist/types/cache/cache-scope.d.ts +34 -5
  10. package/dist/types/cache/handle-capture.d.ts +41 -3
  11. package/dist/types/cache/handle-snapshot.d.ts +53 -13
  12. package/dist/types/cache/segment-codec.d.ts +10 -3
  13. package/dist/types/cache/taint.d.ts +1 -1
  14. package/dist/types/cache/types.d.ts +15 -3
  15. package/dist/types/client.d.ts +4 -4
  16. package/dist/types/client.rsc.d.ts +4 -5
  17. package/dist/types/components/DefaultDocument.d.ts +5 -2
  18. package/dist/types/context-var.d.ts +6 -5
  19. package/dist/types/escape-script.d.ts +1 -1
  20. package/dist/types/handles/MetaTags.d.ts +5 -4
  21. package/dist/types/handles/Scripts.d.ts +8 -7
  22. package/dist/types/handles/meta.d.ts +2 -2
  23. package/dist/types/handles/script.d.ts +17 -16
  24. package/dist/types/href-client.d.ts +1 -1
  25. package/dist/types/html.d.ts +12 -0
  26. package/dist/types/index.d.ts +1 -1
  27. package/dist/types/index.rsc.d.ts +3 -2
  28. package/dist/types/loader-store.d.ts +7 -4
  29. package/dist/types/reverse.d.ts +36 -4
  30. package/dist/types/route-definition/dsl-helpers.d.ts +3 -3
  31. package/dist/types/route-definition/helpers-types.d.ts +33 -9
  32. package/dist/types/route-definition/resolve-handler-use.d.ts +23 -1
  33. package/dist/types/route-types.d.ts +1 -1
  34. package/dist/types/router/debug-manifest.d.ts +3 -2
  35. package/dist/types/router/error-handling.d.ts +20 -0
  36. package/dist/types/router/handler-context.d.ts +6 -0
  37. package/dist/types/router/match-middleware/background-revalidation.d.ts +46 -5
  38. package/dist/types/router/middleware-types.d.ts +3 -2
  39. package/dist/types/router/router-context.d.ts +3 -1
  40. package/dist/types/router/router-interfaces.d.ts +24 -2
  41. package/dist/types/router/router-options.d.ts +8 -5
  42. package/dist/types/router/segment-resolution/fresh.d.ts +10 -0
  43. package/dist/types/router/segment-resolution/loader-cache.d.ts +37 -1
  44. package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -19
  45. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +3 -3
  46. package/dist/types/router/segment-resolution/mask-nested.d.ts +7 -7
  47. package/dist/types/router/segment-resolution/revalidation.d.ts +6 -2
  48. package/dist/types/router/segment-wrappers.d.ts +1 -1
  49. package/dist/types/rsc/response-route-handler.d.ts +1 -1
  50. package/dist/types/rsc/rsc-rendering.d.ts +1 -2
  51. package/dist/types/rsc/shell-capture-constants.d.ts +8 -0
  52. package/dist/types/rsc/shell-capture.d.ts +5 -4
  53. package/dist/types/rsc/ssr-setup.d.ts +6 -6
  54. package/dist/types/rsc/types.d.ts +10 -0
  55. package/dist/types/server/context.d.ts +33 -3
  56. package/dist/types/server/handle-store.d.ts +26 -2
  57. package/dist/types/server/request-context.d.ts +24 -21
  58. package/dist/types/ssr/index.d.ts +15 -0
  59. package/dist/types/static-handler.d.ts +4 -1
  60. package/dist/types/testing/generated-routes.d.ts +2 -2
  61. package/dist/types/testing/internal/context.d.ts +1 -1
  62. package/dist/types/testing/render-route.d.ts +32 -6
  63. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +28 -5
  64. package/dist/types/testing/vitest.d.ts +35 -1
  65. package/dist/types/theme/ThemeScript.d.ts +3 -3
  66. package/dist/types/types/cache-types.d.ts +14 -10
  67. package/dist/types/types/handler-context.d.ts +14 -0
  68. package/dist/types/types/loader-types.d.ts +8 -6
  69. package/dist/types/urls/response-types.d.ts +3 -12
  70. package/dist/types/use-loader.d.ts +12 -3
  71. package/dist/types/vite/plugins/use-cache-transform.d.ts +33 -2
  72. package/dist/types/vite/rango.d.ts +1 -1
  73. package/dist/vite/index.js +1381 -1346
  74. package/package.json +2 -2
  75. package/skills/api-client/SKILL.md +22 -10
  76. package/skills/breadcrumbs/SKILL.md +65 -22
  77. package/skills/bundle-analysis/SKILL.md +22 -22
  78. package/skills/cache-guide/SKILL.md +207 -97
  79. package/skills/caching/SKILL.md +295 -93
  80. package/skills/catalog.json +16 -16
  81. package/skills/client-urls/SKILL.md +53 -31
  82. package/skills/cloudflare/SKILL.md +72 -20
  83. package/skills/cloudflare/references/d1-and-local-dev.md +15 -2
  84. package/skills/cloudflare/references/streaming-and-deploy.md +10 -3
  85. package/skills/cloudflare/references/webhooks-and-crypto.md +9 -2
  86. package/skills/comparison/SKILL.md +5 -0
  87. package/skills/comparison/references/framework-comparison.md +39 -33
  88. package/skills/composability/SKILL.md +38 -29
  89. package/skills/css/SKILL.md +10 -13
  90. package/skills/debug-manifest/SKILL.md +74 -31
  91. package/skills/defer-hydration/SKILL.md +2 -1
  92. package/skills/deployment-caching/SKILL.md +6 -1
  93. package/skills/document-cache/SKILL.md +92 -28
  94. package/skills/fonts/SKILL.md +16 -8
  95. package/skills/handler-use/SKILL.md +53 -17
  96. package/skills/hooks/SKILL.md +28 -20
  97. package/skills/hooks/data.md +23 -3
  98. package/skills/hooks/handle-and-actions.md +31 -16
  99. package/skills/hooks/navigation.md +39 -10
  100. package/skills/hooks/outlets.md +13 -6
  101. package/skills/hooks/state.md +37 -15
  102. package/skills/hooks/urls.md +7 -5
  103. package/skills/host-router/SKILL.md +64 -12
  104. package/skills/i18n/SKILL.md +60 -34
  105. package/skills/intercept/SKILL.md +161 -26
  106. package/skills/layout/SKILL.md +106 -50
  107. package/skills/links/SKILL.md +51 -19
  108. package/skills/loader/SKILL.md +242 -106
  109. package/skills/middleware/SKILL.md +119 -23
  110. package/skills/migrate-nextjs/SKILL.md +100 -45
  111. package/skills/migrate-nextjs/backend-host-swap.md +5 -2
  112. package/skills/migrate-react-router/SKILL.md +49 -38
  113. package/skills/migrate-react-router/component-migration.md +25 -6
  114. package/skills/migrate-react-router/data-and-actions.md +18 -5
  115. package/skills/migrate-react-router/route-mapping.md +12 -6
  116. package/skills/mime-routes/SKILL.md +12 -7
  117. package/skills/observability/SKILL.md +28 -14
  118. package/skills/parallel/SKILL.md +75 -39
  119. package/skills/ppr/SKILL.md +345 -150
  120. package/skills/prerender/SKILL.md +189 -100
  121. package/skills/rango/SKILL.md +65 -34
  122. package/skills/react-compiler/SKILL.md +6 -5
  123. package/skills/response-routes/SKILL.md +133 -25
  124. package/skills/route/SKILL.md +165 -60
  125. package/skills/router-setup/SKILL.md +418 -240
  126. package/skills/scripts/SKILL.md +31 -20
  127. package/skills/server-actions/SKILL.md +220 -59
  128. package/skills/shell-manifest/SKILL.md +12 -7
  129. package/skills/streams-and-websockets/SKILL.md +32 -14
  130. package/skills/tailwind/SKILL.md +37 -33
  131. package/skills/testing/SKILL.md +68 -32
  132. package/skills/testing/cache-prerender.md +55 -29
  133. package/skills/testing/client-components.md +59 -6
  134. package/skills/testing/e2e-parity.md +12 -2
  135. package/skills/testing/flight.md +19 -11
  136. package/skills/testing/handles.md +8 -5
  137. package/skills/testing/loader.md +8 -5
  138. package/skills/testing/middleware.md +8 -3
  139. package/skills/testing/predicates.md +120 -0
  140. package/skills/testing/render-handler.md +70 -22
  141. package/skills/testing/response-routes.md +11 -4
  142. package/skills/testing/reverse-and-types.md +20 -14
  143. package/skills/testing/server-actions.md +12 -3
  144. package/skills/testing/server-tree.md +9 -0
  145. package/skills/testing/setup.md +9 -5
  146. package/skills/theme/SKILL.md +106 -29
  147. package/skills/typesafety/SKILL.md +20 -9
  148. package/skills/typesafety/env-and-bindings.md +126 -47
  149. package/skills/typesafety/generated-files-and-cli.md +26 -14
  150. package/skills/typesafety/params-and-search.md +44 -19
  151. package/skills/typesafety/route-types.md +50 -14
  152. package/skills/use-cache/SKILL.md +200 -82
  153. package/skills/vercel/SKILL.md +21 -11
  154. package/skills/view-transitions/SKILL.md +41 -20
  155. package/src/__augment-tests__/augmented.check.ts +26 -1
  156. package/src/browser/index.ts +2 -2
  157. package/src/browser/navigation-bridge.ts +3 -4
  158. package/src/browser/navigation-transaction.ts +22 -2
  159. package/src/browser/partial-update.ts +35 -25
  160. package/src/browser/react/ScrollRestoration.tsx +6 -6
  161. package/src/browser/rsc-router.tsx +1 -1
  162. package/src/browser/scroll-restoration.ts +53 -6
  163. package/src/cache/cache-exec-scope.ts +37 -4
  164. package/src/cache/cache-key-utils.ts +1 -6
  165. package/src/cache/cache-runtime.ts +230 -61
  166. package/src/cache/cache-scope.ts +76 -12
  167. package/src/cache/document-cache.ts +15 -0
  168. package/src/cache/handle-capture.ts +90 -10
  169. package/src/cache/handle-snapshot.ts +132 -28
  170. package/src/cache/segment-codec.ts +26 -7
  171. package/src/cache/tag-invalidation.ts +4 -3
  172. package/src/cache/taint.ts +1 -1
  173. package/src/cache/types.ts +16 -3
  174. package/src/client.rsc.tsx +10 -9
  175. package/src/client.tsx +5 -4
  176. package/src/components/DefaultDocument.tsx +7 -2
  177. package/src/context-var.ts +6 -5
  178. package/src/escape-script.ts +1 -1
  179. package/src/handles/MetaTags.tsx +13 -11
  180. package/src/handles/Scripts.tsx +11 -10
  181. package/src/handles/meta.ts +2 -2
  182. package/src/handles/script.ts +17 -16
  183. package/src/href-client.ts +1 -1
  184. package/src/html.ts +12 -0
  185. package/src/index.rsc.ts +5 -0
  186. package/src/index.ts +1 -0
  187. package/src/loader-store.ts +7 -4
  188. package/src/prerender/build-shell-capture.ts +8 -11
  189. package/src/reverse.ts +44 -4
  190. package/src/route-definition/dsl-helpers.ts +117 -30
  191. package/src/route-definition/helpers-types.ts +33 -9
  192. package/src/route-definition/resolve-handler-use.ts +121 -6
  193. package/src/route-types.ts +0 -3
  194. package/src/router/debug-manifest.ts +10 -2
  195. package/src/router/error-handling.ts +51 -15
  196. package/src/router/handler-context.ts +34 -25
  197. package/src/router/intercept-resolution.ts +125 -39
  198. package/src/router/loader-resolution.ts +56 -11
  199. package/src/router/match-api.ts +12 -17
  200. package/src/router/match-middleware/background-revalidation.ts +161 -96
  201. package/src/router/match-middleware/cache-lookup.ts +93 -10
  202. package/src/router/match-middleware/cache-store.ts +17 -90
  203. package/src/router/middleware-types.ts +3 -5
  204. package/src/router/middleware.ts +52 -9
  205. package/src/router/prerender-match.ts +53 -19
  206. package/src/router/route-snapshot.ts +11 -1
  207. package/src/router/router-context.ts +2 -0
  208. package/src/router/router-interfaces.ts +25 -2
  209. package/src/router/router-options.ts +8 -5
  210. package/src/router/segment-resolution/fresh.ts +44 -12
  211. package/src/router/segment-resolution/loader-cache.ts +205 -45
  212. package/src/router/segment-resolution/loader-mask.ts +12 -22
  213. package/src/router/segment-resolution/loader-snapshot.ts +6 -6
  214. package/src/router/segment-resolution/mask-nested.ts +7 -7
  215. package/src/router/segment-resolution/revalidation.ts +34 -6
  216. package/src/router/segment-wrappers.ts +4 -0
  217. package/src/rsc/render-pipeline.ts +3 -0
  218. package/src/rsc/response-route-handler.ts +11 -3
  219. package/src/rsc/rsc-rendering.ts +8 -45
  220. package/src/rsc/shell-capture-constants.ts +13 -0
  221. package/src/rsc/shell-capture.ts +111 -90
  222. package/src/rsc/ssr-setup.ts +14 -8
  223. package/src/rsc/types.ts +11 -0
  224. package/src/server/context.ts +88 -11
  225. package/src/server/cookie-store.ts +5 -4
  226. package/src/server/handle-store.ts +232 -7
  227. package/src/server/request-context.ts +35 -30
  228. package/src/ssr/index.tsx +40 -7
  229. package/src/static-handler.ts +4 -1
  230. package/src/testing/flight-runtime.d.ts +21 -0
  231. package/src/testing/generated-routes.ts +2 -2
  232. package/src/testing/internal/context.ts +1 -1
  233. package/src/testing/internal/flight-client-globals.ts +7 -5
  234. package/src/testing/render-route.tsx +85 -24
  235. package/src/testing/run-loader.ts +5 -1
  236. package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
  237. package/src/testing/vitest.ts +62 -1
  238. package/src/theme/ThemeScript.tsx +3 -3
  239. package/src/types/cache-types.ts +14 -10
  240. package/src/types/handler-context.ts +14 -0
  241. package/src/types/loader-types.ts +8 -6
  242. package/src/urls/response-types.ts +3 -23
  243. package/src/use-loader.tsx +12 -3
  244. package/src/vite/plugins/use-cache-transform.ts +118 -74
  245. package/src/vite/rango.ts +7 -3
@@ -35,14 +35,14 @@ import {
35
35
 
36
36
  export { isCachedFunction };
37
37
  import { serializeResult, deserializeResult } from "./segment-codec.js";
38
- import { createHandleStore } from "../server/handle-store.js";
39
38
  import {
40
- restoreHandles,
39
+ appendHandles,
41
40
  encodeHandles,
42
41
  decodeHandles,
43
42
  } from "./handle-snapshot.js";
44
- import { startHandleCapture, type HandleCapture } from "./handle-capture.js";
45
- import { sortedSearchString } from "./cache-key-utils.js";
43
+ import { startHandleCapture, useCacheRecordKey } from "./handle-capture.js";
44
+ import { isHandle } from "../handle.js";
45
+ import { cacheKeyBase, sortedSearchString } from "./cache-key-utils.js";
46
46
  import { encodeKV } from "../encode-kv.js";
47
47
  import { runBackground } from "./background-task.js";
48
48
  import { observePhase, PHASES } from "../router/instrument.js";
@@ -51,9 +51,15 @@ import {
51
51
  recordRequestTags,
52
52
  runWithCacheTagScope,
53
53
  } from "./cache-tag.js";
54
- import { runWithCacheExecScope } from "./cache-exec-scope.js";
54
+ import {
55
+ createCacheExecScope,
56
+ isInCacheExecChain,
57
+ runWithCacheExecScope,
58
+ type CacheExecScope,
59
+ } from "./cache-exec-scope.js";
55
60
  import { reportCacheError } from "./cache-error.js";
56
61
  import type { CacheItemResult } from "./types.js";
62
+ import type { InternalHandlerContext } from "../types.js";
57
63
 
58
64
  /**
59
65
  * DJB2 hash returning an 8-char hex string. Deterministic across runtimes
@@ -125,10 +131,74 @@ export async function replyToCacheKey(
125
131
  return encodeKV(pairs, { sort: true });
126
132
  }
127
133
 
134
+ /**
135
+ * An unescaped temporary-reference token in an encodeReply part. A user
136
+ * string "$T" encodes as "$$T", a quote inside a string is escaped, and an
137
+ * object key "$T" is followed by ":".
138
+ */
139
+ const TEMPORARY_REFERENCE_TOKEN = /(?<!\\)"\$T"(?!:)/g;
140
+
141
+ const REACT_ELEMENT_TYPE = Symbol.for("react.transitional.element");
142
+ const CLIENT_REFERENCE_TYPE = Symbol.for("react.client.reference");
143
+ const SERVER_REFERENCE_TYPE = Symbol.for("react.server.reference");
144
+
145
+ function countTemporaryReferences(encoded: string | FormData): number {
146
+ if (typeof encoded === "string") {
147
+ return encoded.match(TEMPORARY_REFERENCE_TOKEN)?.length ?? 0;
148
+ }
149
+ let count = 0;
150
+ encoded.forEach((value) => {
151
+ if (typeof value === "string") {
152
+ count += value.match(TEMPORARY_REFERENCE_TOKEN)?.length ?? 0;
153
+ }
154
+ });
155
+ return count;
156
+ }
157
+
158
+ /** A React element, or a client or server reference function. */
159
+ function isRenderSlot(value: unknown): boolean {
160
+ if (typeof value === "object" && value !== null) {
161
+ return (value as { $$typeof?: unknown }).$$typeof === REACT_ELEMENT_TYPE;
162
+ }
163
+ if (typeof value === "function") {
164
+ const tag = (value as { $$typeof?: unknown }).$$typeof;
165
+ return tag === CLIENT_REFERENCE_TYPE || tag === SERVER_REFERENCE_TYPE;
166
+ }
167
+ return false;
168
+ }
169
+
170
+ /**
171
+ * Whether encodeReply wrote "$T" for a value that cannot be keyed. Given a
172
+ * temporary-reference set it does not throw on a value it cannot serialize:
173
+ * it writes "$T", so distinct arguments would share one key (issue #924).
174
+ * React elements and client/server references are render slots, left out of
175
+ * the key on purpose (the interleave route, e2e/use-cache-streaming.test.ts);
176
+ * a function, a symbol or a class instance is not.
177
+ *
178
+ * The set is the Map encodeReply fills: one entry per "$T" it writes, plus
179
+ * every object it visits. So more tokens than slot entries means a non-slot
180
+ * "$T", with no traversal of the arguments. Runs only on the encoder path.
181
+ */
182
+ function hasUnkeyableReference(
183
+ encoded: string | FormData,
184
+ tempRefs: unknown,
185
+ ): boolean {
186
+ const tokens = countTemporaryReferences(encoded);
187
+ if (tokens === 0) return false;
188
+ let slots = 0;
189
+ for (const value of (tempRefs as Map<string, unknown>).values()) {
190
+ if (isRenderSlot(value)) slots++;
191
+ }
192
+ return tokens > slots;
193
+ }
194
+
128
195
  // Cached-fn ids already warned about running uncached under a test runner, so
129
196
  // the test-ergonomics warning fires once per fn rather than once per call.
130
197
  const warnedUncachedUnderTest = new Set<string>();
131
198
 
199
+ // Cached-fn ids already warned about arguments that cannot be keyed (dev only).
200
+ const warnedUnkeyableArgs = new Set<string>();
201
+
132
202
  /**
133
203
  * Fast-path cache-key builder for JSON-safe key args. Returns a deterministic
134
204
  * string (object keys recursively sorted so insertion order can't change the
@@ -264,6 +334,30 @@ function raceLeader(
264
334
  });
265
335
  }
266
336
 
337
+ /**
338
+ * A stale refresh's view of a handler ctx: loader reads run on their own
339
+ * executor (setupLoaderAccess _runLoaderIsolated), and an inner HIT replays in
340
+ * full without claiming. The refresh diverts its pushes, so the request's
341
+ * memoized run of a loader would take that loader's live pushes off the page,
342
+ * and a claim would starve the page's own replay of it.
343
+ */
344
+ function refreshView(arg: unknown): unknown {
345
+ const ctx = arg as InternalHandlerContext<any, any>;
346
+ const runIsolated = isTainted(arg) ? ctx._runLoaderIsolated : undefined;
347
+ if (!runIsolated) return arg;
348
+ const runs = new Map<string, Promise<any>>();
349
+ const use = (item: any) => {
350
+ if (isHandle(item)) return ctx.use(item);
351
+ let run = runs.get(item.$$id);
352
+ if (!run) runs.set(item.$$id, (run = runIsolated(item)));
353
+ return run;
354
+ };
355
+ return Object.create(ctx, {
356
+ use: { value: use },
357
+ _claimLoaderPushes: { value: undefined },
358
+ });
359
+ }
360
+
267
361
  // ============================================================================
268
362
  // Core: registerCachedFunction
269
363
  // ============================================================================
@@ -333,17 +427,33 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
333
427
  );
334
428
  }
335
429
 
336
- // Separate tainted args (ctx, env, req) from key-generating args.
337
- // For tainted objects that carry route context (params, pathname,
338
- // searchParams), extract serializable values into the key so
339
- // different routes, param combinations, and query variants produce
340
- // distinct cache entries.
430
+ // Separate request-scoped args (ctx, a Request, the request's env) from
431
+ // key-generating args. For those that carry route context (ctx params,
432
+ // pathname, searchParams; a Request's URL), extract serializable values
433
+ // into the key so different routes, param combinations, and query
434
+ // variants produce distinct cache entries.
341
435
  const keyArgs: unknown[] = [];
342
436
  let hasTaintedArgs = false;
437
+ // The calling segment, read synchronously as ctx.use(Handle) does: a HIT
438
+ // replays into it. A route's layouts and page share one handler ctx and
439
+ // so one key; the recorded ids are the first caller's. Its ctx's claim
440
+ // keeps a replayed loader's pushes to one copy per request.
441
+ let callerSegmentId: string | undefined;
442
+ let claimLoaderPushes: ((loaderId: string) => boolean) | undefined;
443
+ // A fetchable loader's request body (loader-fetch.ts) is an input the
444
+ // route fields leave out: such a call runs uncached (#940).
445
+ let hasBodyCtx = false;
343
446
  for (const arg of args) {
344
447
  if (isTainted(arg)) {
345
448
  hasTaintedArgs = true;
346
449
  const ctx = arg as any;
450
+ if (ctx.body !== undefined || ctx.formData !== undefined) {
451
+ hasBodyCtx = true;
452
+ }
453
+ if (callerSegmentId === undefined) {
454
+ callerSegmentId = ctx._currentSegmentId;
455
+ claimLoaderPushes = ctx._claimLoaderPushes;
456
+ }
347
457
  if (ctx.params && typeof ctx.params === "object") {
348
458
  // Include host to prevent cross-host cache collisions (same
349
459
  // pattern as route-level cache-scope.ts key generation).
@@ -373,6 +483,22 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
373
483
  }
374
484
  }
375
485
  }
486
+ } else if (arg instanceof Request) {
487
+ // A raw Request (ctx.request) is request-scoped like ctx: fold in its
488
+ // URL with the same host-namespacing and search normalization.
489
+ const url = new URL(arg.url);
490
+ keyArgs.push(
491
+ cacheKeyBase(
492
+ url.host,
493
+ url.pathname,
494
+ url.searchParams,
495
+ undefined,
496
+ requestCtx?._searchParamsFilter,
497
+ ),
498
+ );
499
+ } else if (arg != null && arg === requestCtx?.env) {
500
+ // The request's env is left out: constant per deployment, and its
501
+ // bindings are not serializable.
376
502
  } else {
377
503
  keyArgs.push(arg);
378
504
  }
@@ -383,7 +509,7 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
383
509
  // gone. Throw early rather than silently dropping handle side effects.
384
510
  if (hasTaintedArgs && !requestCtx?._handleStore) {
385
511
  throw new Error(
386
- `[use cache] "${id}" receives a tainted argument (ctx/env/req) but the ` +
512
+ `[use cache] "${id}" receives a ctx argument but the ` +
387
513
  `HandleStore is not available. This typically happens when a "use cache" ` +
388
514
  `function with ctx runs outside the request context (e.g., during late ` +
389
515
  `streaming after AsyncLocalStorage context is lost). Move the "use cache" ` +
@@ -391,10 +517,16 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
391
517
  `use the route-level cache() DSL instead.`,
392
518
  );
393
519
  }
520
+ // Records group pushes by owning loader (useCacheRecordKey) and replay
521
+ // into the caller's segment; a caller without one records segment ids.
522
+ const ownerKeyed = callerSegmentId !== undefined;
394
523
 
395
524
  // Generate cache key
396
525
  let cacheKey: string;
397
526
  try {
527
+ if (hasBodyCtx) {
528
+ throw new Error("ctx carries a request body");
529
+ }
398
530
  if (keyArgs.length > 0) {
399
531
  // Fast path: when every key arg is JSON-safe, build the key with a
400
532
  // deterministic stable-stringify and skip encodeReply (the Flight reply
@@ -410,6 +542,9 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
410
542
  const encoded = await encodeReply(keyArgs as unknown[], {
411
543
  temporaryReferences: tempRefs,
412
544
  });
545
+ if (hasUnkeyableReference(encoded, tempRefs)) {
546
+ throw new Error("unserializable key argument");
547
+ }
413
548
  const argsKey = await replyToCacheKey(encoded);
414
549
  cacheKey = `use-cache:${id}:${argsKey}`;
415
550
  }
@@ -420,6 +555,19 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
420
555
  // Non-serializable args: run uncached (within a tag scope so cacheTag()
421
556
  // still does not throw). Record runtime tags so the document union still
422
557
  // sees them even though this call is not itself cached.
558
+ if (
559
+ process.env.NODE_ENV !== "production" &&
560
+ !warnedUnkeyableArgs.has(id)
561
+ ) {
562
+ warnedUnkeyableArgs.add(id);
563
+ console.warn(
564
+ `[use cache] "${id}" ran uncached: an argument, or a value nested in ` +
565
+ `one, cannot be serialized into the cache key (a function, class ` +
566
+ `instance or symbol, or a loader ctx carrying a request body). ` +
567
+ `Pass serializable values; ctx, a Request, the request's env and ` +
568
+ `React elements are handled.`,
569
+ );
570
+ }
423
571
  const scoped = runWithCacheTagScope(() => fn.apply(this, args));
424
572
  const result = await scoped.result;
425
573
  recordRequestTags(scoped.tags, requestCtx);
@@ -441,7 +589,9 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
441
589
  const handleStore = requestCtx?._handleStore;
442
590
  if (handleStore) {
443
591
  const r = await decodeHandles(entry.handles);
444
- if (r) restoreHandles(r, handleStore);
592
+ if (r) {
593
+ appendHandles(r, handleStore, callerSegmentId, claimLoaderPushes);
594
+ }
445
595
  }
446
596
  }
447
597
  recordRequestTags(entry.tags, requestCtx);
@@ -474,34 +624,29 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
474
624
  // Stale hit: return stale value, revalidate in background
475
625
  try {
476
626
  const result = await serveCached(cached);
627
+ const liveStore = hasTaintedArgs ? requestCtx?._handleStore : undefined;
628
+ const recordKey = ownerKeyed ? useCacheRecordKey() : undefined;
477
629
  // Background revalidation — must capture handles if tainted args present.
478
630
  runBackground(requestCtx, async () => {
479
- // The background body runs under a DERIVED context with an OWN
480
- // _handleStore (the shell-capture isolation pattern —
481
- // shell-capture.ts attemptCapture): its handle pushes land in the
482
- // isolated store (captured below, persisted with the entry) while
483
- // the foreground keeps pushing into the ORIGINAL store, untouched.
484
- // Derivation matters because the foreground is STILL RENDERING here
485
- // — runBackground/waitUntil starts the task on the next microtask,
486
- // not after the response. The previous shape swapped
487
- // requestCtx._handleStore in place (restore in finally), which
488
- // routed the whole overlap window's foreground pushes into the
489
- // background store: lost from the live document AND persisted into
490
- // the revalidated entry (issue #684, plan 010).
491
- const bgHandleStore =
492
- hasTaintedArgs && requestCtx ? createHandleStore() : undefined;
493
- const bgCtx: typeof requestCtx = bgHandleStore
494
- ? Object.assign(Object.create(requestCtx), {
495
- _handleStore: bgHandleStore,
631
+ // The background body's handle pushes belong to the refreshed entry
632
+ // only: the foreground already replayed the stale entry's. They
633
+ // reach the REQUEST's store (a handler ctx.use(Handle) binds it at
634
+ // setupLoaderAccess, so a derived context with its own store never
635
+ // sees them), so the capture accepts only this body's chain and
636
+ // diverts what it records away from the live store. The foreground
637
+ // is still rendering here (runBackground starts on the next
638
+ // microtask), and its concurrent pushes are neither recorded nor
639
+ // diverted (issue #684, plan 010). A root scope: the task is
640
+ // detached, so an outer "use cache" body that scheduled it must not
641
+ // record the refresh's pushes.
642
+ const bgScope: CacheExecScope = {};
643
+ const bgCapture = liveStore
644
+ ? startHandleCapture(liveStore, {
645
+ accept: () => isInCacheExecChain(bgScope),
646
+ divert: true,
647
+ key: recordKey,
496
648
  })
497
- : requestCtx;
498
- let bgCapture: HandleCapture | undefined;
499
- let bgStopCapture: (() => void) | undefined;
500
- if (bgHandleStore) {
501
- const c = startHandleCapture(bgHandleStore);
502
- bgCapture = c.capture;
503
- bgStopCapture = c.stop;
504
- }
649
+ : undefined;
505
650
 
506
651
  // Tainted args are NOT stamped here, in contrast to the foreground
507
652
  // miss path below. The args include the live HandlerContext the
@@ -522,23 +667,25 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
522
667
  // getRequestContext().env.ApiKey) resolves during the background
523
668
  // revalidation instead of throwing "called outside of a request
524
669
  // context". runWithRequestContext sets the store for fn's
525
- // synchronous kickoff; its async continuations inherit it. The
526
- // DERIVED context goes in, so ambient _handleStore reads inside
527
- // the body resolve to the isolated store.
670
+ // synchronous kickoff; its async continuations inherit it.
528
671
  //
529
672
  // The span opens inside the request ALS and wraps the WHOLE task —
530
673
  // fn kickoff through the store write — so its platform spans nest
531
674
  // under rango.background and a slow or failing setItem is part of
532
675
  // the traced revalidation, not an untraced tail.
533
- await runWithRequestContext(bgCtx, () =>
676
+ await runWithRequestContext(requestCtx, () =>
534
677
  observePhase(
535
678
  PHASES.background("use-cache-revalidation"),
536
679
  async () => {
680
+ const refreshArgs = args.map(refreshView);
537
681
  const scoped = runWithCacheTagScope(() =>
538
- runWithCacheExecScope(() => fn.apply(this, args)),
682
+ runWithCacheExecScope(
683
+ () => fn.apply(this, refreshArgs),
684
+ bgScope,
685
+ ),
539
686
  );
540
687
  const freshResult = await scoped.result;
541
- bgStopCapture?.();
688
+ bgCapture?.stop();
542
689
  // Merge profile/DSL tags with runtime cacheTag() tags, read
543
690
  // after awaiting so post-await cacheTag() calls are included.
544
691
  // Normalize (drops empty profile tags, matching the
@@ -549,11 +696,24 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
549
696
  ),
550
697
  ];
551
698
  recordRequestTags(freshTags, requestCtx);
552
- const serialized = await serializeResult(freshResult);
699
+ const flightErrors: unknown[] = [];
700
+ const onFlightError = (error: unknown): void => {
701
+ flightErrors.push(error);
702
+ };
703
+ const serialized = await serializeResult(
704
+ freshResult,
705
+ onFlightError,
706
+ );
553
707
  if (serialized !== null) {
554
- const encodedHandles = bgCapture?.data
555
- ? await encodeHandles(bgCapture.data)
708
+ const encodedHandles = bgCapture
709
+ ? await encodeHandles(
710
+ bgCapture.capture.data,
711
+ onFlightError,
712
+ )
556
713
  : undefined;
714
+ // An error row would replace the stale entry; the catch
715
+ // below reports it and the stale entry keeps serving.
716
+ if (flightErrors.length > 0) throw flightErrors[0];
557
717
  await store.setItem!(cacheKey, serialized, {
558
718
  handles: encodedHandles,
559
719
  ttl: profile.ttl,
@@ -565,7 +725,7 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
565
725
  ),
566
726
  );
567
727
  } catch (bgError) {
568
- bgStopCapture?.();
728
+ bgCapture?.stop();
569
729
  // Pass requestCtx explicitly: this runs in a detached background
570
730
  // task where the ALS context is gone, so onError can only fire if
571
731
  // we hand it the context captured up front.
@@ -576,8 +736,6 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
576
736
  requestCtx,
577
737
  );
578
738
  }
579
- // No finally: nothing shared was mutated — the derived context and
580
- // its handle store are garbage after the task settles.
581
739
  });
582
740
  return result;
583
741
  } catch (error) {
@@ -669,14 +827,17 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
669
827
  };
670
828
 
671
829
  // execute, serialize, store
830
+ // Record only this execution's pushes (and cached functions it calls):
831
+ // the handler and loaders push into the same store concurrently, and they
832
+ // re-run on a hit.
672
833
  const handleStore = hasTaintedArgs ? requestCtx?._handleStore : undefined;
673
- let capture: HandleCapture | undefined;
674
- let stopCapture: (() => void) | undefined;
675
- if (handleStore && hasTaintedArgs) {
676
- const c = startHandleCapture(handleStore);
677
- capture = c.capture;
678
- stopCapture = c.stop;
679
- }
834
+ const execScope = createCacheExecScope();
835
+ const capture = handleStore
836
+ ? startHandleCapture(handleStore, {
837
+ accept: () => isInCacheExecChain(execScope),
838
+ key: ownerKeyed ? useCacheRecordKey() : undefined,
839
+ })
840
+ : undefined;
680
841
 
681
842
  // Stamp tainted args so ctx.set(), ctx.header(), etc. throw if called
682
843
  // inside the cached function body (those side effects are lost on hit).
@@ -702,7 +863,7 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
702
863
  let scoped: ReturnType<typeof runWithCacheTagScope>;
703
864
  try {
704
865
  scoped = runWithCacheTagScope(() =>
705
- runWithCacheExecScope(() => fn.apply(this, args)),
866
+ runWithCacheExecScope(() => fn.apply(this, args), execScope),
706
867
  );
707
868
  result = await scoped.result;
708
869
  } catch (execError) {
@@ -717,7 +878,7 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
717
878
  unstampCacheExec(arg as object);
718
879
  }
719
880
  // Remove this capture token (order-independent, safe for concurrent use)
720
- stopCapture?.();
881
+ capture?.stop();
721
882
  }
722
883
 
723
884
  // Merge profile/DSL tags with runtime cacheTag() tags. Read scoped.tags
@@ -735,11 +896,19 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
735
896
  const finalizeAndWrite = async (): Promise<void> => {
736
897
  let serialized: string | null;
737
898
  let encodedHandles: string | undefined;
899
+ const flightErrors: unknown[] = [];
900
+ const onFlightError = (error: unknown): void => {
901
+ flightErrors.push(error);
902
+ };
738
903
  try {
739
- serialized = await serializeResult(result);
740
- encodedHandles = capture?.data
741
- ? await encodeHandles(capture.data)
904
+ serialized = await serializeResult(result, onFlightError);
905
+ encodedHandles = capture
906
+ ? await encodeHandles(capture.capture.data, onFlightError)
742
907
  : undefined;
908
+ // Flight encodes an async component that throws, or a rejected
909
+ // promise in the result or a handle value, as an error row and
910
+ // completes normally; stored, every hit would serve that error.
911
+ if (flightErrors.length > 0) throw flightErrors[0];
743
912
  } catch (buildError) {
744
913
  // Serialize/handle-encode failed: no envelope for followers (they run
745
914
  // fresh) and nothing to write.
@@ -28,6 +28,7 @@ import { reportCacheError } from "./cache-error.js";
28
28
  // is unchanged: both methods are async and already awaited the codec.
29
29
  import {
30
30
  captureHandles,
31
+ captureHandleOwners,
31
32
  restoreHandles,
32
33
  encodeHandles,
33
34
  decodeHandles,
@@ -149,6 +150,14 @@ export class CacheScope {
149
150
  config: PartialCacheOptions | false,
150
151
  parent: CacheScope | null = null,
151
152
  private readonly defaultKeyPrefix?: "doc",
153
+ /**
154
+ * @internal shortCode of the outermost cache() entry of this scope's
155
+ * enabled chain (createCacheScope). Entries above it are live: their
156
+ * segments are not stored and resolve fresh on a hit (withCacheLookup).
157
+ * Undefined covers the whole matched chain (the implicit shell doc scope,
158
+ * a ppr route).
159
+ */
160
+ readonly boundary?: string,
152
161
  ) {
153
162
  this.config = config;
154
163
  this.parent = parent;
@@ -156,6 +165,24 @@ export class CacheScope {
156
165
  this.explicitStore = config !== false ? config.store : undefined;
157
166
  }
158
167
 
168
+ /**
169
+ * @internal Whether a segment belongs to this scope's cache entry. ShortCodes are
170
+ * hierarchical (server/context.ts getShortCode: parent shortCode + include
171
+ * scope + type letter + counter), so the boundary's subtree is every id that
172
+ * extends the boundary shortCode at a non-digit (`C1` must not claim `C10`).
173
+ * Intercept slot segments ride the target route's entry wherever the
174
+ * intercept is declared.
175
+ */
176
+ covers(id: string, namespace?: string): boolean {
177
+ const boundary = this.boundary;
178
+ if (boundary === undefined) return true;
179
+ if (id.startsWith(boundary)) {
180
+ const next = id.charCodeAt(boundary.length);
181
+ return !(next >= 48 && next <= 57);
182
+ }
183
+ return namespace?.startsWith("intercept:") === true;
184
+ }
185
+
159
186
  /**
160
187
  * Whether caching is enabled for this scope
161
188
  */
@@ -316,11 +343,14 @@ export class CacheScope {
316
343
  * @param pathname - URL pathname for cache key generation
317
344
  * @param params - Route params for cache key generation
318
345
  * @param isIntercept - Whether this is an intercept navigation (uses different cache key)
346
+ * @param claimLoaderPushes - The handler context's _claimLoaderPushes, for
347
+ * restoreHandles' loader-owned values
319
348
  */
320
349
  async lookupRoute(
321
350
  pathname: string,
322
351
  params: Record<string, string>,
323
352
  isIntercept?: boolean,
353
+ claimLoaderPushes?: (loaderId: string) => boolean,
324
354
  ): Promise<{
325
355
  segments: ResolvedSegment[];
326
356
  shouldRevalidate: boolean;
@@ -329,6 +359,7 @@ export class CacheScope {
329
359
  pathname,
330
360
  params,
331
361
  isIntercept,
362
+ claimLoaderPushes,
332
363
  );
333
364
  return outcome.status === "hit" ? outcome.result : null;
334
365
  }
@@ -355,6 +386,7 @@ export class CacheScope {
355
386
  pathname: string,
356
387
  params: Record<string, string>,
357
388
  isIntercept?: boolean,
389
+ claimLoaderPushes?: (loaderId: string) => boolean,
358
390
  ): Promise<CacheRouteLookupOutcome> {
359
391
  if (!this.enabled) return { status: "bypass" };
360
392
  if (!this.conditionAllows("read")) return { status: "bypass" };
@@ -424,6 +456,14 @@ export class CacheScope {
424
456
  return { status: "miss" };
425
457
  }
426
458
 
459
+ // A record from a whole-chain writer (a previous build on an unversioned
460
+ // store) can hold segments above the boundary. withCacheLookup resolves
461
+ // those fresh, so replaying them too would duplicate them and their
462
+ // handle pushes.
463
+ if (this.boundary !== undefined) {
464
+ segments = segments.filter((s) => this.covers(s.id, s.namespace));
465
+ }
466
+
427
467
  // A hit serves content that was tagged at write time, so the document
428
468
  // tag union must include this entry's tags for updateTag()/revalidateTag()
429
469
  // to invalidate any full-page entry built on top of it. The write path
@@ -438,7 +478,18 @@ export class CacheScope {
438
478
  if (handleStore && cached.handles) {
439
479
  const handlesRecord = await decodeHandles(cached.handles);
440
480
  if (handlesRecord) {
441
- restoreHandles(handlesRecord, handleStore);
481
+ if (this.boundary !== undefined) {
482
+ const kept = new Set(segments.map((s) => s.id));
483
+ for (const id of Object.keys(handlesRecord)) {
484
+ if (!kept.has(id)) delete handlesRecord[id];
485
+ }
486
+ }
487
+ restoreHandles(
488
+ handlesRecord,
489
+ handleStore,
490
+ cached.handleOwners,
491
+ claimLoaderPushes,
492
+ );
442
493
  }
443
494
  }
444
495
 
@@ -515,8 +566,11 @@ export class CacheScope {
515
566
  if (!handleStore || !requestCtx) return;
516
567
 
517
568
  // Exclude loader segments - loaders are always fresh by default
518
- // Loaders can opt-in to caching with their own cache() config
519
- const nonLoaderSegments = segments.filter((s) => s.type !== "loader");
569
+ // Loaders can opt-in to caching with their own cache() config.
570
+ // Segments above the boundary are live and never stored.
571
+ const nonLoaderSegments = segments.filter(
572
+ (s) => s.type !== "loader" && this.covers(s.id, s.namespace),
573
+ );
520
574
  if (nonLoaderSegments.length === 0) return;
521
575
 
522
576
  const ttl = this.ttl;
@@ -585,11 +639,8 @@ export class CacheScope {
585
639
  }
586
640
 
587
641
  // Collect handle data for non-loader segments only
588
- const handles = captureHandles(
589
- nonLoaderSegments,
590
- handleStore,
591
- requestCtx._shellCaptureLoaderHandleValues,
592
- );
642
+ const handles = captureHandles(nonLoaderSegments, handleStore);
643
+ const handleOwners = captureHandleOwners(nonLoaderSegments, handleStore);
593
644
 
594
645
  try {
595
646
  if (INTERNAL_RANGO_DEBUG) {
@@ -602,10 +653,18 @@ export class CacheScope {
602
653
  // through the codec (not raw into the entry) so Promise/ReactNode handle
603
654
  // values survive a JSON-serializing store — see encodeHandles.
604
655
  const { serializeSegments } = await import("./segment-codec.js");
656
+ const flightErrors: unknown[] = [];
657
+ const onFlightError = (error: unknown): void => {
658
+ flightErrors.push(error);
659
+ };
605
660
  const [serializedSegments, encodedHandles] = await Promise.all([
606
- serializeSegments(nonLoaderSegments),
607
- encodeHandles(handles),
661
+ serializeSegments(nonLoaderSegments, onFlightError),
662
+ encodeHandles(handles, onFlightError),
608
663
  ]);
664
+ // Flight encodes a component that throws (an async server component in
665
+ // the tree) or a rejected handle value as an error row and completes
666
+ // normally; stored, every HIT would render that error until expiry.
667
+ if (flightErrors.length > 0) throw flightErrors[0];
609
668
 
610
669
  const data: CachedEntryData = {
611
670
  segments: serializedSegments,
@@ -613,6 +672,7 @@ export class CacheScope {
613
672
  expiresAt: Date.now() + ttl * 1000,
614
673
  tags,
615
674
  };
675
+ if (handleOwners && encodedHandles) data.handleOwners = handleOwners;
616
676
 
617
677
  if (INTERNAL_RANGO_DEBUG) {
618
678
  debugCacheLog(`[CacheScope] waitUntil: calling store.set for ${key}`);
@@ -640,14 +700,18 @@ export class CacheScope {
640
700
  }
641
701
 
642
702
  /**
643
- * Create a cache scope from entry's cache config
703
+ * Create a cache scope from entry's cache config. `shortCode` is the cache()
704
+ * entry's: a scope nested in an enabled parent keeps the parent's boundary
705
+ * (one entry covers both), any other opens its own at this entry.
644
706
  */
645
707
  export function createCacheScope(
646
708
  config: { options: PartialCacheOptions | false } | undefined,
647
709
  parent: CacheScope | null = null,
710
+ shortCode?: string,
648
711
  ): CacheScope | null {
649
712
  if (!config) return parent; // No config, inherit parent
650
- return new CacheScope(config.options, parent);
713
+ const boundary = parent?.enabled ? parent.boundary : shortCode;
714
+ return new CacheScope(config.options, parent, undefined, boundary);
651
715
  }
652
716
 
653
717
  type ShellImplicitCacheMarker = NonNullable<