@rangojs/router 0.16.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 (309) hide show
  1. package/README.md +255 -89
  2. package/dist/testing/vitest.js +252 -0
  3. package/dist/types/browser/cookie-name.d.ts +6 -0
  4. package/dist/types/browser/history-state.d.ts +16 -0
  5. package/dist/types/browser/navigation-transaction.d.ts +6 -0
  6. package/dist/types/browser/partial-update.d.ts +11 -0
  7. package/dist/types/browser/react/ScrollRestoration.d.ts +6 -6
  8. package/dist/types/browser/rsc-router.d.ts +1 -1
  9. package/dist/types/browser/scroll-restoration.d.ts +7 -0
  10. package/dist/types/cache/cache-exec-scope.d.ts +18 -1
  11. package/dist/types/cache/cache-key-utils.d.ts +17 -2
  12. package/dist/types/cache/cache-policy.d.ts +17 -0
  13. package/dist/types/cache/cache-scope.d.ts +132 -19
  14. package/dist/types/cache/cache-tag.d.ts +92 -7
  15. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  16. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  17. package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
  18. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  19. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  20. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
  21. package/dist/types/cache/handle-capture.d.ts +41 -3
  22. package/dist/types/cache/handle-snapshot.d.ts +54 -13
  23. package/dist/types/cache/index.d.ts +1 -0
  24. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  25. package/dist/types/cache/segment-codec.d.ts +10 -3
  26. package/dist/types/cache/shell-memo.d.ts +178 -0
  27. package/dist/types/cache/shell-snapshot.d.ts +110 -21
  28. package/dist/types/cache/tag-invalidation.d.ts +30 -3
  29. package/dist/types/cache/taint.d.ts +1 -1
  30. package/dist/types/cache/types.d.ts +220 -44
  31. package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
  32. package/dist/types/client.d.ts +4 -4
  33. package/dist/types/client.rsc.d.ts +4 -5
  34. package/dist/types/components/DefaultDocument.d.ts +5 -2
  35. package/dist/types/context-var.d.ts +6 -5
  36. package/dist/types/escape-script.d.ts +1 -1
  37. package/dist/types/handles/MetaTags.d.ts +5 -4
  38. package/dist/types/handles/Scripts.d.ts +8 -7
  39. package/dist/types/handles/meta.d.ts +2 -2
  40. package/dist/types/handles/script.d.ts +17 -16
  41. package/dist/types/href-client.d.ts +1 -1
  42. package/dist/types/html.d.ts +12 -0
  43. package/dist/types/index.d.ts +1 -1
  44. package/dist/types/index.rsc.d.ts +3 -2
  45. package/dist/types/loader-store.d.ts +7 -4
  46. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  47. package/dist/types/reverse.d.ts +36 -4
  48. package/dist/types/route-definition/dsl-helpers.d.ts +3 -3
  49. package/dist/types/route-definition/helpers-types.d.ts +33 -9
  50. package/dist/types/route-definition/resolve-handler-use.d.ts +23 -1
  51. package/dist/types/route-types.d.ts +1 -1
  52. package/dist/types/router/debug-manifest.d.ts +3 -2
  53. package/dist/types/router/error-handling.d.ts +20 -0
  54. package/dist/types/router/handler-context.d.ts +6 -0
  55. package/dist/types/router/instrument.d.ts +8 -0
  56. package/dist/types/router/match-middleware/background-revalidation.d.ts +46 -5
  57. package/dist/types/router/middleware-types.d.ts +9 -3
  58. package/dist/types/router/router-context.d.ts +3 -1
  59. package/dist/types/router/router-interfaces.d.ts +24 -2
  60. package/dist/types/router/router-options.d.ts +8 -5
  61. package/dist/types/router/segment-resolution/fresh.d.ts +10 -0
  62. package/dist/types/router/segment-resolution/loader-cache.d.ts +66 -1
  63. package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -19
  64. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +23 -12
  65. package/dist/types/router/segment-resolution/mask-nested.d.ts +67 -26
  66. package/dist/types/router/segment-resolution/revalidation.d.ts +6 -2
  67. package/dist/types/router/segment-wrappers.d.ts +1 -1
  68. package/dist/types/rsc/full-payload.d.ts +14 -0
  69. package/dist/types/rsc/handler.d.ts +1 -1
  70. package/dist/types/rsc/response-route-handler.d.ts +1 -1
  71. package/dist/types/rsc/rsc-rendering.d.ts +1 -2
  72. package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
  73. package/dist/types/rsc/shell-capture-constants.d.ts +61 -6
  74. package/dist/types/rsc/shell-capture.d.ts +151 -19
  75. package/dist/types/rsc/shell-serve.d.ts +140 -17
  76. package/dist/types/rsc/ssr-setup.d.ts +6 -6
  77. package/dist/types/rsc/types.d.ts +17 -0
  78. package/dist/types/server/context.d.ts +139 -15
  79. package/dist/types/server/cookie-store.d.ts +21 -0
  80. package/dist/types/server/handle-store.d.ts +26 -2
  81. package/dist/types/server/request-context.d.ts +182 -53
  82. package/dist/types/ssr/index.d.ts +24 -0
  83. package/dist/types/ssr/ssr-root.d.ts +9 -8
  84. package/dist/types/static-handler.d.ts +4 -1
  85. package/dist/types/testing/flight.entry.d.ts +7 -0
  86. package/dist/types/testing/generated-routes.d.ts +2 -2
  87. package/dist/types/testing/internal/context.d.ts +1 -1
  88. package/dist/types/testing/render-route.d.ts +32 -6
  89. package/dist/types/testing/run-loader.d.ts +18 -3
  90. package/dist/types/testing/serve-shell-request.d.ts +132 -0
  91. package/dist/types/testing/shell-status.d.ts +23 -10
  92. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +28 -5
  93. package/dist/types/testing/vitest.d.ts +35 -1
  94. package/dist/types/theme/ThemeScript.d.ts +3 -3
  95. package/dist/types/types/cache-types.d.ts +14 -10
  96. package/dist/types/types/handler-context.d.ts +28 -1
  97. package/dist/types/types/loader-types.d.ts +8 -6
  98. package/dist/types/urls/pattern-types.d.ts +22 -18
  99. package/dist/types/urls/response-types.d.ts +3 -12
  100. package/dist/types/use-loader.d.ts +12 -3
  101. package/dist/types/vite/plugins/use-cache-transform.d.ts +33 -2
  102. package/dist/types/vite/rango.d.ts +1 -1
  103. package/dist/types/vite/router-discovery.d.ts +29 -2
  104. package/dist/vite/index.js +1420 -1348
  105. package/package.json +2 -2
  106. package/skills/api-client/SKILL.md +22 -10
  107. package/skills/breadcrumbs/SKILL.md +75 -22
  108. package/skills/bundle-analysis/SKILL.md +22 -22
  109. package/skills/cache-guide/SKILL.md +306 -124
  110. package/skills/caching/SKILL.md +530 -98
  111. package/skills/catalog.json +16 -16
  112. package/skills/client-urls/SKILL.md +65 -38
  113. package/skills/cloudflare/SKILL.md +87 -20
  114. package/skills/cloudflare/references/d1-and-local-dev.md +15 -2
  115. package/skills/cloudflare/references/streaming-and-deploy.md +10 -3
  116. package/skills/cloudflare/references/webhooks-and-crypto.md +9 -2
  117. package/skills/comparison/SKILL.md +5 -0
  118. package/skills/comparison/references/framework-comparison.md +75 -57
  119. package/skills/composability/SKILL.md +38 -29
  120. package/skills/css/SKILL.md +10 -13
  121. package/skills/debug-manifest/SKILL.md +74 -31
  122. package/skills/defer-hydration/SKILL.md +52 -31
  123. package/skills/deployment-caching/SKILL.md +11 -3
  124. package/skills/document-cache/SKILL.md +98 -28
  125. package/skills/fonts/SKILL.md +16 -8
  126. package/skills/handler-use/SKILL.md +55 -17
  127. package/skills/hooks/SKILL.md +28 -20
  128. package/skills/hooks/data.md +23 -3
  129. package/skills/hooks/handle-and-actions.md +34 -17
  130. package/skills/hooks/navigation.md +39 -10
  131. package/skills/hooks/outlets.md +13 -6
  132. package/skills/hooks/state.md +39 -15
  133. package/skills/hooks/urls.md +7 -5
  134. package/skills/host-router/SKILL.md +64 -12
  135. package/skills/i18n/SKILL.md +60 -34
  136. package/skills/intercept/SKILL.md +161 -26
  137. package/skills/layout/SKILL.md +106 -50
  138. package/skills/links/SKILL.md +51 -19
  139. package/skills/loader/SKILL.md +386 -128
  140. package/skills/middleware/SKILL.md +129 -26
  141. package/skills/migrate-nextjs/SKILL.md +156 -70
  142. package/skills/migrate-nextjs/backend-host-swap.md +5 -2
  143. package/skills/migrate-react-router/SKILL.md +51 -39
  144. package/skills/migrate-react-router/component-migration.md +28 -7
  145. package/skills/migrate-react-router/data-and-actions.md +21 -6
  146. package/skills/migrate-react-router/route-mapping.md +19 -10
  147. package/skills/mime-routes/SKILL.md +12 -7
  148. package/skills/observability/SKILL.md +92 -14
  149. package/skills/parallel/SKILL.md +88 -47
  150. package/skills/ppr/SKILL.md +866 -379
  151. package/skills/prerender/SKILL.md +210 -108
  152. package/skills/rango/SKILL.md +115 -65
  153. package/skills/react-compiler/SKILL.md +6 -5
  154. package/skills/response-routes/SKILL.md +133 -25
  155. package/skills/route/SKILL.md +170 -60
  156. package/skills/router-setup/SKILL.md +420 -240
  157. package/skills/scripts/SKILL.md +39 -21
  158. package/skills/server-actions/SKILL.md +231 -59
  159. package/skills/shell-manifest/SKILL.md +28 -14
  160. package/skills/streams-and-websockets/SKILL.md +32 -14
  161. package/skills/tailwind/SKILL.md +37 -33
  162. package/skills/testing/SKILL.md +73 -33
  163. package/skills/testing/cache-prerender.md +60 -33
  164. package/skills/testing/client-components.md +59 -6
  165. package/skills/testing/e2e-parity.md +12 -2
  166. package/skills/testing/flight.md +19 -11
  167. package/skills/testing/handles.md +8 -5
  168. package/skills/testing/loader.md +26 -6
  169. package/skills/testing/middleware.md +8 -3
  170. package/skills/testing/predicates.md +120 -0
  171. package/skills/testing/render-handler.md +70 -22
  172. package/skills/testing/response-routes.md +11 -4
  173. package/skills/testing/reverse-and-types.md +20 -14
  174. package/skills/testing/serve-shell-request.md +188 -0
  175. package/skills/testing/server-actions.md +12 -3
  176. package/skills/testing/server-tree.md +9 -0
  177. package/skills/testing/setup.md +9 -5
  178. package/skills/theme/SKILL.md +155 -30
  179. package/skills/typesafety/SKILL.md +20 -9
  180. package/skills/typesafety/env-and-bindings.md +137 -50
  181. package/skills/typesafety/generated-files-and-cli.md +26 -14
  182. package/skills/typesafety/params-and-search.md +44 -19
  183. package/skills/typesafety/route-types.md +50 -14
  184. package/skills/use-cache/SKILL.md +224 -83
  185. package/skills/vercel/SKILL.md +35 -13
  186. package/skills/view-transitions/SKILL.md +42 -21
  187. package/src/__augment-tests__/augmented.check.ts +26 -1
  188. package/src/browser/cookie-name.ts +23 -0
  189. package/src/browser/history-state.ts +23 -0
  190. package/src/browser/index.ts +2 -2
  191. package/src/browser/navigation-bridge.ts +3 -4
  192. package/src/browser/navigation-transaction.ts +22 -2
  193. package/src/browser/partial-update.ts +35 -25
  194. package/src/browser/react/ScrollRestoration.tsx +6 -6
  195. package/src/browser/rsc-router.tsx +6 -1
  196. package/src/browser/scroll-restoration.ts +53 -6
  197. package/src/cache/cache-exec-scope.ts +37 -4
  198. package/src/cache/cache-key-utils.ts +23 -8
  199. package/src/cache/cache-policy.ts +22 -0
  200. package/src/cache/cache-runtime.ts +264 -68
  201. package/src/cache/cache-scope.ts +419 -43
  202. package/src/cache/cache-tag.ts +375 -11
  203. package/src/cache/cf/cf-base64.ts +36 -4
  204. package/src/cache/cf/cf-cache-constants.ts +12 -0
  205. package/src/cache/cf/cf-cache-store.ts +990 -348
  206. package/src/cache/cf/cf-cache-types.ts +32 -4
  207. package/src/cache/cf/cf-shell-frame.ts +258 -0
  208. package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
  209. package/src/cache/document-cache.ts +15 -0
  210. package/src/cache/handle-capture.ts +90 -10
  211. package/src/cache/handle-snapshot.ts +133 -28
  212. package/src/cache/index.ts +2 -0
  213. package/src/cache/isolate-tag-memo.ts +308 -0
  214. package/src/cache/segment-codec.ts +26 -7
  215. package/src/cache/shell-memo.ts +341 -0
  216. package/src/cache/shell-snapshot.ts +217 -27
  217. package/src/cache/tag-invalidation.ts +108 -22
  218. package/src/cache/taint.ts +1 -1
  219. package/src/cache/types.ts +225 -44
  220. package/src/cache/vercel/vercel-cache-store.ts +545 -61
  221. package/src/client.rsc.tsx +10 -9
  222. package/src/client.tsx +5 -4
  223. package/src/components/DefaultDocument.tsx +7 -2
  224. package/src/context-var.ts +6 -5
  225. package/src/escape-script.ts +1 -1
  226. package/src/handles/MetaTags.tsx +13 -11
  227. package/src/handles/Scripts.tsx +11 -10
  228. package/src/handles/meta.ts +2 -2
  229. package/src/handles/script.ts +17 -16
  230. package/src/href-client.ts +1 -1
  231. package/src/html.ts +12 -0
  232. package/src/index.rsc.ts +5 -0
  233. package/src/index.ts +1 -0
  234. package/src/loader-store.ts +7 -4
  235. package/src/prerender/build-shell-capture.ts +73 -22
  236. package/src/reverse.ts +44 -4
  237. package/src/route-definition/dsl-helpers.ts +121 -33
  238. package/src/route-definition/helpers-types.ts +33 -9
  239. package/src/route-definition/resolve-handler-use.ts +121 -6
  240. package/src/route-types.ts +0 -3
  241. package/src/router/debug-manifest.ts +10 -2
  242. package/src/router/error-handling.ts +51 -15
  243. package/src/router/handler-context.ts +71 -28
  244. package/src/router/instrument.ts +54 -2
  245. package/src/router/intercept-resolution.ts +141 -43
  246. package/src/router/loader-resolution.ts +115 -39
  247. package/src/router/match-api.ts +40 -26
  248. package/src/router/match-handlers.ts +5 -0
  249. package/src/router/match-middleware/background-revalidation.ts +161 -96
  250. package/src/router/match-middleware/cache-lookup.ts +110 -10
  251. package/src/router/match-middleware/cache-store.ts +48 -101
  252. package/src/router/middleware-types.ts +9 -6
  253. package/src/router/middleware.ts +75 -13
  254. package/src/router/prerender-match.ts +59 -19
  255. package/src/router/route-snapshot.ts +11 -1
  256. package/src/router/router-context.ts +2 -0
  257. package/src/router/router-interfaces.ts +25 -2
  258. package/src/router/router-options.ts +8 -5
  259. package/src/router/segment-resolution/fresh.ts +62 -21
  260. package/src/router/segment-resolution/helpers.ts +4 -2
  261. package/src/router/segment-resolution/loader-cache.ts +407 -76
  262. package/src/router/segment-resolution/loader-mask.ts +12 -22
  263. package/src/router/segment-resolution/loader-snapshot.ts +208 -56
  264. package/src/router/segment-resolution/mask-nested.ts +138 -44
  265. package/src/router/segment-resolution/revalidation.ts +61 -16
  266. package/src/router/segment-wrappers.ts +4 -0
  267. package/src/rsc/full-payload.ts +25 -1
  268. package/src/rsc/handler.ts +14 -2
  269. package/src/rsc/progressive-enhancement.ts +3 -2
  270. package/src/rsc/render-pipeline.ts +3 -0
  271. package/src/rsc/response-cache-serve.ts +9 -6
  272. package/src/rsc/response-route-handler.ts +11 -3
  273. package/src/rsc/rsc-rendering.ts +608 -225
  274. package/src/rsc/shell-build-manifest.ts +27 -9
  275. package/src/rsc/shell-capture-constants.ts +73 -6
  276. package/src/rsc/shell-capture.ts +1068 -296
  277. package/src/rsc/shell-serve.ts +316 -34
  278. package/src/rsc/ssr-setup.ts +14 -8
  279. package/src/rsc/types.ts +18 -0
  280. package/src/server/context.ts +300 -32
  281. package/src/server/cookie-store.ts +87 -61
  282. package/src/server/handle-store.ts +232 -7
  283. package/src/server/request-context.ts +297 -67
  284. package/src/ssr/index.tsx +136 -44
  285. package/src/ssr/ssr-root.tsx +9 -8
  286. package/src/static-handler.ts +4 -1
  287. package/src/testing/flight-runtime.d.ts +21 -0
  288. package/src/testing/flight.entry.ts +14 -0
  289. package/src/testing/generated-routes.ts +2 -2
  290. package/src/testing/internal/context.ts +1 -1
  291. package/src/testing/internal/flight-client-globals.ts +7 -5
  292. package/src/testing/render-route.tsx +85 -24
  293. package/src/testing/run-loader.ts +68 -6
  294. package/src/testing/serve-shell-request.ts +378 -0
  295. package/src/testing/shell-status.ts +34 -12
  296. package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
  297. package/src/testing/vitest.ts +62 -1
  298. package/src/theme/ThemeProvider.tsx +7 -6
  299. package/src/theme/ThemeScript.tsx +3 -3
  300. package/src/types/cache-types.ts +14 -10
  301. package/src/types/handler-context.ts +28 -1
  302. package/src/types/loader-types.ts +8 -6
  303. package/src/urls/pattern-types.ts +22 -18
  304. package/src/urls/response-types.ts +3 -23
  305. package/src/use-loader.tsx +12 -3
  306. package/src/vite/discovery/shell-prerender-phase.ts +9 -0
  307. package/src/vite/plugins/use-cache-transform.ts +118 -74
  308. package/src/vite/rango.ts +7 -3
  309. package/src/vite/router-discovery.ts +14 -4
@@ -6,10 +6,82 @@
6
6
  * pulling in @vitejs/plugin-rsc/rsc dependencies.
7
7
  */
8
8
 
9
+ import { getCurrentLoaderBodyId } from "../server/context.js";
9
10
  import type { HandleStore } from "../server/handle-store.js";
10
11
  import type { SegmentHandleData } from "./types.js";
11
12
 
12
- export interface HandleCapture {
13
+ /**
14
+ * Scoping for one capture of a cached unit (a loader's own cache(),
15
+ * loader-cache.ts; a "use cache" function, cache-runtime.ts) that runs
16
+ * concurrently with live pushes into the same store.
17
+ */
18
+ export interface HandleCaptureOptions {
19
+ /** Push-time predicate; a rejected push is not recorded by this capture. */
20
+ accept?: () => boolean;
21
+ /**
22
+ * Recorded pushes do not reach the store. A stale hit's background
23
+ * revalidation: the foreground already replayed the entry's pushes, so the
24
+ * fresh body's pushes belong only to the refreshed entry.
25
+ */
26
+ divert?: boolean;
27
+ /**
28
+ * Record key for an accepted push, in place of its segment id. A loader's
29
+ * own cache() (loader-cache.ts recordOwnerKey) and "use cache"
30
+ * (useCacheRecordKey) group pushes by the loader body that made them.
31
+ */
32
+ key?: () => string;
33
+ }
34
+
35
+ // The loader whose recorded push a "use cache" HIT is replaying
36
+ // (replayLoaderPush), for an enclosing "use cache" MISS's record key.
37
+ let replayingOwner: string | undefined;
38
+
39
+ /**
40
+ * HandleStore.pushReplayed, attributed to `loaderId` for the record key of an
41
+ * enclosing "use cache" MISS: an outer cached function records an inner HIT's
42
+ * replayed loader pushes under that loader, not as its own.
43
+ */
44
+ export function replayLoaderPush(
45
+ handleStore: HandleStore,
46
+ handleName: string,
47
+ segmentId: string,
48
+ value: unknown,
49
+ loaderId: string,
50
+ ): void {
51
+ const prev = replayingOwner;
52
+ replayingOwner = loaderId;
53
+ try {
54
+ handleStore.pushReplayed(handleName, segmentId, value, loaderId);
55
+ } finally {
56
+ replayingOwner = prev;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Record key for a "use cache" capture: run-length groups `${seq}:${owner}`,
62
+ * so the HIT (handle-snapshot.ts appendHandles) delivers each loader's pushes
63
+ * at most once per request. The owner is the loader whose body, entered
64
+ * inside this execution via ctx.use, made the push; empty for the function's
65
+ * own pushes and those of cached functions it calls. Groups keep push order
66
+ * across owners. Create at call time: the caller's loader body is the
67
+ * function's own scope.
68
+ */
69
+ export function useCacheRecordKey(): () => string {
70
+ const callerBody = getCurrentLoaderBodyId();
71
+ let seq = 0;
72
+ let last: string | undefined;
73
+ return () => {
74
+ const body = replayingOwner ?? getCurrentLoaderBodyId();
75
+ const owner = body === callerBody ? "" : (body ?? "");
76
+ if (owner !== last) {
77
+ last = owner;
78
+ seq++;
79
+ }
80
+ return `${seq}:${owner}`;
81
+ };
82
+ }
83
+
84
+ export interface HandleCapture extends HandleCaptureOptions {
13
85
  data: Record<string, SegmentHandleData>;
14
86
  }
15
87
 
@@ -42,33 +114,41 @@ function ensureInterceptorInstalled(handleStore: HandleStore): void {
42
114
  segmentId: string,
43
115
  value: unknown,
44
116
  ) => {
117
+ let diverted = false;
45
118
  for (const capture of captures) {
46
- if (!capture.data[segmentId]) {
47
- capture.data[segmentId] = {};
119
+ if (capture.accept && !capture.accept()) continue;
120
+ if (capture.divert) diverted = true;
121
+ const key = capture.key ? capture.key() : segmentId;
122
+ if (!capture.data[key]) {
123
+ capture.data[key] = {};
48
124
  }
49
- if (!capture.data[segmentId][handleName]) {
50
- capture.data[segmentId][handleName] = [];
125
+ if (!capture.data[key][handleName]) {
126
+ capture.data[key][handleName] = [];
51
127
  }
52
- capture.data[segmentId][handleName].push(value);
128
+ capture.data[key][handleName].push(value);
53
129
  }
54
- originalPush(handleName, segmentId, value);
130
+ if (!diverted) originalPush(handleName, segmentId, value);
55
131
  };
56
132
  }
57
133
 
58
134
  /**
59
- * Start capturing handle pushes for a cached function execution.
135
+ * Start capturing handle pushes for a cached function or cached loader
136
+ * execution (`options`: see HandleCaptureOptions).
60
137
  *
61
138
  * Concurrency-safe: multiple overlapping captures on the same
62
139
  * HandleStore are independent. Each capture registers a token in a
63
140
  * Set; stopping removes it. No ordering requirement (LIFO not needed).
64
141
  */
65
- export function startHandleCapture(handleStore: HandleStore): {
142
+ export function startHandleCapture(
143
+ handleStore: HandleStore,
144
+ options?: HandleCaptureOptions,
145
+ ): {
66
146
  capture: HandleCapture;
67
147
  stop: () => void;
68
148
  } {
69
149
  ensureInterceptorInstalled(handleStore);
70
150
 
71
- const capture: HandleCapture = { data: {} };
151
+ const capture: HandleCapture = { data: {}, ...options };
72
152
  const captures = activeCapturesMap.get(handleStore)!;
73
153
  captures.add(capture);
74
154
 
@@ -6,9 +6,10 @@
6
6
  * during segment resolution and stored alongside cached segments.
7
7
  */
8
8
 
9
+ import { replayLoaderPush } from "./handle-capture.js";
9
10
  import type { ResolvedSegment } from "../types.js";
10
11
  import type { HandleStore } from "../server/handle-store.js";
11
- import type { SegmentHandleData } from "./types.js";
12
+ import type { HandleOwners, SegmentHandleData } from "./types.js";
12
13
  // segment-codec eagerly pulls @vitejs/plugin-rsc (a virtual: module unresolvable
13
14
  // in plain node/vitest). It is imported LAZILY inside the two async encode/decode
14
15
  // helpers below so that modules which import handle-snapshot only for the
@@ -47,19 +48,32 @@ function withTimeout<T>(p: Promise<T>, ms: number, onTimeout: T): Promise<T> {
47
48
  ]);
48
49
  }
49
50
 
50
- export async function encodeHandles(handles: HandleRecord): Promise<string> {
51
+ /**
52
+ * `onError` reaches the Flight encode (see serializeResult). A cache writer
53
+ * passes the collector it gives the value's encode, so a handle value that
54
+ * fails to encode (a rejected pushed promise) refuses the whole entry like a
55
+ * failed value does; prerender (prerender-match.ts) passes one too and
56
+ * hands the error to its prerender.onError policy.
57
+ */
58
+ export async function encodeHandles(
59
+ handles: HandleRecord,
60
+ onError?: (error: unknown) => void,
61
+ ): Promise<string> {
51
62
  if (!hasHandleData(handles)) return "";
52
- return encodeHandleValue(handles);
63
+ return encodeHandleValue(handles, onError);
53
64
  }
54
65
 
55
66
  export function decodeHandles(encoded: string): Promise<HandleRecord | null> {
56
67
  return decodeHandleValue<HandleRecord>(encoded);
57
68
  }
58
69
 
59
- export async function encodeHandleValue(value: unknown): Promise<string> {
70
+ export async function encodeHandleValue(
71
+ value: unknown,
72
+ onError?: (error: unknown) => void,
73
+ ): Promise<string> {
60
74
  const { serializeResult } = await import("./segment-codec.js");
61
75
  const encoded = await withTimeout(
62
- serializeResult(value),
76
+ serializeResult(value, onError),
63
77
  HANDLE_ENCODE_TIMEOUT_MS,
64
78
  null,
65
79
  );
@@ -84,49 +98,140 @@ export async function decodeHandleValue<T>(encoded: string): Promise<T | null> {
84
98
  * Capture handle data for a set of segments from the handle store.
85
99
  * Used when caching segments to preserve their handle data.
86
100
  *
87
- * `exclude` (shell captures: RequestContext._shellCaptureLoaderHandleValues)
88
- * drops DSL-loader-scoped push values from the CACHE WRITE only: loaders
89
- * re-run fresh on every HIT, so replaying their captured values would
90
- * duplicate the fresh push — and their masked nested promises would stall the
91
- * Flight handle encode to its timeout. Threaded as an explicit argument so
92
- * every other getDataForSegment consumer (the render-barrier snapshot,
93
- * prerender) provably sees every push.
101
+ * DSL-loader pushes stay out of the CACHE WRITE: a HIT runs loaders exactly
102
+ * as an uncached render of the same request would (cache-lookup.ts
103
+ * resolveFreshLoadersAndYield), so replaying a recorded copy would duplicate
104
+ * the live push. Handler pushes, including
105
+ * handler-invoked ctx.use(Loader) bodies (skipped with their handler on a
106
+ * HIT), are kept. The store tags loader pushes by array position at push
107
+ * time, so primitive values are covered. A PPR shell capture untags the
108
+ * settled bake-lane pushes its record keeps (shell-capture.ts) and gives
109
+ * them an owner (captureHandleOwners). Only this call site passes
110
+ * excludeLoaderPushes; every other getDataForSegment consumer (the
111
+ * render-barrier snapshot, prerender) sees every push.
94
112
  */
95
113
  export function captureHandles(
96
114
  segments: ResolvedSegment[],
97
115
  handleStore: HandleStore,
98
- exclude?: WeakSet<object>,
99
116
  ): Record<string, SegmentHandleData> {
100
117
  const handles: Record<string, SegmentHandleData> = {};
101
118
  for (const seg of segments) {
102
- const data = handleStore.getDataForSegment(seg.id);
103
- if (!exclude) {
104
- handles[seg.id] = data;
105
- continue;
106
- }
107
- const filtered: SegmentHandleData = {};
108
- for (const [handleName, values] of Object.entries(data)) {
109
- const kept = values.filter(
110
- (v) => typeof v !== "object" || v === null || !exclude.has(v),
111
- );
112
- if (kept.length > 0) filtered[handleName] = kept;
113
- }
114
- handles[seg.id] = filtered;
119
+ handles[seg.id] = handleStore.getDataForSegment(seg.id, true);
115
120
  }
116
121
  return handles;
117
122
  }
118
123
 
124
+ /** The owners of captureHandles' values; undefined when none is owned. */
125
+ export function captureHandleOwners(
126
+ segments: ResolvedSegment[],
127
+ handleStore: HandleStore,
128
+ ): HandleOwners | undefined {
129
+ let owners: HandleOwners | undefined;
130
+ for (const seg of segments) {
131
+ const segOwners = handleStore.getRecordOwners(seg.id);
132
+ if (segOwners) (owners ??= {})[seg.id] = segOwners;
133
+ }
134
+ return owners;
135
+ }
136
+
119
137
  /**
120
138
  * Restore handle data from a cached snapshot into the handle store.
121
- * Used when serving cached segments to replay their handle data.
139
+ * Used when serving cached segments to replay their handle data: a route
140
+ * cache() record owns its segments' arrays, so it REPLACES them.
141
+ *
142
+ * A segment with `owners` is replaced with empty arrays and re-pushed in
143
+ * recorded order, an owned value through pushReplayed: if the owning loader
144
+ * runs on the HIT (a live loader, or a promise-carrying `ssr: false` one;
145
+ * a promise-free one is served from the shell and does not), its live pushes
146
+ * replace the recorded ones in place instead of appending a second copy. `claim`
147
+ * (setupLoaderAccess _claimLoaderPushes) is asked once per owner, so the
148
+ * loader's own cache() HIT does not replay its pushes a second time
149
+ * (loader-cache.ts replayLoaderHandles); an owner it refuses is skipped. A
150
+ * record without owners restores as a plain replay.
122
151
  */
123
152
  export function restoreHandles(
124
153
  handles: Record<string, SegmentHandleData>,
125
154
  handleStore: HandleStore,
155
+ owners?: HandleOwners,
156
+ claim?: (loaderId: string) => boolean,
126
157
  ): void {
158
+ let delivers: Map<string, boolean> | undefined;
127
159
  for (const [segId, segHandles] of Object.entries(handles)) {
128
- if (Object.keys(segHandles).length > 0) {
160
+ if (Object.keys(segHandles).length === 0) continue;
161
+ const segOwners = owners?.[segId];
162
+ if (!segOwners) {
129
163
  handleStore.replaySegmentData(segId, segHandles);
164
+ continue;
165
+ }
166
+ const cleared: SegmentHandleData = {};
167
+ for (const handleName in segHandles) cleared[handleName] = [];
168
+ handleStore.replaySegmentData(segId, cleared);
169
+ for (const [handleName, values] of Object.entries(segHandles)) {
170
+ const valueOwners = segOwners[handleName];
171
+ for (let i = 0; i < values.length; i++) {
172
+ const owner = valueOwners?.[i];
173
+ if (!owner) {
174
+ handleStore.push(handleName, segId, values[i]);
175
+ continue;
176
+ }
177
+ delivers ??= new Map();
178
+ let deliver = delivers.get(owner);
179
+ if (deliver === undefined) {
180
+ deliver = claim ? claim(owner) : true;
181
+ delivers.set(owner, deliver);
182
+ }
183
+ if (deliver) {
184
+ handleStore.pushReplayed(handleName, segId, values[i], owner);
185
+ }
186
+ }
187
+ }
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Append recorded handle pushes to the store, in recorded order, via push()
193
+ * so active captures and loader-scope tagging see them like live pushes. For
194
+ * a cached unit that shares its segments with live pushes ("use cache");
195
+ * restoreHandles would wipe those. `segmentId` redirects every value to one
196
+ * segment (the caller's).
197
+ *
198
+ * Owner-keyed groups (useCacheRecordKey, `${seq}:${loaderId}`) are a loader's
199
+ * pushes, which reach the page once per request: `claim` (setupLoaderAccess
200
+ * _claimLoaderPushes) skips a loader that already ran or was replayed in this
201
+ * request, and a claimed loader that runs later replaces its replayed values
202
+ * (HandleStore.pushReplayed). `${seq}:` groups are the function's own pushes.
203
+ * A key without ":" is a segment id (records written before owner keys) and
204
+ * replays unchanged. A loader's own cache() replays through loader-cache.ts
205
+ * replayLoaderHandles.
206
+ */
207
+ export function appendHandles(
208
+ handles: Record<string, SegmentHandleData>,
209
+ handleStore: HandleStore,
210
+ segmentId?: string,
211
+ claim?: (loaderId: string) => boolean,
212
+ ): void {
213
+ let claims: Map<string, boolean> | undefined;
214
+ for (const [key, segHandles] of Object.entries(handles)) {
215
+ const colon = key.indexOf(":");
216
+ const owner = colon < 0 ? "" : key.slice(colon + 1);
217
+ if (owner) {
218
+ claims ??= new Map();
219
+ let deliver = claims.get(owner);
220
+ if (deliver === undefined) {
221
+ deliver = claim ? claim(owner) : true;
222
+ claims.set(owner, deliver);
223
+ }
224
+ if (!deliver) continue;
225
+ }
226
+ const target = segmentId ?? key;
227
+ for (const [handleName, values] of Object.entries(segHandles)) {
228
+ for (const value of values) {
229
+ if (owner) {
230
+ replayLoaderPush(handleStore, handleName, target, value, owner);
231
+ } else {
232
+ handleStore.push(handleName, target, value);
233
+ }
234
+ }
130
235
  }
131
236
  }
132
237
  }
@@ -13,6 +13,8 @@ export { CACHE_READ_ERROR } from "./types.js";
13
13
 
14
14
  export { MemorySegmentCacheStore } from "./memory-segment-store.js";
15
15
 
16
+ export type { StoreMemoOptions } from "./shell-memo.js";
17
+
16
18
  export {
17
19
  CFCacheStore,
18
20
  type CFCacheStoreOptions,
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Per-isolate tag-marker memos shared by the built-in stores (issue #941;
3
+ * docs/design/shell-entry-layout.md "The tag-marker memo").
4
+ *
5
+ * A tagged shell HIT waits for its tag-invalidation markers before the first
6
+ * byte. Two things here shorten that wait:
7
+ *
8
+ * - {@link TagNameHints}: which tag NAMES a shell key carried last time, so a
9
+ * HIT can start the marker reads alongside the entry read instead of after
10
+ * the entry's head names its tags. Never marker values.
11
+ * - {@link TagMarkerMemo}: the marker VALUES (a tag's latest invalidatedAt,
12
+ * or null), served stale-while-revalidate. A value younger than
13
+ * `markerFreshMs` is used as is; one younger than `markerMaxStaleMs` is used
14
+ * and refreshed from the store in the background; an older one blocks on
15
+ * the store read as before.
16
+ *
17
+ * Simple reads get SWR; a mutation gets correct reads. The request that runs
18
+ * updateTag()/revalidateTag() reads its own writes through the per-request
19
+ * memo; the isolate that runs it writes the new value through into this memo;
20
+ * and its response sets the fresh-reads cookie (request-context.ts), so the
21
+ * same user's next requests on OTHER isolates skip this memo and the shell
22
+ * memo for as long as either can be stale. Other users can see a marker up to
23
+ * `markerMaxStaleMs` old, on top of the platform's own consistency
24
+ * (Cloudflare KV is eventually consistent across locations, up to about 60 s;
25
+ * Vercel's `tm` markers are regional).
26
+ *
27
+ * Only PPR shell reads use the value memo. Cached data families (`cache()`,
28
+ * `"use cache"`, responses) and the shell write gate
29
+ * (`isTagsInvalidatedSince`) keep reading their markers, in every request,
30
+ * whatever its shell read found: a shell read's memoized values never reach
31
+ * the data families' per-request memo (cf-tag-marker-memo.ts
32
+ * ShellMarkerReads).
33
+ */
34
+
35
+ import type { RequestContext } from "../server/request-context.js";
36
+ import type { ShellReadStats } from "./types.js";
37
+
38
+ /** Default freshness of a memoized marker on CFCacheStore (ms). */
39
+ export const DEFAULT_CF_MARKER_FRESH_MS = 1000;
40
+ /** Default freshness of a memoized marker on VercelCacheStore (ms). */
41
+ export const DEFAULT_VERCEL_MARKER_FRESH_MS = 300;
42
+ /** Default oldest memoized marker served while it refreshes (ms), CF. */
43
+ export const DEFAULT_CF_MARKER_MAX_STALE_MS = 10_000;
44
+ /**
45
+ * The same cap on VercelCacheStore: the runtime cache expires tags globally
46
+ * within about 300 ms, so a much longer stale window would widen that for
47
+ * other users more than KV's own up-to-60 s cross-location lag does on CF.
48
+ */
49
+ export const DEFAULT_VERCEL_MARKER_MAX_STALE_MS = 2000;
50
+ /** Most tags a marker memo holds (least recently used evicted). */
51
+ const MARKER_MEMO_MAX_ENTRIES = 4096;
52
+ /**
53
+ * Most shell keys a hint map holds (least recently used evicted).
54
+ * @internal Exported for tests that fill the map to its cap.
55
+ */
56
+ export const TAG_HINTS_MAX_ENTRIES = 2048;
57
+
58
+ /** How a marker read was answered, for the debugPerformance marker row. */
59
+ export type MarkerMemoOutcome = "fresh" | "stale" | "read" | "bypass";
60
+
61
+ interface MarkerSlot {
62
+ value: number | null;
63
+ readAt: number;
64
+ }
65
+
66
+ /** A memoized marker a read may use: its value, and whether it is stale. */
67
+ export interface MarkerMemoHit {
68
+ value: number | null;
69
+ stale: boolean;
70
+ }
71
+
72
+ /**
73
+ * A store read of a marker for {@link TagMarkerMemo.readThrough}: the value,
74
+ * and whether it may be memoized (a timed-out read that failed open may not).
75
+ */
76
+ export interface MarkerFetch {
77
+ value: number | null;
78
+ memoize: boolean;
79
+ }
80
+
81
+ /** Options of {@link TagMarkerMemo.readThrough}. */
82
+ export interface MarkerReadThroughOptions {
83
+ /** Fresh window (ms); 0 or less reads the store and memoizes nothing. */
84
+ freshMs: number;
85
+ /** The oldest value served while it refreshes (ms). */
86
+ maxStaleMs: number;
87
+ /** The request carries the fresh-reads cookie: read the store. */
88
+ bypass: boolean;
89
+ /** Keeps a background refresh alive after the response (waitUntil). */
90
+ keepAlive?: (pending: Promise<void>) => void;
91
+ /** How the read was answered, for the debugPerformance marker row. */
92
+ onOutcome?: (outcome: MarkerMemoOutcome) => void;
93
+ }
94
+
95
+ export class TagMarkerMemo {
96
+ private readonly slots = new Map<string, MarkerSlot>();
97
+ private readonly refreshing = new Map<string, Promise<void>>();
98
+
99
+ constructor(private readonly maxEntries: number = MARKER_MEMO_MAX_ENTRIES) {}
100
+
101
+ /**
102
+ * The memoized marker for `key` if it is younger than `maxStaleMs` (stale
103
+ * when older than `freshMs`), or undefined when the caller must read the
104
+ * store. `freshMs <= 0` disables the memo.
105
+ */
106
+ lookup(
107
+ key: string,
108
+ freshMs: number,
109
+ maxStaleMs: number,
110
+ now: number = Date.now(),
111
+ ): MarkerMemoHit | undefined {
112
+ if (freshMs <= 0) return undefined;
113
+ const slot = this.slots.get(key);
114
+ if (!slot) return undefined;
115
+ const age = now - slot.readAt;
116
+ if (age >= Math.max(freshMs, maxStaleMs)) return undefined;
117
+ this.slots.delete(key);
118
+ this.slots.set(key, slot);
119
+ return { value: slot.value, stale: age >= freshMs };
120
+ }
121
+
122
+ /**
123
+ * Record a marker the store returned to a read that started at
124
+ * `readStartedAt`. The age counts from the read's start, not its end: a
125
+ * marker written while the read was in flight may be missing from it, so
126
+ * the value is only known to be current as of the start. Markers only move
127
+ * forward, so a value older than one already held (a write-through that
128
+ * landed during the read) is kept at the newer value, with the later stamp.
129
+ */
130
+ store(
131
+ key: string,
132
+ value: number | null,
133
+ readStartedAt: number = Date.now(),
134
+ ): void {
135
+ const held = this.slots.get(key);
136
+ const heldValue = held?.value ?? null;
137
+ const next =
138
+ heldValue !== null && (value === null || heldValue > value)
139
+ ? heldValue
140
+ : value;
141
+ this.slots.delete(key);
142
+ this.slots.set(key, {
143
+ value: next,
144
+ readAt: Math.max(held?.readAt ?? readStartedAt, readStartedAt),
145
+ });
146
+ if (this.slots.size > this.maxEntries) {
147
+ const oldest = this.slots.keys().next().value as string;
148
+ this.slots.delete(oldest);
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Refresh a stale marker in the background: one read per key at a time,
154
+ * handed to `keepAlive` (the store's waitUntil) so the runtime lets it
155
+ * finish after the response; without it the read runs detached. A read
156
+ * that fails, or may not be memoized, stores nothing.
157
+ */
158
+ refresh(
159
+ key: string,
160
+ read: () => Promise<MarkerFetch>,
161
+ keepAlive?: (pending: Promise<void>) => void,
162
+ ): void {
163
+ if (this.refreshing.has(key)) return;
164
+ const pending = (async (): Promise<void> => {
165
+ const startedAt = Date.now();
166
+ try {
167
+ const { value, memoize } = await read();
168
+ if (memoize) this.store(key, value, startedAt);
169
+ } catch {
170
+ // The next read past the stale window blocks on the store anyway.
171
+ } finally {
172
+ this.refreshing.delete(key);
173
+ }
174
+ })();
175
+ this.refreshing.set(key, pending);
176
+ keepAlive?.(pending);
177
+ }
178
+
179
+ /**
180
+ * A marker through the memo, stale-while-revalidate: a value within
181
+ * `freshMs` is used as is; one within `maxStaleMs` is used while one
182
+ * background read refreshes it; otherwise, and for a request carrying the
183
+ * fresh-reads cookie, the store is read (`fetch(false)`) and the result
184
+ * memoized. `fetch(true)` is the background refresh, outside any request.
185
+ */
186
+ async readThrough(
187
+ key: string,
188
+ fetch: (background: boolean) => Promise<MarkerFetch>,
189
+ options: MarkerReadThroughOptions,
190
+ ): Promise<number | null> {
191
+ if (options.freshMs <= 0) return (await fetch(false)).value;
192
+ if (!options.bypass) {
193
+ const hit = this.lookup(key, options.freshMs, options.maxStaleMs);
194
+ if (hit) {
195
+ options.onOutcome?.(hit.stale ? "stale" : "fresh");
196
+ if (hit.stale) this.refresh(key, () => fetch(true), options.keepAlive);
197
+ return hit.value;
198
+ }
199
+ }
200
+ const startedAt = Date.now();
201
+ const { value, memoize } = await fetch(false);
202
+ if (memoize) this.store(key, value, startedAt);
203
+ options.onOutcome?.(options.bypass ? "bypass" : "read");
204
+ return value;
205
+ }
206
+
207
+ clear(): void {
208
+ this.slots.clear();
209
+ this.refreshing.clear();
210
+ }
211
+ }
212
+
213
+ /**
214
+ * The tag names a shell key's entry carried when it was last read or written
215
+ * in this isolate, so the next HIT can start their marker reads before the
216
+ * entry names them. A wrong or missing hint costs a wasted read or nothing;
217
+ * the freshness check always uses the entry's own tags.
218
+ */
219
+ export class TagNameHints {
220
+ private readonly keys = new Map<string, readonly string[]>();
221
+
222
+ constructor(private readonly maxEntries: number = TAG_HINTS_MAX_ENTRIES) {}
223
+
224
+ get(key: string): readonly string[] | undefined {
225
+ return this.keys.get(key);
226
+ }
227
+
228
+ remember(key: string, tags: readonly string[] | undefined): void {
229
+ this.keys.delete(key);
230
+ if (!tags || tags.length === 0) return;
231
+ this.keys.set(key, tags);
232
+ if (this.keys.size > this.maxEntries) {
233
+ const oldest = this.keys.keys().next().value as string;
234
+ this.keys.delete(oldest);
235
+ }
236
+ }
237
+
238
+ clear(): void {
239
+ this.keys.clear();
240
+ }
241
+ }
242
+
243
+ /** The union of a key's remembered tag names and the route's static tags. */
244
+ export function hintedTags(
245
+ remembered: readonly string[] | undefined,
246
+ routeTags: readonly string[] | undefined,
247
+ ): string[] {
248
+ if (!routeTags || routeTags.length === 0)
249
+ return remembered ? [...remembered] : [];
250
+ if (!remembered || remembered.length === 0) return [...routeTags];
251
+ return [...new Set([...remembered, ...routeTags])];
252
+ }
253
+
254
+ /**
255
+ * The marker row's memo outcome for a read's tags: the most store-bound one
256
+ * (a bypass or store read outranks a stale value, which outranks a fresh one).
257
+ */
258
+ export function summarizeMarkerOutcomes(
259
+ tags: readonly string[] | undefined,
260
+ outcomes: ReadonlyMap<string, MarkerMemoOutcome>,
261
+ ): MarkerMemoOutcome | undefined {
262
+ let summary: MarkerMemoOutcome | undefined;
263
+ for (const tag of tags ?? []) {
264
+ const outcome = outcomes.get(tag);
265
+ if (
266
+ outcome &&
267
+ (!summary || OUTCOME_RANK[outcome] > OUTCOME_RANK[summary])
268
+ ) {
269
+ summary = outcome;
270
+ }
271
+ }
272
+ return summary;
273
+ }
274
+
275
+ const OUTCOME_RANK: Record<MarkerMemoOutcome, number> = {
276
+ fresh: 0,
277
+ stale: 1,
278
+ read: 2,
279
+ bypass: 3,
280
+ };
281
+
282
+ /**
283
+ * Fill a shell read's marker-row stats (debugPerformance) for the entry's
284
+ * own tags: the memo outcome, and how many of them the read had hinted.
285
+ */
286
+ export function recordMarkerRow(
287
+ stats: ShellReadStats,
288
+ tags: readonly string[] | undefined,
289
+ outcomes: ReadonlyMap<string, MarkerMemoOutcome> | undefined,
290
+ ): void {
291
+ if (!tags || tags.length === 0) return;
292
+ if (outcomes) stats.markerMemo = summarizeMarkerOutcomes(tags, outcomes);
293
+ if (stats.markerHinted) {
294
+ const hinted = stats.markerHinted;
295
+ stats.markerHintHits = tags.filter((tag) => hinted.includes(tag)).length;
296
+ }
297
+ }
298
+
299
+ /**
300
+ * True when this request carries the fresh-reads cookie: a request of the same
301
+ * user after an updateTag()/revalidateTag(), which must not be served from an
302
+ * isolate memo (shell or marker) that could predate the mutation.
303
+ */
304
+ export function freshReadsRequired(
305
+ ctx: Pick<RequestContext, "_freshReads"> | undefined,
306
+ ): boolean {
307
+ return ctx?._freshReads === true;
308
+ }