@rangojs/router 0.15.1 → 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 (249) 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 +52 -0
  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 +36 -10
  32. package/dist/types/route-definition/resolve-handler-use.d.ts +23 -1
  33. package/dist/types/route-types.d.ts +3 -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/thenable-status.d.ts +9 -0
  67. package/dist/types/types/cache-types.d.ts +14 -10
  68. package/dist/types/types/handler-context.d.ts +14 -0
  69. package/dist/types/types/loader-types.d.ts +8 -6
  70. package/dist/types/urls/response-types.d.ts +3 -12
  71. package/dist/types/use-loader.d.ts +12 -3
  72. package/dist/types/vite/plugins/use-cache-transform.d.ts +33 -2
  73. package/dist/types/vite/rango.d.ts +1 -1
  74. package/dist/vite/index.js +884 -768
  75. package/package.json +4 -4
  76. package/skills/api-client/SKILL.md +22 -10
  77. package/skills/breadcrumbs/SKILL.md +65 -22
  78. package/skills/bundle-analysis/SKILL.md +22 -22
  79. package/skills/cache-guide/SKILL.md +207 -97
  80. package/skills/caching/SKILL.md +295 -93
  81. package/skills/catalog.json +16 -16
  82. package/skills/client-urls/SKILL.md +53 -31
  83. package/skills/cloudflare/SKILL.md +72 -20
  84. package/skills/cloudflare/references/d1-and-local-dev.md +15 -2
  85. package/skills/cloudflare/references/streaming-and-deploy.md +10 -3
  86. package/skills/cloudflare/references/webhooks-and-crypto.md +9 -2
  87. package/skills/comparison/SKILL.md +5 -0
  88. package/skills/comparison/references/framework-comparison.md +39 -33
  89. package/skills/composability/SKILL.md +38 -29
  90. package/skills/css/SKILL.md +10 -13
  91. package/skills/debug-manifest/SKILL.md +74 -31
  92. package/skills/defer-hydration/SKILL.md +2 -1
  93. package/skills/deployment-caching/SKILL.md +6 -1
  94. package/skills/document-cache/SKILL.md +92 -28
  95. package/skills/fonts/SKILL.md +16 -8
  96. package/skills/handler-use/SKILL.md +53 -17
  97. package/skills/hooks/SKILL.md +28 -20
  98. package/skills/hooks/data.md +43 -1
  99. package/skills/hooks/handle-and-actions.md +31 -16
  100. package/skills/hooks/navigation.md +39 -10
  101. package/skills/hooks/outlets.md +13 -6
  102. package/skills/hooks/state.md +37 -15
  103. package/skills/hooks/urls.md +7 -5
  104. package/skills/host-router/SKILL.md +64 -12
  105. package/skills/i18n/SKILL.md +60 -34
  106. package/skills/intercept/SKILL.md +161 -26
  107. package/skills/layout/SKILL.md +106 -50
  108. package/skills/links/SKILL.md +51 -19
  109. package/skills/loader/SKILL.md +242 -106
  110. package/skills/middleware/SKILL.md +119 -23
  111. package/skills/migrate-nextjs/SKILL.md +100 -45
  112. package/skills/migrate-nextjs/backend-host-swap.md +5 -2
  113. package/skills/migrate-react-router/SKILL.md +49 -38
  114. package/skills/migrate-react-router/component-migration.md +25 -6
  115. package/skills/migrate-react-router/data-and-actions.md +18 -5
  116. package/skills/migrate-react-router/route-mapping.md +12 -6
  117. package/skills/mime-routes/SKILL.md +12 -7
  118. package/skills/observability/SKILL.md +28 -14
  119. package/skills/parallel/SKILL.md +75 -39
  120. package/skills/ppr/SKILL.md +345 -150
  121. package/skills/prerender/SKILL.md +189 -100
  122. package/skills/rango/SKILL.md +65 -34
  123. package/skills/react-compiler/SKILL.md +6 -5
  124. package/skills/response-routes/SKILL.md +133 -25
  125. package/skills/route/SKILL.md +165 -60
  126. package/skills/router-setup/SKILL.md +418 -240
  127. package/skills/scripts/SKILL.md +31 -20
  128. package/skills/server-actions/SKILL.md +220 -59
  129. package/skills/shell-manifest/SKILL.md +12 -7
  130. package/skills/streams-and-websockets/SKILL.md +32 -14
  131. package/skills/tailwind/SKILL.md +37 -33
  132. package/skills/testing/SKILL.md +68 -32
  133. package/skills/testing/cache-prerender.md +55 -29
  134. package/skills/testing/client-components.md +59 -6
  135. package/skills/testing/e2e-parity.md +12 -2
  136. package/skills/testing/flight.md +19 -11
  137. package/skills/testing/handles.md +8 -5
  138. package/skills/testing/loader.md +8 -5
  139. package/skills/testing/middleware.md +8 -3
  140. package/skills/testing/predicates.md +120 -0
  141. package/skills/testing/render-handler.md +70 -22
  142. package/skills/testing/response-routes.md +11 -4
  143. package/skills/testing/reverse-and-types.md +20 -14
  144. package/skills/testing/server-actions.md +12 -3
  145. package/skills/testing/server-tree.md +9 -0
  146. package/skills/testing/setup.md +9 -5
  147. package/skills/theme/SKILL.md +106 -29
  148. package/skills/typesafety/SKILL.md +20 -9
  149. package/skills/typesafety/env-and-bindings.md +126 -47
  150. package/skills/typesafety/generated-files-and-cli.md +26 -14
  151. package/skills/typesafety/params-and-search.md +44 -19
  152. package/skills/typesafety/route-types.md +50 -14
  153. package/skills/use-cache/SKILL.md +200 -82
  154. package/skills/vercel/SKILL.md +21 -11
  155. package/skills/view-transitions/SKILL.md +41 -20
  156. package/src/__augment-tests__/augmented.check.ts +26 -1
  157. package/src/browser/index.ts +2 -2
  158. package/src/browser/navigation-bridge.ts +3 -4
  159. package/src/browser/navigation-transaction.ts +22 -2
  160. package/src/browser/partial-update.ts +75 -80
  161. package/src/browser/react/ScrollRestoration.tsx +6 -6
  162. package/src/browser/rsc-router.tsx +1 -1
  163. package/src/browser/scroll-restoration.ts +53 -6
  164. package/src/cache/cache-exec-scope.ts +37 -4
  165. package/src/cache/cache-key-utils.ts +1 -6
  166. package/src/cache/cache-runtime.ts +230 -61
  167. package/src/cache/cache-scope.ts +76 -12
  168. package/src/cache/document-cache.ts +15 -0
  169. package/src/cache/handle-capture.ts +90 -10
  170. package/src/cache/handle-snapshot.ts +132 -28
  171. package/src/cache/segment-codec.ts +26 -7
  172. package/src/cache/tag-invalidation.ts +4 -3
  173. package/src/cache/taint.ts +1 -1
  174. package/src/cache/types.ts +16 -3
  175. package/src/client.rsc.tsx +10 -9
  176. package/src/client.tsx +5 -4
  177. package/src/components/DefaultDocument.tsx +7 -2
  178. package/src/context-var.ts +6 -5
  179. package/src/escape-script.ts +1 -1
  180. package/src/handles/MetaTags.tsx +13 -11
  181. package/src/handles/Scripts.tsx +11 -10
  182. package/src/handles/meta.ts +2 -2
  183. package/src/handles/script.ts +17 -16
  184. package/src/href-client.ts +1 -1
  185. package/src/html.ts +12 -0
  186. package/src/index.rsc.ts +5 -0
  187. package/src/index.ts +1 -0
  188. package/src/loader-store.ts +106 -0
  189. package/src/prerender/build-shell-capture.ts +8 -11
  190. package/src/reverse.ts +44 -4
  191. package/src/route-definition/dsl-helpers.ts +134 -42
  192. package/src/route-definition/helpers-types.ts +36 -10
  193. package/src/route-definition/resolve-handler-use.ts +121 -6
  194. package/src/route-types.ts +2 -3
  195. package/src/router/debug-manifest.ts +10 -2
  196. package/src/router/error-handling.ts +51 -15
  197. package/src/router/handler-context.ts +34 -25
  198. package/src/router/intercept-resolution.ts +125 -39
  199. package/src/router/loader-resolution.ts +56 -11
  200. package/src/router/match-api.ts +12 -17
  201. package/src/router/match-middleware/background-revalidation.ts +161 -96
  202. package/src/router/match-middleware/cache-lookup.ts +93 -10
  203. package/src/router/match-middleware/cache-store.ts +17 -90
  204. package/src/router/middleware-types.ts +3 -5
  205. package/src/router/middleware.ts +52 -9
  206. package/src/router/prerender-match.ts +53 -19
  207. package/src/router/route-snapshot.ts +11 -1
  208. package/src/router/router-context.ts +2 -0
  209. package/src/router/router-interfaces.ts +25 -2
  210. package/src/router/router-options.ts +8 -5
  211. package/src/router/segment-resolution/fresh.ts +44 -12
  212. package/src/router/segment-resolution/loader-cache.ts +205 -45
  213. package/src/router/segment-resolution/loader-mask.ts +12 -22
  214. package/src/router/segment-resolution/loader-snapshot.ts +6 -6
  215. package/src/router/segment-resolution/mask-nested.ts +7 -7
  216. package/src/router/segment-resolution/revalidation.ts +34 -6
  217. package/src/router/segment-wrappers.ts +4 -0
  218. package/src/rsc/render-pipeline.ts +3 -0
  219. package/src/rsc/response-route-handler.ts +11 -3
  220. package/src/rsc/rsc-rendering.ts +8 -45
  221. package/src/rsc/shell-capture-constants.ts +13 -0
  222. package/src/rsc/shell-capture.ts +111 -90
  223. package/src/rsc/ssr-setup.ts +14 -8
  224. package/src/rsc/types.ts +11 -0
  225. package/src/server/context.ts +88 -11
  226. package/src/server/cookie-store.ts +5 -4
  227. package/src/server/handle-store.ts +232 -7
  228. package/src/server/request-context.ts +35 -30
  229. package/src/ssr/index.tsx +40 -7
  230. package/src/ssr-suspension-warning.ts +2 -13
  231. package/src/static-handler.ts +4 -1
  232. package/src/testing/flight-runtime.d.ts +21 -0
  233. package/src/testing/generated-routes.ts +2 -2
  234. package/src/testing/internal/context.ts +1 -1
  235. package/src/testing/internal/flight-client-globals.ts +7 -5
  236. package/src/testing/render-route.tsx +85 -24
  237. package/src/testing/run-loader.ts +5 -1
  238. package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
  239. package/src/testing/vitest.ts +62 -1
  240. package/src/theme/ThemeProvider.tsx +14 -12
  241. package/src/theme/ThemeScript.tsx +3 -3
  242. package/src/thenable-status.ts +16 -0
  243. package/src/types/cache-types.ts +14 -10
  244. package/src/types/handler-context.ts +14 -0
  245. package/src/types/loader-types.ts +8 -6
  246. package/src/urls/response-types.ts +3 -23
  247. package/src/use-loader.tsx +24 -4
  248. package/src/vite/plugins/use-cache-transform.ts +118 -74
  249. package/src/vite/rango.ts +7 -3
@@ -72,6 +72,13 @@ type GetScrollKeyFunction = (location: {
72
72
 
73
73
  let customGetKey: GetScrollKeyFunction | null = null;
74
74
 
75
+ /**
76
+ * Scroll key of the entry on screen. At popstate history.state already belongs
77
+ * to the destination, so this is the only way to save the page being left
78
+ * under its own key (see handleTraversalStart).
79
+ */
80
+ let displayedKey: string | null = null;
81
+
75
82
  /**
76
83
  * Generate a unique key for the current history entry.
77
84
  * Uses history.state.key if available, otherwise generates and stores a new one.
@@ -138,7 +145,7 @@ export function initScrollRestoration(options?: {
138
145
  }
139
146
 
140
147
  // Ensure current history entry has a key
141
- getHistoryStateKey();
148
+ trackDisplayedEntry();
142
149
 
143
150
  // Save scroll positions on pagehide (before leaving/refreshing)
144
151
  const handlePageHide = () => {
@@ -148,7 +155,14 @@ export function initScrollRestoration(options?: {
148
155
  window.history.scrollRestoration = "auto";
149
156
  };
150
157
 
158
+ // A bfcache restore keeps the "auto" set on pagehide; take scroll back.
159
+ // Only here: without <Html.ScrollRestoration> the browser owns scroll.
160
+ const handlePageShow = (event: PageTransitionEvent) => {
161
+ if (event.persisted) window.history.scrollRestoration = "manual";
162
+ };
163
+
151
164
  window.addEventListener("pagehide", handlePageHide);
165
+ window.addEventListener("pageshow", handlePageShow);
152
166
 
153
167
  if (IS_BROWSER_DEBUG) {
154
168
  debugLog(
@@ -160,10 +174,12 @@ export function initScrollRestoration(options?: {
160
174
  return () => {
161
175
  cancelScrollRestorationPolling();
162
176
  window.removeEventListener("pagehide", handlePageHide);
177
+ window.removeEventListener("pageshow", handlePageShow);
163
178
  window.history.scrollRestoration = "auto";
164
179
  initialized = false;
165
180
  savedScrollPositions = {};
166
181
  scrollKeyOrder = [];
182
+ displayedKey = null;
167
183
  };
168
184
  }
169
185
 
@@ -172,8 +188,10 @@ export function initScrollRestoration(options?: {
172
188
  * Maintains bounded size by evicting oldest entries when the limit is exceeded.
173
189
  */
174
190
  export function saveCurrentScrollPosition(): void {
175
- const key = getScrollKey();
191
+ saveScrollPosition(getScrollKey(), window.scrollY);
192
+ }
176
193
 
194
+ function saveScrollPosition(key: string, y: number): void {
177
195
  // If this key already exists, remove it from its current position
178
196
  // in the order array so it can be re-appended at the end (most recent).
179
197
  const existingIndex = scrollKeyOrder.indexOf(key);
@@ -181,7 +199,7 @@ export function saveCurrentScrollPosition(): void {
181
199
  scrollKeyOrder.splice(existingIndex, 1);
182
200
  }
183
201
 
184
- savedScrollPositions[key] = window.scrollY;
202
+ savedScrollPositions[key] = y;
185
203
  scrollKeyOrder.push(key);
186
204
 
187
205
  // Evict oldest entries if we exceed the limit
@@ -354,6 +372,23 @@ export function handleNavigationStart(): void {
354
372
  saveCurrentScrollPosition();
355
373
  }
356
374
 
375
+ /**
376
+ * Handle scroll for a back/forward traversal, at popstate before anything
377
+ * renders: save the page being left under ITS key (history has already moved,
378
+ * so saveCurrentScrollPosition would use the destination's key) and track the
379
+ * destination as the displayed entry.
380
+ */
381
+ export function handleTraversalStart(): void {
382
+ if (!initialized) return;
383
+ const leftKey = displayedKey;
384
+ trackDisplayedEntry();
385
+ // A key shared with the destination (a getKey that collapses entries, e.g.
386
+ // by pathname) keeps the destination's saved position.
387
+ if (leftKey !== null && leftKey !== displayedKey) {
388
+ saveScrollPosition(leftKey, window.scrollY);
389
+ }
390
+ }
391
+
357
392
  /**
358
393
  * Handle scroll after navigation completes.
359
394
  * @param options.restore - If true, restore saved position (for popstate)
@@ -376,10 +411,17 @@ export function handleNavigationEnd(options: {
376
411
  // But basic scroll-to-top and hash scrolling work without it — this
377
412
  // matters during cross-app navigation where ScrollRestoration unmounts
378
413
  // and remounts, creating a brief window where initialized is false.
379
- if (restore && initialized) {
380
- if (restoreScrollPosition({ retryIfStreaming: true, isStreaming })) {
414
+ if (restore) {
415
+ if (
416
+ initialized &&
417
+ restoreScrollPosition({ retryIfStreaming: true, isStreaming })
418
+ ) {
381
419
  return;
382
420
  }
421
+ // An "auto" entry (no <Html.ScrollRestoration>, or created before it mounted)
422
+ // is restored by the browser. A scrollTo here races that restore, and
423
+ // when it runs second the page lands at the top.
424
+ if (window.history.scrollRestoration === "auto") return;
383
425
  // Fall through to hash or top if no saved position
384
426
  }
385
427
 
@@ -410,5 +452,10 @@ export function handleNavigationEnd(options: {
410
452
  * Call this after changing history to ensure new entry has a key.
411
453
  */
412
454
  export function ensureHistoryKey(): void {
413
- getHistoryStateKey();
455
+ trackDisplayedEntry();
456
+ }
457
+
458
+ /** Mints the current entry's key if missing and records it as displayed. */
459
+ function trackDisplayedEntry(): void {
460
+ displayedKey = getScrollKey();
414
461
  }
@@ -26,19 +26,52 @@
26
26
  import { AsyncLocalStorage } from "node:async_hooks";
27
27
  import { _setCacheExecScopeProbe } from "./taint.js";
28
28
 
29
- const cacheExecStorage = new AsyncLocalStorage<true>();
29
+ /**
30
+ * One "use cache" execution. Identity matters: the execution's handle capture
31
+ * (cache-runtime.ts) records only pushes from its own chain, including cached
32
+ * functions it calls (`parent` links them), not concurrent pushes into the
33
+ * same request store.
34
+ */
35
+ export interface CacheExecScope {
36
+ readonly parent?: CacheExecScope;
37
+ }
38
+
39
+ const cacheExecStorage = new AsyncLocalStorage<CacheExecScope>();
30
40
 
31
41
  /**
32
42
  * Run fn with the "use cache" execution scope active. Continuations spawned
33
43
  * from fn's synchronous kickoff inherit the scope; parallel chains do not.
44
+ * Pass `scope` (createCacheExecScope) to test membership with
45
+ * isInCacheExecChain.
34
46
  */
35
- export function runWithCacheExecScope<T>(fn: () => T): T {
36
- return cacheExecStorage.run(true, fn);
47
+ export function runWithCacheExecScope<T>(
48
+ fn: () => T,
49
+ scope: CacheExecScope = createCacheExecScope(),
50
+ ): T {
51
+ return cacheExecStorage.run(scope, fn);
52
+ }
53
+
54
+ /** A scope nested in the calling chain's current one, if any. */
55
+ export function createCacheExecScope(): CacheExecScope {
56
+ return { parent: cacheExecStorage.getStore() };
37
57
  }
38
58
 
39
59
  /** True when the calling async chain is inside a "use cache" body. */
40
60
  export function isInsideCacheExecScope(): boolean {
41
- return cacheExecStorage.getStore() === true;
61
+ return cacheExecStorage.getStore() !== undefined;
62
+ }
63
+
64
+ /** The calling chain's innermost "use cache" scope, if any. */
65
+ export function getCacheExecScope(): CacheExecScope | undefined {
66
+ return cacheExecStorage.getStore();
67
+ }
68
+
69
+ /** True when the calling async chain runs inside `scope`, at any depth. */
70
+ export function isInCacheExecChain(scope: CacheExecScope): boolean {
71
+ for (let s = cacheExecStorage.getStore(); s; s = s.parent) {
72
+ if (s === scope) return true;
73
+ }
74
+ return false;
42
75
  }
43
76
 
44
77
  // Wire the ambient ctx-method guards (assertNotInsideCacheExec in taint.ts)
@@ -20,12 +20,7 @@ import type { SearchParamsFilter } from "./search-params-filter.js";
20
20
  * slot; an allowlist keeps the router's own params out of the key while leaving
21
21
  * consumer `__` params intact.
22
22
  */
23
- const RESERVED_SEARCH_PARAMS = new Set([
24
- "__no_cache",
25
- "__rsc",
26
- "__html",
27
- "__prerender_collect",
28
- ]);
23
+ const RESERVED_SEARCH_PARAMS = new Set(["__no_cache", "__rsc", "__html"]);
29
24
 
30
25
  function isReservedSearchParam(key: string): boolean {
31
26
  return key.startsWith("_rsc") || RESERVED_SEARCH_PARAMS.has(key);
@@ -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.