@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
@@ -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<
@@ -129,6 +129,19 @@ function shouldCacheResponse(response: Response): CacheDirectives | null {
129
129
  return parseCacheControl(cacheControl);
130
130
  }
131
131
 
132
+ /**
133
+ * Throw the first error the drained render reported through Flight or Fizz
134
+ * onError. A component that throws after the 200 committed does not fail the
135
+ * stream: it completes with an error row or an errored Suspense boundary, and
136
+ * every hit would serve it (issue #915). The caller's catch reports the skipped
137
+ * write as cache-write. Read only after the body is fully drained, when both
138
+ * renders have finished.
139
+ */
140
+ function throwIfRenderErrored(requestCtx: RequestContext | undefined): void {
141
+ const errors = requestCtx?._renderErrors;
142
+ if (errors && errors.length > 0) throw errors[0];
143
+ }
144
+
132
145
  // ============================================================================
133
146
  // Response Helpers
134
147
  // ============================================================================
@@ -404,6 +417,7 @@ export function createDocumentCacheMiddleware<TEnv = any>(
404
417
  // drain the fresh render fully before snapshotting tags
405
418
  // (same render-complete barrier as the miss path).
406
419
  const body = await new Response(fresh.body).arrayBuffer();
420
+ throwIfRenderErrored(requestCtx);
407
421
  await store.putResponse!(
408
422
  cacheKey,
409
423
  new Response(body, fresh),
@@ -468,6 +482,7 @@ export function createDocumentCacheMiddleware<TEnv = any>(
468
482
  // unaffected) is the render-complete barrier that keeps the cached
469
483
  // body and its tag set consistent.
470
484
  const body = await new Response(cacheStream).arrayBuffer();
485
+ throwIfRenderErrored(requestCtx);
471
486
  await store.putResponse!(
472
487
  cacheKey,
473
488
  new Response(body, originalResponse),
@@ -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,139 @@ 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: the owning loader
144
+ * re-runs on the HIT (loaders stay live), and its live pushes replace the
145
+ * recorded ones in place instead of appending a second copy. `claim`
146
+ * (setupLoaderAccess _claimLoaderPushes) is asked once per owner, so the
147
+ * loader's own cache() HIT does not replay its pushes a second time
148
+ * (loader-cache.ts replayLoaderHandles); an owner it refuses is skipped. A
149
+ * record without owners restores as a plain replay.
122
150
  */
123
151
  export function restoreHandles(
124
152
  handles: Record<string, SegmentHandleData>,
125
153
  handleStore: HandleStore,
154
+ owners?: HandleOwners,
155
+ claim?: (loaderId: string) => boolean,
126
156
  ): void {
157
+ let delivers: Map<string, boolean> | undefined;
127
158
  for (const [segId, segHandles] of Object.entries(handles)) {
128
- if (Object.keys(segHandles).length > 0) {
159
+ if (Object.keys(segHandles).length === 0) continue;
160
+ const segOwners = owners?.[segId];
161
+ if (!segOwners) {
129
162
  handleStore.replaySegmentData(segId, segHandles);
163
+ continue;
164
+ }
165
+ const cleared: SegmentHandleData = {};
166
+ for (const handleName in segHandles) cleared[handleName] = [];
167
+ handleStore.replaySegmentData(segId, cleared);
168
+ for (const [handleName, values] of Object.entries(segHandles)) {
169
+ const valueOwners = segOwners[handleName];
170
+ for (let i = 0; i < values.length; i++) {
171
+ const owner = valueOwners?.[i];
172
+ if (!owner) {
173
+ handleStore.push(handleName, segId, values[i]);
174
+ continue;
175
+ }
176
+ delivers ??= new Map();
177
+ let deliver = delivers.get(owner);
178
+ if (deliver === undefined) {
179
+ deliver = claim ? claim(owner) : true;
180
+ delivers.set(owner, deliver);
181
+ }
182
+ if (deliver) {
183
+ handleStore.pushReplayed(handleName, segId, values[i], owner);
184
+ }
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Append recorded handle pushes to the store, in recorded order, via push()
192
+ * so active captures and loader-scope tagging see them like live pushes. For
193
+ * a cached unit that shares its segments with live pushes ("use cache");
194
+ * restoreHandles would wipe those. `segmentId` redirects every value to one
195
+ * segment (the caller's).
196
+ *
197
+ * Owner-keyed groups (useCacheRecordKey, `${seq}:${loaderId}`) are a loader's
198
+ * pushes, which reach the page once per request: `claim` (setupLoaderAccess
199
+ * _claimLoaderPushes) skips a loader that already ran or was replayed in this
200
+ * request, and a claimed loader that runs later replaces its replayed values
201
+ * (HandleStore.pushReplayed). `${seq}:` groups are the function's own pushes.
202
+ * A key without ":" is a segment id (records written before owner keys) and
203
+ * replays unchanged. A loader's own cache() replays through loader-cache.ts
204
+ * replayLoaderHandles.
205
+ */
206
+ export function appendHandles(
207
+ handles: Record<string, SegmentHandleData>,
208
+ handleStore: HandleStore,
209
+ segmentId?: string,
210
+ claim?: (loaderId: string) => boolean,
211
+ ): void {
212
+ let claims: Map<string, boolean> | undefined;
213
+ for (const [key, segHandles] of Object.entries(handles)) {
214
+ const colon = key.indexOf(":");
215
+ const owner = colon < 0 ? "" : key.slice(colon + 1);
216
+ if (owner) {
217
+ claims ??= new Map();
218
+ let deliver = claims.get(owner);
219
+ if (deliver === undefined) {
220
+ deliver = claim ? claim(owner) : true;
221
+ claims.set(owner, deliver);
222
+ }
223
+ if (!deliver) continue;
224
+ }
225
+ const target = segmentId ?? key;
226
+ for (const [handleName, values] of Object.entries(segHandles)) {
227
+ for (const value of values) {
228
+ if (owner) {
229
+ replayLoaderPush(handleStore, handleName, target, value, owner);
230
+ } else {
231
+ handleStore.push(handleName, target, value);
232
+ }
233
+ }
130
234
  }
131
235
  }
132
236
  }
@@ -69,11 +69,15 @@ export function stringToStream(str: string): ReadableStream<Uint8Array> {
69
69
  */
70
70
  export async function rscSerialize(
71
71
  value: unknown,
72
+ onError?: (error: unknown) => void,
72
73
  ): Promise<string | undefined> {
73
74
  if (value === undefined || value === null) return undefined;
74
75
 
75
76
  const temporaryReferences = createTemporaryReferenceSet();
76
- const stream = renderToReadableStream(value, { temporaryReferences });
77
+ const stream = renderToReadableStream(value, {
78
+ temporaryReferences,
79
+ onError,
80
+ });
77
81
  return streamToString(stream);
78
82
  }
79
83
 
@@ -102,10 +106,16 @@ export async function rscDeserialize<T>(
102
106
  *
103
107
  * Returns null only on serialization failure.
104
108
  */
105
- export async function serializeResult(value: unknown): Promise<string | null> {
109
+ export async function serializeResult(
110
+ value: unknown,
111
+ onError?: (error: unknown) => void,
112
+ ): Promise<string | null> {
106
113
  try {
107
114
  const temporaryReferences = createTemporaryReferenceSet();
108
- const stream = renderToReadableStream(value, { temporaryReferences });
115
+ const stream = renderToReadableStream(value, {
116
+ temporaryReferences,
117
+ onError,
118
+ });
109
119
  return await streamToString(stream);
110
120
  } catch (error) {
111
121
  // Returning null silently turns a non-serializable cache value into a
@@ -145,9 +155,17 @@ export const deserializeComponent: (encoded: string) => Promise<unknown> =
145
155
  * Serialize segments for storage.
146
156
  * Each segment's component, layout, loading, and loaderData are RSC-serialized.
147
157
  * Metadata is preserved as-is.
158
+ *
159
+ * `onError` goes to every Flight encode. A component that throws while
160
+ * encoding (e.g. an async server component in the tree) does not reject the
161
+ * encode: Flight reports it through onError and writes an error row
162
+ * (`1:E{...}`) that throws when the decoded tree renders. cacheRoute and the
163
+ * prerender producers (prerender-match.ts) pass it to refuse such an entry;
164
+ * without it the encode is unchanged.
148
165
  */
149
166
  export async function serializeSegments(
150
167
  segments: ResolvedSegment[],
168
+ onError?: (error: unknown) => void,
151
169
  ): Promise<SerializedSegmentData[]> {
152
170
  return Promise.all(
153
171
  segments.map(async (segment): Promise<SerializedSegmentData> => {
@@ -162,6 +180,7 @@ export async function serializeSegments(
162
180
  // Serialize the component to RSC stream
163
181
  const stream = renderToReadableStream(componentResolved, {
164
182
  temporaryReferences,
183
+ onError,
165
184
  });
166
185
 
167
186
  // RSC-serialize loading: "null" string distinguishes explicit null from undefined
@@ -169,7 +188,7 @@ export async function serializeSegments(
169
188
  segment.loading !== undefined
170
189
  ? segment.loading === null
171
190
  ? "null"
172
- : await rscSerialize(segment.loading)
191
+ : await rscSerialize(segment.loading, onError)
173
192
  : undefined;
174
193
 
175
194
  // Await loaderData / loaderDataPromise if they're Promises
@@ -190,9 +209,9 @@ export async function serializeSegments(
190
209
  encodedLoaderDataPromise,
191
210
  ] = await Promise.all([
192
211
  streamToString(stream),
193
- segment.layout ? rscSerialize(segment.layout) : undefined,
194
- rscSerialize(loaderDataResolved),
195
- rscSerialize(loaderDataPromiseResolved),
212
+ segment.layout ? rscSerialize(segment.layout, onError) : undefined,
213
+ rscSerialize(loaderDataResolved, onError),
214
+ rscSerialize(loaderDataPromiseResolved, onError),
196
215
  ]);
197
216
 
198
217
  return {
@@ -68,8 +68,8 @@ function warnNoTagStore(fn: string, tags: string[]): void {
68
68
  console.warn(
69
69
  `[${fn}] No tag-capable cache store is configured; tags ` +
70
70
  `[${tags.join(", ")}] were not invalidated. The configured store must ` +
71
- `implement invalidateTags() (the built-in MemorySegmentCacheStore and ` +
72
- `CFCacheStore do).`,
71
+ `implement invalidateTags() (the built-in MemorySegmentCacheStore, ` +
72
+ `CFCacheStore, and VercelCacheStore do).`,
73
73
  );
74
74
  }
75
75
 
@@ -98,7 +98,8 @@ function warnPartialTagStore(fn: string, incapable: number): void {
98
98
  console.warn(
99
99
  `[${fn}] ${incapable} configured cache store(s) do not implement ` +
100
100
  `invalidateTags(); their tagged entries were NOT invalidated. Use a ` +
101
- `tag-capable store (e.g. MemorySegmentCacheStore / CFCacheStore) for any ` +
101
+ `tag-capable store (MemorySegmentCacheStore / CFCacheStore / ` +
102
+ `VercelCacheStore) for any ` +
102
103
  `cache({ store }) boundary whose entries you invalidate by tag.`,
103
104
  );
104
105
  }