@rangojs/router 0.21.0 → 0.22.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 (243) hide show
  1. package/dist/bin/rango.js +13 -1
  2. package/dist/types/browser/event-controller.d.ts +30 -13
  3. package/dist/types/browser/navigation-store.d.ts +9 -0
  4. package/dist/types/browser/partial-update.d.ts +2 -1
  5. package/dist/types/browser/prefetch/default-strategy.d.ts +16 -20
  6. package/dist/types/browser/react/NavigationProvider.d.ts +14 -1
  7. package/dist/types/browser/react/context.d.ts +14 -0
  8. package/dist/types/browser/rsc-router.d.ts +13 -2
  9. package/dist/types/browser/types.d.ts +10 -0
  10. package/dist/types/build/generate-manifest.d.ts +2 -0
  11. package/dist/types/cache/background-task.d.ts +22 -0
  12. package/dist/types/cache/cache-key-utils.d.ts +50 -5
  13. package/dist/types/cache/cache-policy.d.ts +17 -0
  14. package/dist/types/cache/cache-scope.d.ts +1 -1
  15. package/dist/types/cache/cf/cf-cache-store.d.ts +7 -1
  16. package/dist/types/cache/document-cache.d.ts +8 -5
  17. package/dist/types/cache/handle-snapshot.d.ts +23 -0
  18. package/dist/types/cache/index.d.ts +1 -1
  19. package/dist/types/cache/invalidation-order.d.ts +14 -0
  20. package/dist/types/cache/memory-segment-store.d.ts +33 -1
  21. package/dist/types/cache/shell-snapshot.d.ts +10 -1
  22. package/dist/types/cache/store-scope.d.ts +22 -0
  23. package/dist/types/cache/tag-invalidation.d.ts +7 -6
  24. package/dist/types/cache/types.d.ts +63 -14
  25. package/dist/types/cache/vercel/vercel-cache-store.d.ts +7 -1
  26. package/dist/types/errors.d.ts +6 -0
  27. package/dist/types/handles/deferred-resolution.d.ts +7 -0
  28. package/dist/types/internal-suspense-audit.d.ts +1 -0
  29. package/dist/types/loader-store.d.ts +4 -8
  30. package/dist/types/outlet-provider.d.ts +2 -1
  31. package/dist/types/prerender/cloudflare.d.ts +49 -0
  32. package/dist/types/prerender/create-prerender-trigger.d.ts +121 -0
  33. package/dist/types/prerender/index.d.ts +12 -0
  34. package/dist/types/prerender/memory-prerender-store.d.ts +29 -0
  35. package/dist/types/prerender/on-demand.d.ts +272 -0
  36. package/dist/types/prerender/producer-guard.d.ts +33 -0
  37. package/dist/types/prerender/shell-manifest-key.d.ts +1 -18
  38. package/dist/types/prerender/warm-request.d.ts +65 -0
  39. package/dist/types/prerender/warm.d.ts +65 -0
  40. package/dist/types/prerender/writable-store.d.ts +170 -0
  41. package/dist/types/prerender.d.ts +27 -0
  42. package/dist/types/reverse.d.ts +1 -1
  43. package/dist/types/route-content-wrapper.d.ts +3 -1
  44. package/dist/types/router/error-handling.d.ts +2 -0
  45. package/dist/types/router/handler-context.d.ts +1 -1
  46. package/dist/types/router/match-middleware/cache-lookup.d.ts +14 -0
  47. package/dist/types/router/pattern-matching.d.ts +2 -0
  48. package/dist/types/router/prefetch-default.d.ts +4 -4
  49. package/dist/types/router/prerender-match.d.ts +23 -1
  50. package/dist/types/router/route-trie-builder.d.ts +8 -1
  51. package/dist/types/router/router-context.d.ts +1 -0
  52. package/dist/types/router/router-interfaces.d.ts +30 -0
  53. package/dist/types/router/router-options.d.ts +24 -0
  54. package/dist/types/router/segment-resolution/fresh.d.ts +7 -0
  55. package/dist/types/router/segment-resolution/loader-cache.d.ts +2 -1
  56. package/dist/types/router/segment-resolution/revalidation.d.ts +5 -1
  57. package/dist/types/router/segment-resolution/streamed-handler-boundary.d.ts +43 -0
  58. package/dist/types/router/trie-matching.d.ts +2 -0
  59. package/dist/types/rsc/shell-build-manifest.d.ts +6 -4
  60. package/dist/types/rsc/shell-capture-constants.d.ts +20 -5
  61. package/dist/types/rsc/shell-capture.d.ts +14 -1
  62. package/dist/types/rsc/shell-serve.d.ts +2 -2
  63. package/dist/types/rsc/ssr-module-loader.d.ts +15 -0
  64. package/dist/types/rsc/types.d.ts +8 -0
  65. package/dist/types/segment-boundary-content.d.ts +17 -0
  66. package/dist/types/segment-loader-promise.d.ts +2 -6
  67. package/dist/types/server/build-version-table.d.ts +9 -0
  68. package/dist/types/server/context.d.ts +12 -4
  69. package/dist/types/server/handle-store.d.ts +2 -0
  70. package/dist/types/server/request-context.d.ts +63 -2
  71. package/dist/types/suspense-audit-react.d.ts +26 -0
  72. package/dist/types/suspense-audit.d.ts +157 -0
  73. package/dist/types/testing/action-form.d.ts +34 -0
  74. package/dist/types/testing/flight.entry.d.ts +2 -0
  75. package/dist/types/testing/index.d.ts +4 -0
  76. package/dist/types/testing/internal/server-modules.d.ts +8 -0
  77. package/dist/types/testing/serve-shell-request.d.ts +34 -4
  78. package/dist/types/testing/shell-status.d.ts +14 -6
  79. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +9 -6
  80. package/dist/types/thenable-status.d.ts +6 -0
  81. package/dist/types/types/handler-context.d.ts +7 -0
  82. package/dist/types/types/route-entry.d.ts +5 -0
  83. package/dist/types/urls/path-helper.d.ts +18 -0
  84. package/dist/types/vite/discovery/state.d.ts +11 -0
  85. package/dist/types/vite/inject-client-debug.d.ts +6 -25
  86. package/dist/types/vite/utils/bundle-analysis.d.ts +6 -2
  87. package/dist/vite/index.js +179 -496
  88. package/package.json +12 -2
  89. package/skills/cache-guide/SKILL.md +18 -17
  90. package/skills/caching/SKILL.md +40 -4
  91. package/skills/catalog.json +1 -1
  92. package/skills/cloudflare/SKILL.md +131 -0
  93. package/skills/comparison/references/framework-comparison.md +1 -1
  94. package/skills/deployment-caching/SKILL.md +33 -12
  95. package/skills/document-cache/SKILL.md +16 -14
  96. package/skills/hooks/SKILL.md +22 -0
  97. package/skills/hooks/handle-and-actions.md +3 -4
  98. package/skills/hooks/navigation.md +10 -0
  99. package/skills/hooks/state.md +5 -4
  100. package/skills/host-router/SKILL.md +18 -0
  101. package/skills/loader/SKILL.md +6 -4
  102. package/skills/middleware/SKILL.md +1 -1
  103. package/skills/migrate-nextjs/SKILL.md +82 -9
  104. package/skills/ppr/SKILL.md +31 -8
  105. package/skills/prerender/SKILL.md +645 -8
  106. package/skills/rango/SKILL.md +1 -0
  107. package/skills/response-routes/SKILL.md +5 -5
  108. package/skills/route/SKILL.md +29 -0
  109. package/skills/testing/SKILL.md +2 -1
  110. package/skills/testing/cache-prerender.md +18 -17
  111. package/skills/testing/serve-shell-request.md +104 -8
  112. package/skills/use-cache/SKILL.md +9 -5
  113. package/skills/vercel/SKILL.md +2 -0
  114. package/skills/view-transitions/SKILL.md +9 -4
  115. package/src/browser/event-controller.ts +69 -25
  116. package/src/browser/link-interceptor.ts +2 -2
  117. package/src/browser/navigation-bridge.ts +75 -35
  118. package/src/browser/navigation-store.ts +26 -0
  119. package/src/browser/navigation-transaction.ts +9 -2
  120. package/src/browser/network-error-handler.ts +2 -0
  121. package/src/browser/partial-update.ts +48 -12
  122. package/src/browser/prefetch/default-strategy.ts +22 -42
  123. package/src/browser/react/Link.tsx +37 -42
  124. package/src/browser/react/NavigationProvider.tsx +63 -3
  125. package/src/browser/react/context.ts +17 -0
  126. package/src/browser/react/location-state.ts +5 -19
  127. package/src/browser/react/use-action.ts +4 -1
  128. package/src/browser/react/use-handle.ts +4 -20
  129. package/src/browser/react/use-link-status.ts +4 -3
  130. package/src/browser/react/use-navigation.ts +4 -1
  131. package/src/browser/react/use-params.ts +4 -1
  132. package/src/browser/react/use-pathname.ts +4 -1
  133. package/src/browser/react/use-search-params.ts +5 -3
  134. package/src/browser/react/use-segments.ts +36 -54
  135. package/src/browser/rsc-router.tsx +79 -39
  136. package/src/browser/segment-reconciler.ts +3 -3
  137. package/src/browser/server-action-bridge.ts +26 -6
  138. package/src/browser/types.ts +11 -0
  139. package/src/build/generate-manifest.ts +17 -0
  140. package/src/build/merge-full-manifests.ts +5 -0
  141. package/src/build/route-trie.ts +1 -0
  142. package/src/cache/background-task.ts +61 -0
  143. package/src/cache/cache-key-utils.ts +91 -12
  144. package/src/cache/cache-policy.ts +11 -2
  145. package/src/cache/cache-runtime.ts +23 -23
  146. package/src/cache/cache-scope.ts +38 -36
  147. package/src/cache/cf/cf-cache-store.ts +16 -11
  148. package/src/cache/document-cache.ts +35 -14
  149. package/src/cache/handle-snapshot.ts +50 -0
  150. package/src/cache/index.ts +1 -0
  151. package/src/cache/invalidation-order.ts +53 -0
  152. package/src/cache/memory-segment-store.ts +52 -1
  153. package/src/cache/shell-snapshot.ts +17 -0
  154. package/src/cache/store-scope.ts +36 -0
  155. package/src/cache/tag-invalidation.ts +7 -5
  156. package/src/cache/types.ts +69 -14
  157. package/src/cache/vercel/vercel-cache-store.ts +20 -6
  158. package/src/client.tsx +40 -3
  159. package/src/errors.ts +13 -6
  160. package/src/handles/deferred-resolution.ts +7 -2
  161. package/src/internal-suspense-audit.ts +12 -0
  162. package/src/loader-store.ts +4 -9
  163. package/src/outlet-provider.tsx +25 -1
  164. package/src/prerender/cloudflare.ts +130 -0
  165. package/src/prerender/create-prerender-trigger.ts +780 -0
  166. package/src/prerender/index.ts +39 -0
  167. package/src/prerender/memory-prerender-store.ts +115 -0
  168. package/src/prerender/on-demand.ts +339 -0
  169. package/src/prerender/producer-guard.ts +66 -0
  170. package/src/prerender/shell-manifest-key.ts +12 -17
  171. package/src/prerender/warm-request.ts +154 -0
  172. package/src/prerender/warm.ts +193 -0
  173. package/src/prerender/writable-store.ts +278 -0
  174. package/src/prerender.ts +29 -0
  175. package/src/reverse.ts +1 -1
  176. package/src/route-content-wrapper.tsx +107 -13
  177. package/src/router/error-handling.ts +12 -12
  178. package/src/router/find-match.ts +1 -0
  179. package/src/router/handler-context.ts +2 -0
  180. package/src/router/intercept-resolution.ts +15 -8
  181. package/src/router/loader-resolution.ts +2 -10
  182. package/src/router/match-middleware/background-revalidation.ts +2 -0
  183. package/src/router/match-middleware/cache-lookup.ts +230 -38
  184. package/src/router/pattern-matching.ts +14 -0
  185. package/src/router/prefetch-default.ts +4 -4
  186. package/src/router/prerender-match.ts +460 -352
  187. package/src/router/route-trie-builder.ts +9 -0
  188. package/src/router/router-context.ts +1 -0
  189. package/src/router/router-interfaces.ts +33 -0
  190. package/src/router/router-options.ts +30 -0
  191. package/src/router/segment-resolution/fresh.ts +20 -19
  192. package/src/router/segment-resolution/loader-cache.ts +15 -17
  193. package/src/router/segment-resolution/revalidation.ts +34 -24
  194. package/src/router/segment-resolution/streamed-handler-boundary.ts +133 -0
  195. package/src/router/trie-matching.ts +3 -0
  196. package/src/router.ts +107 -1
  197. package/src/rsc/handler.ts +51 -4
  198. package/src/rsc/progressive-enhancement.ts +5 -1
  199. package/src/rsc/response-cache-serve.ts +10 -4
  200. package/src/rsc/rsc-rendering.ts +53 -11
  201. package/src/rsc/shell-build-manifest.ts +10 -8
  202. package/src/rsc/shell-capture-constants.ts +39 -7
  203. package/src/rsc/shell-capture.ts +108 -19
  204. package/src/rsc/shell-serve.ts +12 -6
  205. package/src/rsc/ssr-module-loader.ts +26 -0
  206. package/src/rsc/types.ts +8 -0
  207. package/src/segment-boundary-content.ts +25 -0
  208. package/src/segment-loader-promise.ts +28 -38
  209. package/src/segment-system.tsx +117 -57
  210. package/src/server/build-version-table.ts +15 -0
  211. package/src/server/context.ts +40 -17
  212. package/src/server/cookie-store.ts +5 -0
  213. package/src/server/handle-store.ts +13 -0
  214. package/src/server/request-context.ts +87 -1
  215. package/src/ssr/ssr-root.tsx +20 -13
  216. package/src/suspense-audit-react.tsx +98 -0
  217. package/src/suspense-audit.ts +1030 -0
  218. package/src/testing/action-form.ts +66 -0
  219. package/src/testing/flight-runtime.d.ts +27 -0
  220. package/src/testing/flight.entry.ts +3 -0
  221. package/src/testing/index.ts +19 -0
  222. package/src/testing/internal/server-modules.ts +32 -0
  223. package/src/testing/render-route.tsx +25 -12
  224. package/src/testing/serve-shell-request.ts +190 -34
  225. package/src/testing/shell-status.ts +17 -6
  226. package/src/testing/vitest-stubs/plugin-rsc.ts +37 -11
  227. package/src/thenable-status.ts +10 -0
  228. package/src/types/handler-context.ts +7 -0
  229. package/src/types/route-entry.ts +6 -0
  230. package/src/urls/path-helper.ts +69 -9
  231. package/src/use-loader.tsx +57 -30
  232. package/src/vite/discovery/bundle-postprocess.ts +4 -0
  233. package/src/vite/discovery/discover-routers.ts +15 -0
  234. package/src/vite/discovery/shell-prerender-phase.ts +5 -5
  235. package/src/vite/discovery/state.ts +17 -1
  236. package/src/vite/inject-client-debug.ts +42 -20
  237. package/src/vite/plugins/virtual-entries.ts +8 -1
  238. package/src/vite/router-discovery.ts +14 -0
  239. package/src/vite/utils/bundle-analysis.ts +39 -7
  240. package/dist/types/browser/react/subscribe-to-nothing.d.ts +0 -7
  241. package/dist/types/segment-content-promise.d.ts +0 -13
  242. package/src/browser/react/subscribe-to-nothing.ts +0 -7
  243. package/src/segment-content-promise.ts +0 -67
@@ -41,7 +41,9 @@ import {
41
41
  SHELL_CAPTURE_TASK_HARD_CAP_MS,
42
42
  resetShellWarningsForTests,
43
43
  warnOnce,
44
+ type ShellCaptureRefusal,
44
45
  } from "./shell-capture-constants.js";
46
+ import { noteWarmWrite } from "../prerender/warm-request.js";
45
47
  import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
46
48
  import { observePhase, PHASES } from "../router/instrument.js";
47
49
  import type { TraceSpan } from "../router/tracing.js";
@@ -75,6 +77,7 @@ import type {
75
77
  CachedEntryData,
76
78
  ShellCacheEntry,
77
79
  SegmentCacheStore,
80
+ ShellSnapshotHandlesValue,
78
81
  ShellSnapshotRecord,
79
82
  } from "../cache/types.js";
80
83
  import {
@@ -83,6 +86,7 @@ import {
83
86
  } from "../router/segment-resolution/loader-snapshot.js";
84
87
  import {
85
88
  RecordingShellStore,
89
+ SHELL_HANDLES_RECORD_KEY,
86
90
  SnapshotOnlySegmentStore,
87
91
  countSnapshotFamilies,
88
92
  estimateShellEntryBytes,
@@ -90,10 +94,17 @@ import {
90
94
  hasDocRecord,
91
95
  pruneShellSnapshot,
92
96
  } from "../cache/shell-snapshot.js";
97
+ import {
98
+ captureOwnedHandles,
99
+ encodeHandles,
100
+ } from "../cache/handle-snapshot.js";
93
101
  import type { HandlerContext } from "./handler-context.js";
94
102
  import type { SSRModule } from "./types.js";
95
103
  import { buildFullPayload, payloadInitialTheme } from "./full-payload.js";
96
- import { resolveDeferredHandleValues } from "../handles/deferred-resolution.js";
104
+ import {
105
+ recordedHandleData,
106
+ resolveDeferredHandleValues,
107
+ } from "../handles/deferred-resolution.js";
97
108
  import { renderRscFlightStage } from "./render-pipeline.js";
98
109
  import { stripInternalParams } from "../router/handler-context.js";
99
110
 
@@ -482,6 +493,20 @@ function refuseOnCaptureGuard(
482
493
  return true;
483
494
  }
484
495
 
496
+ /** The refusal a tripped capture guard stands for (refuseOnCaptureGuard). */
497
+ function guardRefusal(derivedCtx: RequestContext<any>): ShellCaptureRefusal {
498
+ return derivedCtx._dynamic ? "dynamic" : "identity";
499
+ }
500
+
501
+ /** A refused attempt; the reason rides its event (ShellCaptureDebugEvent.refusal). */
502
+ function refuse(
503
+ stats: Pick<ShellCaptureDebugEvent, "refusal">,
504
+ refusal: ShellCaptureRefusal,
505
+ ): "refused" {
506
+ stats.refusal = refusal;
507
+ return "refused";
508
+ }
509
+
485
510
  /** The no-shell cause for handler output that missed the capture deadline. */
486
511
  const HANDLER_OUTPUT_TIMEOUT_REASON: string =
487
512
  "the handler output (a promise it passes or pushes, or an async server " +
@@ -658,6 +683,8 @@ export interface ShellCaptureDebugEvent {
658
683
  untaggedBake?: true;
659
684
  /** Outcome reported by a store that supports shell-write acknowledgements. */
660
685
  storeWrite?: "stored" | "invalidated" | "uncacheable";
686
+ /** Why a `refused` attempt refused. */
687
+ refusal?: ShellCaptureRefusal;
661
688
  /** Consecutive failure count in the key's backoff entry, when one exists. */
662
689
  backoffFailures?: number;
663
690
  /** Ms remaining in the key's backoff window, when one exists. */
@@ -719,6 +746,7 @@ export function describeShellCaptureEvent(
719
746
  if (event.storeWrite !== undefined) {
720
747
  parts.push(`store-write=${event.storeWrite}`);
721
748
  }
749
+ if (event.refusal !== undefined) parts.push(`refusal=${event.refusal}`);
722
750
  if (event.backoffFailures !== undefined) {
723
751
  parts.push(`backoff-failures=${event.backoffFailures}`);
724
752
  }
@@ -1042,6 +1070,15 @@ export interface ShellCaptureDescriptor {
1042
1070
  * re-render the page and drop the isolate's memo of the shell just stored.
1043
1071
  */
1044
1072
  storedSeqAtRead?: number;
1073
+ /**
1074
+ * A `router.prerender()` warm's capture (rsc-rendering.ts shellServePlan):
1075
+ * one explicit call that must store, so the guards that shed a herd of
1076
+ * visitors do not apply to it. scheduleShellCapture skips the in-flight
1077
+ * dedup and the refused-capture backoff, and attemptCapture waits longer on
1078
+ * the write barrier. Every correctness guard, the inert-store skip and the
1079
+ * queue limits still decide.
1080
+ */
1081
+ force?: true;
1045
1082
  }
1046
1083
 
1047
1084
  /** Per key, the sequence number of its latest stored capture (bounded). */
@@ -1086,7 +1123,10 @@ export function scheduleShellCapture(
1086
1123
  descriptor: ShellCaptureDescriptor,
1087
1124
  ): void {
1088
1125
  const key = descriptor.key;
1089
- const inFlight = inFlightCaptures.get(key);
1126
+ // A forced capture installs its own token below: a visitor's capture that
1127
+ // started before the warm may have read data from before the change the
1128
+ // warm publishes, and the queue runs it first, so the warm stores last.
1129
+ const inFlight = descriptor.force ? undefined : inFlightCaptures.get(key);
1090
1130
  if (inFlight) {
1091
1131
  if (Date.now() - inFlight.startedAt <= SHELL_CAPTURE_TASK_HARD_CAP_MS) {
1092
1132
  publishCaptureDebugEvent(descriptor, { key, outcome: "skip-in-flight" });
@@ -1107,7 +1147,7 @@ export function scheduleShellCapture(
1107
1147
  }
1108
1148
  // Refused/failed within the window → skip the doomed re-render (one probe per
1109
1149
  // key per window per isolate). Expired entries self-evict inside the check.
1110
- if (isCaptureBackedOff(key)) {
1150
+ if (!descriptor.force && isCaptureBackedOff(key)) {
1111
1151
  publishCaptureDebugEvent(descriptor, {
1112
1152
  key,
1113
1153
  outcome: "skip-backoff",
@@ -1436,6 +1476,7 @@ type CaptureAttemptStats = Pick<
1436
1476
  | "snapshotSkipped"
1437
1477
  | "untaggedBake"
1438
1478
  | "storeWrite"
1479
+ | "refusal"
1439
1480
  | "recordSettleMs"
1440
1481
  | "entryBytes"
1441
1482
  > & {
@@ -1597,7 +1638,7 @@ async function runShellCapture(
1597
1638
  export type CaptureRecordOutcome =
1598
1639
  | { kind: "record" | "prerender"; match: MatchResult }
1599
1640
  | { kind: "timeout"; reason: string }
1600
- | { kind: "refused" };
1641
+ | { kind: "refused"; refusal: ShellCaptureRefusal };
1601
1642
 
1602
1643
  /**
1603
1644
  * Record-first capture: the doc record is both the settle signal and the
@@ -1643,7 +1684,13 @@ export async function settleCaptureRecord(
1643
1684
  const loaderRecords = derivedCtx._shellCaptureLoaderRecords;
1644
1685
  const settled = await raceDeadline(
1645
1686
  Promise.all([
1646
- freshHandleStore.getData().then(resolveDeferredHandleValues),
1687
+ freshHandleStore
1688
+ .getData()
1689
+ .then((data) =>
1690
+ resolveDeferredHandleValues(
1691
+ recordedHandleData(freshHandleStore, data),
1692
+ ),
1693
+ ),
1647
1694
  derivation.handlerPushesSettled(),
1648
1695
  loaderRecords && loaderRecords.size > 0
1649
1696
  ? Promise.allSettled([...loaderRecords.values()])
@@ -1655,7 +1702,7 @@ export async function settleCaptureRecord(
1655
1702
  // write starts (no encode for a capture that already failed).
1656
1703
  if (!settled.done) {
1657
1704
  if (refuseOnCaptureGuard(capture.key, derivedCtx))
1658
- return { kind: "refused" };
1705
+ return { kind: "refused", refusal: guardRefusal(derivedCtx) };
1659
1706
  return { kind: "timeout", reason: HANDLER_OUTPUT_TIMEOUT_REASON };
1660
1707
  }
1661
1708
 
@@ -1676,7 +1723,9 @@ export async function settleCaptureRecord(
1676
1723
 
1677
1724
  // Capture guards first: a tripped guard usually also fails the record,
1678
1725
  // and its own message names the cause.
1679
- if (refuseOnCaptureGuard(capture.key, derivedCtx)) return { kind: "refused" };
1726
+ if (refuseOnCaptureGuard(capture.key, derivedCtx)) {
1727
+ return { kind: "refused", refusal: guardRefusal(derivedCtx) };
1728
+ }
1680
1729
 
1681
1730
  const docKey = derivedCtx._shellImplicitCache?.docKey;
1682
1731
  const record =
@@ -1694,7 +1743,7 @@ export async function settleCaptureRecord(
1694
1743
  return { kind: "timeout", reason: HANDLER_OUTPUT_TIMEOUT_REASON };
1695
1744
  }
1696
1745
  warnCaptureRefusedOnce(capture.key, NO_DOC_RECORD_REASON);
1697
- return { kind: "refused" };
1746
+ return { kind: "refused", refusal: "no-record" };
1698
1747
  }
1699
1748
 
1700
1749
  // A handle encode that timed out stores "" (handle-snapshot.ts
@@ -1708,7 +1757,7 @@ export async function settleCaptureRecord(
1708
1757
  "record. A promise inside a Map, Set, or class instance is not awaited " +
1709
1758
  "before the encode; push plain objects and arrays.",
1710
1759
  );
1711
- return { kind: "refused" };
1760
+ return { kind: "refused", refusal: "handles" };
1712
1761
  }
1713
1762
 
1714
1763
  const { fragmentSegments } = await import("../cache/segment-codec.js");
@@ -1805,8 +1854,21 @@ async function attemptCapture(
1805
1854
  // skipped, cache-store middleware's write path gated off by state.cacheHit), so
1806
1855
  // prelude, snapshot, and ring-3 agree on the foreground's generation. Runs per
1807
1856
  // attempt (the retry re-checks; already-settled promises are free).
1857
+ //
1858
+ // A forced capture (a warm) is not latency-bound and must not bake an entry
1859
+ // its own foreground is still replacing, so it waits for as long as the task
1860
+ // hard cap leaves after the capture budget.
1808
1861
  const barrierStart = performance.now();
1809
- await settleTrackedBackgroundTasks(reqCtx, SHELL_CAPTURE_WRITE_BARRIER_MS);
1862
+ await settleTrackedBackgroundTasks(
1863
+ reqCtx,
1864
+ descriptor.force
1865
+ ? Math.max(
1866
+ SHELL_CAPTURE_WRITE_BARRIER_MS,
1867
+ SHELL_CAPTURE_TASK_HARD_CAP_MS -
1868
+ (descriptor.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS),
1869
+ )
1870
+ : SHELL_CAPTURE_WRITE_BARRIER_MS,
1871
+ );
1810
1872
  stats.barrierWaitMs = Math.round(performance.now() - barrierStart);
1811
1873
 
1812
1874
  const derivation = deriveShellCaptureContext(
@@ -1863,7 +1925,7 @@ async function attemptCapture(
1863
1925
  stats.recordSettleMs,
1864
1926
  );
1865
1927
  }
1866
- if (settled.kind === "refused") return "refused";
1928
+ if (settled.kind === "refused") return refuse(stats, settled.refusal);
1867
1929
  if (settled.kind === "timeout") {
1868
1930
  stats.noShellCause = settled.reason;
1869
1931
  return "no-shell";
@@ -2122,6 +2184,7 @@ export function deriveShellCaptureContext(
2122
2184
  // Own list: the capture's render errors refuse the capture only, and the
2123
2185
  // foreground's never reach it.
2124
2186
  derivedCtx._renderErrors = [];
2187
+ derivedCtx._recoveredHandlerErrors = undefined;
2125
2188
  derivedCtx._shellCaptureRun = true;
2126
2189
  // Own dynamic() latch. The inherited method writes the FOREGROUND context
2127
2190
  // (its closure), which already served, and nothing read it for a runtime
@@ -2240,7 +2303,9 @@ async function captureAndStoreShell(
2240
2303
  // (boundary-less segment — lands in the catch) or is swallowed into
2241
2304
  // per-loader error UI (the render completes — caught after the try).
2242
2305
  const refuseOnGuardTrip = (): "refused" | undefined =>
2243
- refuseOnCaptureGuard(capture.key, reqCtx) ? "refused" : undefined;
2306
+ refuseOnCaptureGuard(capture.key, reqCtx)
2307
+ ? refuse(stats, guardRefusal(reqCtx))
2308
+ : undefined;
2244
2309
 
2245
2310
  // Dev diagnostics for a no-shell attempt (warnNullCaptureOnce): the stacks of
2246
2311
  // the tasks React reports as still pending at the capture's abort. React's
@@ -2356,7 +2421,7 @@ async function captureAndStoreShell(
2356
2421
  "Fix the loader, or drop its ssr: false to move it to the live lane.",
2357
2422
  PPR_LANE_HINT,
2358
2423
  );
2359
- return "refused";
2424
+ return refuse(stats, "loader");
2360
2425
  }
2361
2426
  // The container itself never settled: it is a hole (under an ancestor
2362
2427
  // boundary) or the trivial-prelude gate already fired. Omit — no pin.
@@ -2373,7 +2438,7 @@ async function captureAndStoreShell(
2373
2438
  "Drop its ssr: false to move it to the live lane, or move the decision into middleware.",
2374
2439
  PPR_LANE_HINT,
2375
2440
  );
2376
- return "refused";
2441
+ return refuse(stats, "loader");
2377
2442
  }
2378
2443
  // Past the hole check: this container settled with real material that
2379
2444
  // bakes into the shell prelude (independent of the snapshot pin below).
@@ -2413,6 +2478,21 @@ async function captureAndStoreShell(
2413
2478
  }
2414
2479
  }
2415
2480
 
2481
+ // Built before the render-error check below so its encode errors refuse
2482
+ // the capture (ShellSnapshotHandlesValue).
2483
+ const docKey = reqCtx._shellImplicitCache?.docKey;
2484
+ let prerenderHandles: ShellSnapshotHandlesValue | undefined;
2485
+ if (!capture.navigationOnly && !hasDocRecord(snapshot, docKey)) {
2486
+ // The store is sealed (settleCaptureRecord) and settled (quiesce).
2487
+ const { handles, owners } = captureOwnedHandles(reqCtx._handleStore);
2488
+ prerenderHandles = {
2489
+ handles: await encodeHandles(handles, (error) => {
2490
+ reqCtx._renderErrors?.push(error);
2491
+ }),
2492
+ handleOwners: owners,
2493
+ };
2494
+ }
2495
+
2416
2496
  // A shell component that threw inside a Suspense boundary does not reject
2417
2497
  // the capture: Flight and Fizz report it through onError and the prelude
2418
2498
  // carries the errored boundary, which every HIT would serve (issue #915).
@@ -2426,7 +2506,6 @@ async function captureAndStoreShell(
2426
2506
  // Store only what a HIT reads (issue #941): the doc record and, for a
2427
2507
  // document entry, the bake-lane loader pins. Before the size guards, so
2428
2508
  // they measure what is stored.
2429
- const docKey = reqCtx._shellImplicitCache?.docKey;
2430
2509
  let prunedRecords: string | undefined;
2431
2510
  const { kept, pruned } = pruneShellSnapshot(
2432
2511
  snapshot,
@@ -2464,6 +2543,15 @@ async function captureAndStoreShell(
2464
2543
  stats.snapshotSkipped = true;
2465
2544
  }
2466
2545
  }
2546
+ // Exempt from maxSnapshotBytes: the HIT needs it for its HTML and
2547
+ // hydration data to match.
2548
+ if (prerenderHandles) {
2549
+ snapshot.push({
2550
+ family: "handles",
2551
+ key: SHELL_HANDLES_RECORD_KEY,
2552
+ value: prerenderHandles,
2553
+ });
2554
+ }
2467
2555
 
2468
2556
  // settleCaptureRecord refused every capture without a doc record except a
2469
2557
  // prerender-served one (the prerender store supplies its handler layer on
@@ -2525,7 +2613,7 @@ async function captureAndStoreShell(
2525
2613
  "Shrink the page the shell bakes, or move large regions under a live " +
2526
2614
  "loader's boundary.",
2527
2615
  );
2528
- return "refused";
2616
+ return refuse(stats, "size");
2529
2617
  }
2530
2618
 
2531
2619
  // Missing tags are valid: the shell follows TTL/SWR-only invalidation. Expose
@@ -2597,7 +2685,7 @@ async function captureAndStoreShell(
2597
2685
  record,
2598
2686
  `${sinceWriteMs} ms after this capture wrote it`,
2599
2687
  );
2600
- return "refused";
2688
+ return refuse(stats, "record-expired");
2601
2689
  }
2602
2690
  stats.expired = {
2603
2691
  route,
@@ -2623,7 +2711,7 @@ async function captureAndStoreShell(
2623
2711
  "If capture code deterministically calls updateTag() on its own shell tag, move that mutation out of the render; " +
2624
2712
  "otherwise every generation is invalidated before it can be served.",
2625
2713
  );
2626
- return "refused";
2714
+ return refuse(stats, "invalidated");
2627
2715
  }
2628
2716
  if (storeWrite === "uncacheable") {
2629
2717
  // The store declared the entry unstorable under its current
@@ -2638,8 +2726,9 @@ async function captureAndStoreShell(
2638
2726
  "the write as permanently refusable, so the key is backed off. See the cache store's " +
2639
2727
  "own warning for the specific limit and remedy.",
2640
2728
  );
2641
- return "refused";
2729
+ return refuse(stats, "uncacheable");
2642
2730
  }
2731
+ noteWarmWrite(reqCtx, "shell");
2643
2732
  } catch (error) {
2644
2733
  // Best-effort: a failed put must never throw out of the background task.
2645
2734
  reportCacheError(
@@ -35,6 +35,7 @@ import {
35
35
  resetShellWarningsForTests,
36
36
  warnOnce,
37
37
  } from "./shell-capture-constants.js";
38
+ import { routerKeyPrefix } from "../cache/cache-key-utils.js";
38
39
 
39
40
  export {
40
41
  PPR_REPLAY_STATUS_HEADER,
@@ -529,8 +530,9 @@ export function warnPprWindowCappedOnce(
529
530
 
530
531
  /**
531
532
  * Paths a partitioned request found without a build shell. Build shells are
532
- * per path (a param route prerenders some of its paths), so a negative is
533
- * kept per path, not per route; cleared when full, so it stays bounded.
533
+ * per router and path (a param route prerenders some of its paths), so a
534
+ * negative is kept per router and path, not per route; cleared when full, so
535
+ * it stays bounded.
534
536
  */
535
537
  const pathsWithoutBuildShell = new Set<string>();
536
538
  const PATHS_WITHOUT_BUILD_SHELL_MAX = 1_000;
@@ -540,12 +542,14 @@ const PATHS_WITHOUT_BUILD_SHELL_MAX = 1_000;
540
542
  * already warned, or its path was already found without one.
541
543
  */
542
544
  export function partitionBuildShellCheckDone(
545
+ routerId: string,
543
546
  pathname: string,
544
547
  routeName: string | undefined,
545
548
  ): boolean {
549
+ const router = routerKeyPrefix(routerId);
546
550
  return (
547
- hasWarnedOnce("partition-build-shell", routeName ?? pathname) ||
548
- pathsWithoutBuildShell.has(pathname)
551
+ hasWarnedOnce("partition-build-shell", router + (routeName ?? pathname)) ||
552
+ pathsWithoutBuildShell.has(router + pathname)
549
553
  );
550
554
  }
551
555
 
@@ -560,21 +564,23 @@ export function partitionBuildShellCheckDone(
560
564
  * with no build shell stays silent.
561
565
  */
562
566
  export function notePartitionBuildShellCheck(
567
+ routerId: string,
563
568
  pathname: string,
564
569
  routeName: string | undefined,
565
570
  found: boolean,
566
571
  ): void {
572
+ const router = routerKeyPrefix(routerId);
567
573
  if (!found) {
568
574
  if (pathsWithoutBuildShell.size >= PATHS_WITHOUT_BUILD_SHELL_MAX) {
569
575
  pathsWithoutBuildShell.clear();
570
576
  }
571
- pathsWithoutBuildShell.add(pathname);
577
+ pathsWithoutBuildShell.add(router + pathname);
572
578
  return;
573
579
  }
574
580
  // Concurrent first requests can both probe; one of them warns.
575
581
  warnOnce(
576
582
  "partition-build-shell",
577
- routeName ?? pathname,
583
+ router + (routeName ?? pathname),
578
584
  () =>
579
585
  `[rango] Route ${routeName ? `"${routeName}" ` : ""}("${pathname}") has a ` +
580
586
  "build-time shell, but its request partition (a cache({ key }) enclosing the route, " +
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The SSR module loader a handler created without `loadSSRModule` falls back
3
+ * to in tests (`router.fetch` creates its handler that way). A leaf with no
4
+ * runtime imports: testing/serve-shell-request.ts installs its stub here when
5
+ * it loads, without importing rsc/handler.ts (which binds build-only virtual
6
+ * modules a flight-only test config does not alias).
7
+ */
8
+
9
+ import type { LoadSSRModule } from "./types.js";
10
+
11
+ let testLoader: LoadSSRModule | undefined;
12
+
13
+ /**
14
+ * @internal Tests only: `router.prerender()` warms through `router.fetch`,
15
+ * and the rsc Vitest project cannot run `import.meta.viteRsc.loadModule`.
16
+ */
17
+ export function setDefaultSSRModuleLoaderForTests(
18
+ loader: LoadSSRModule | undefined,
19
+ ): void {
20
+ testLoader = loader;
21
+ }
22
+
23
+ /** @internal Undefined everywhere but in tests. */
24
+ export function defaultSSRModuleLoaderForTests(): LoadSSRModule | undefined {
25
+ return testLoader;
26
+ }
package/src/rsc/types.ts CHANGED
@@ -97,6 +97,14 @@ export interface RscPayload {
97
97
  locationState?: Record<string, unknown>;
98
98
  };
99
99
  returnValue?: { ok: boolean; data: unknown };
100
+ /**
101
+ * useActionState form state of a progressive-enhancement POST re-render
102
+ * (the value the SSR render received). Present only on that document; the
103
+ * browser entry passes it to hydrateRoot so the hydrated hook matches the
104
+ * server HTML instead of resetting to its initial state. A promise so a
105
+ * state Flight cannot serialize rejects this slot alone.
106
+ */
107
+ formState?: Promise<ReactFormState>;
100
108
  }
101
109
 
102
110
  /**
@@ -0,0 +1,25 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ const IS_BROWSER = typeof window !== "undefined";
4
+
5
+ /**
6
+ * What a RouteContentWrapper receives as `content`.
7
+ *
8
+ * Browser: hand over the settled value. React treats a promise as fulfilled
9
+ * only after it has read it, and a boundary on screen handed a promise it has
10
+ * not read, in a render that cannot wait, shows its fallback. A component that
11
+ * is already a promise (a Flight-streamed segment) is returned as is, since
12
+ * it can really be pending.
13
+ *
14
+ * Server: a fresh `Promise.resolve(component)` per call, so Suspense emits the
15
+ * fallback in the streamed HTML. A shared resolved promise would carry React's
16
+ * `.status` across requests and skip it.
17
+ *
18
+ * @internal
19
+ */
20
+ export function getBoundaryContent(component: ReactNode): ReactNode {
21
+ if (component instanceof Promise || IS_BROWSER) {
22
+ return component;
23
+ }
24
+ return Promise.resolve(component);
25
+ }
@@ -2,36 +2,30 @@ import type { ResolvedSegment } from "./types.js";
2
2
  import { INTERNAL_RANGO_DEBUG } from "./internal-debug.js";
3
3
 
4
4
  /**
5
- * Cache of aggregate Promise.all results keyed on the first loader's
6
- * `loaderData` reference. Each entry holds the source refs it was built from
7
- * plus the resulting Promise/array; lookup scans entries for the matching
8
- * source array (typically a single entry, since distinct loader groups rarely
9
- * share a first source). Object first-refs live in a WeakMap (auto-GC);
10
- * primitive first-refs (strings/numbers/booleans/null) live in a Map so
11
- * loaders that resolve to primitive data are memoized too. The per-key array
12
- * is capped (MAX_ENTRIES_PER_KEY, oldest evicted) so a long session under a
13
- * stable first-ref does not grow it without bound.
5
+ * Browser: a boundary is handed the settled value. React treats a promise as
6
+ * fulfilled only after it has read it, and a boundary on screen handed a
7
+ * promise it has not read, in a render that cannot wait, shows its fallback.
8
+ * So a cache entry records its array once the aggregate fulfils and later
9
+ * calls return that same array; until then (and when it rejects) they return
10
+ * the promise. A boundary with no loaders gets one shared empty array.
14
11
  *
15
- * Keying externally means reconciliation's fresh segment objects no longer
16
- * drop memoization — the cache survives as long as the underlying loader
17
- * segments do, and GC collects entries when those loaders are released
18
- * (object keys only).
12
+ * Server: a fresh promise per call, so Suspense emits the fallback in the
13
+ * streamed HTML. A shared resolved promise would carry React's `.status`
14
+ * across requests and skip it.
19
15
  *
20
- * Browser-only. On the server each SSR render needs a fresh Promise so
21
- * Suspense can actually suspend and emit the loading fallback HTML before
22
- * content streams. A shared already-resolved promise has `.status` attached
23
- * by React on first `use()`; subsequent observations return synchronously
24
- * and skip the fallback. The zero-loader case is especially prone because
25
- * every empty-loader site would otherwise share one promise across requests.
16
+ * Entries are keyed on the first loader's `loaderData` reference (object refs
17
+ * in a WeakMap, primitives in a Map), so reconciliation's fresh segment
18
+ * objects keep hitting them. Each key's entry list is capped (see
19
+ * MAX_ENTRIES_PER_KEY).
26
20
  */
27
21
  const IS_BROWSER = typeof window !== "undefined";
28
22
 
29
23
  interface LoaderCacheEntry {
30
24
  sources: any[];
31
- // buildLoaderPromise always returns a Promise, so the cached value is never a
32
- // bare array. The public getMemoizedLoaderPromise return type stays broader
33
- // (Promise<any[]> | any[]) to mirror its siblings.
25
+ // Resolves to the same array it records in `value`; a rejection passes
26
+ // through untouched.
34
27
  promise: Promise<any[]>;
28
+ value?: any[];
35
29
  }
36
30
 
37
31
  // Cap the per-key entries array. A stable first-ref (e.g. a layout loader whose
@@ -51,12 +45,8 @@ const primitiveLoaderCache = IS_BROWSER
51
45
  ? new Map<unknown, LoaderCacheEntry[]>()
52
46
  : null;
53
47
 
54
- // In the browser, a single shared empty aggregate is safe (and desirable) —
55
- // reusing the same resolved promise keeps React's `use()` in a known-fulfilled
56
- // state across renders. On the server it would leak `.status = "fulfilled"`
57
- // across requests and skip the Suspense fallback, so we rebuild on each call.
58
- const SHARED_EMPTY_LOADER_PROMISE: Promise<any[]> | null = IS_BROWSER
59
- ? Promise.resolve([])
48
+ const SHARED_EMPTY_LOADERS: any[] | null = IS_BROWSER
49
+ ? (Object.freeze([]) as unknown as any[])
60
50
  : null;
61
51
 
62
52
  function hasSameReferences(a: any[], b: any[]): boolean {
@@ -117,12 +107,8 @@ function isObjectLike(value: unknown): value is object {
117
107
  }
118
108
 
119
109
  /**
120
- * Memoize an aggregate Promise.all for a set of loader segments. Reusing the
121
- * same aggregate across renders — invalidated only when any underlying
122
- * loader.loaderData ref changes — keeps React's `use()` in "known fulfilled"
123
- * state and prevents a fresh Promise.all from suspending (and briefly
124
- * committing the Suspense fallback) on every partial update that doesn't
125
- * actually change loader data.
110
+ * The aggregate for a set of loader segments, memoized on their `loaderData`
111
+ * refs: the settled array once it has fulfilled, the promise before that.
126
112
  *
127
113
  * @internal
128
114
  */
@@ -130,7 +116,7 @@ export function getMemoizedLoaderPromise(
130
116
  loaders: ResolvedSegment[],
131
117
  ): Promise<any[]> | any[] {
132
118
  if (loaders.length === 0) {
133
- return SHARED_EMPTY_LOADER_PROMISE ?? buildLoaderPromise(loaders);
119
+ return SHARED_EMPTY_LOADERS ?? buildLoaderPromise(loaders);
134
120
  }
135
121
  if (!objectLoaderCache || !primitiveLoaderCache) {
136
122
  return buildLoaderPromise(loaders);
@@ -145,13 +131,17 @@ export function getMemoizedLoaderPromise(
145
131
  if (entries) {
146
132
  for (const entry of entries) {
147
133
  if (hasSameReferences(entry.sources, sources)) {
148
- return entry.promise;
134
+ return entry.value ?? entry.promise;
149
135
  }
150
136
  }
151
137
  }
152
138
 
153
- const promise = buildLoaderPromise(loaders);
154
- const newEntry: LoaderCacheEntry = { sources, promise };
139
+ const newEntry = { sources } as LoaderCacheEntry;
140
+ const promise = buildLoaderPromise(loaders).then((values) => {
141
+ newEntry.value = values;
142
+ return values;
143
+ });
144
+ newEntry.promise = promise;
155
145
  if (entries) {
156
146
  // Bound the array: drop the oldest entry before appending when at the cap.
157
147
  if (entries.length >= MAX_ENTRIES_PER_KEY) {