@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
@@ -6,6 +6,7 @@ import {
6
6
  import {
7
7
  createNavigationStore,
8
8
  generateHistoryKey,
9
+ liveHandleDataForCache,
9
10
  } from "./navigation-store.js";
10
11
  import { createEventController } from "./event-controller.js";
11
12
  import { resolveDeferredHandleValues } from "../handles/deferred-resolution.js";
@@ -27,11 +28,14 @@ import { expandPayloadFragments } from "../segment-fragments.js";
27
28
  import { initRangoState } from "./rango-state.js";
28
29
  import { registerNavigationStore } from "./navigation-store-handle.js";
29
30
  import { initPrefetchCache } from "./prefetch/cache.js";
31
+ import {
32
+ auditTreeCause,
33
+ setSuspenseAuditStreamProbe,
34
+ } from "../suspense-audit.js";
30
35
  import {
31
36
  setPrefetchConcurrency,
32
37
  setPrefetchDecoder,
33
38
  } from "./prefetch/loader.js";
34
- import { setDefaultPrefetchStrategy } from "./prefetch/default-strategy.js";
35
39
  import { setAppVersion } from "./app-version.js";
36
40
  import {
37
41
  isInterceptSegment,
@@ -147,6 +151,12 @@ export interface BrowserAppContext {
147
151
  * prefetch TTL are document-lifetime too (see AppShell).
148
152
  */
149
153
  appShellRef?: import("./app-shell.js").AppShellRef;
154
+ /**
155
+ * Resolves when the document's stream has ended and every Suspense boundary
156
+ * in it has been revealed. NavigationProvider closes the hydration window
157
+ * then (NavigationProviderProps.hydration).
158
+ */
159
+ documentRevealed: Promise<void>;
150
160
  }
151
161
 
152
162
  // Module-level state for the initialized app
@@ -255,6 +265,9 @@ export async function initBrowserApp(
255
265
  const eventController = createEventController({
256
266
  initialLocation: new URL(window.location.href),
257
267
  });
268
+ if (process.env.NODE_ENV !== "production") {
269
+ setSuspenseAuditStreamProbe(() => eventController.getState().isStreaming);
270
+ }
258
271
 
259
272
  // The document's entry, after the clearOnReload pass and under the version
260
273
  // set above: what readers get once they have hydrated.
@@ -307,11 +320,9 @@ export async function initBrowserApp(
307
320
  if (lateGenerator) {
308
321
  void (async () => {
309
322
  try {
310
- // Applied once the root has hydrated (see hydrationCommitted): the
311
- // readers that hydrated with it are subscribed by then and take
312
- // the update as an ordinary one. Yields buffer in the generator
313
- // meanwhile; nothing is lost.
314
- await hydrationCommitted;
323
+ // Applied as they arrive: during the hydration window (lockHydration
324
+ // below) the store keeps them in order, and readers see them after
325
+ // the commit that hydrates every boundary (NavigationProvider).
315
326
  for await (const rawLateData of lateGenerator) {
316
327
  // Handles are async by design: a push(promise) value resolves
317
328
  // whenever it resolves — only SSR'd handles are awaited server-
@@ -337,10 +348,11 @@ export async function initBrowserApp(
337
348
  } else {
338
349
  bootLog("handles: none in payload");
339
350
  }
340
- // What every reader hydrates with, whenever its boundary hydrates
341
- // (useHandle): the late channel above changes the live state once the root
342
- // has hydrated, and a navigation can before a streamed boundary has.
343
- eventController.freezeHydrationHandleState();
351
+ // The hydration window: until NavigationProvider's barrier commit has
352
+ // hydrated every boundary, reads return the handle state the document's HTML
353
+ // was rendered with, and the late channel's and any navigation's handle
354
+ // writes wait in order (EventController.lockHydration).
355
+ eventController.lockHydration();
344
356
 
345
357
  // Create composable utilities
346
358
  const client = createNavigationClient(deps);
@@ -371,13 +383,10 @@ export async function initBrowserApp(
371
383
  if (prefetchConcurrency !== undefined) {
372
384
  setPrefetchConcurrency(prefetchConcurrency);
373
385
  }
374
- // Apply the router-wide default Link prefetch strategy. Undefined (older
375
- // server payload) keeps the module's environment-aware default, which equals
376
- // the server resolver's default by contract — see default-strategy.ts.
386
+ // The router-wide default Link prefetch strategy, for delegated plain
387
+ // anchors (navigation bridge). Undefined falls back to the environment
388
+ // default (default-strategy.ts).
377
389
  const defaultPrefetch = initialPayload.metadata?.defaultPrefetch;
378
- if (defaultPrefetch !== undefined) {
379
- setDefaultPrefetchStrategy(defaultPrefetch);
380
- }
381
390
 
382
391
  // Wire the RSC decoder so prefetches decode eagerly and warm the route's
383
392
  // client chunks (same createFromFetch the navigation client uses). Fragment
@@ -580,7 +589,11 @@ export async function initBrowserApp(
580
589
  intercept: responseIsIntercept,
581
590
  });
582
591
  store.setHistoryKey(historyKey);
583
- const currentHandleData = eventController.getHandleState().data;
592
+ const currentHandleData = liveHandleDataForCache(
593
+ eventController,
594
+ store,
595
+ historyKey,
596
+ );
584
597
  store.cacheSegmentsForHistory(
585
598
  historyKey,
586
599
  segments,
@@ -588,6 +601,7 @@ export async function initBrowserApp(
588
601
  );
589
602
 
590
603
  const { main, intercept } = splitInterceptSegments(segments);
604
+ if (process.env.NODE_ENV !== "production") auditTreeCause("hmr");
591
605
  store.emitUpdate({
592
606
  root: renderSegments(main, {
593
607
  interceptSegments: intercept.length > 0 ? intercept : undefined,
@@ -627,6 +641,28 @@ export async function initBrowserApp(
627
641
  linkInterceptionEnabled: linkInterception,
628
642
  version,
629
643
  appShellRef,
644
+ documentRevealed: new Promise<void>((resolve) => {
645
+ // Parsing ends with the stream, but React reveals a streamed boundary
646
+ // in batches up to 300 ms later (Fizz's $RC marks it `$~` and reveals
647
+ // it from a timer), so the boundary is still pending at
648
+ // DOMContentLoaded. A transition then would make React client-render
649
+ // it over its server HTML.
650
+ const start = (): void => {
651
+ // A reveal rewrites the same comment's data, or React removes the
652
+ // node; a boundary nested in revealed content joins the document
653
+ // only then, so resolve only once a full walk finds none.
654
+ let pending = unrevealedBoundaries();
655
+ const check = (): void => {
656
+ pending = pending.filter(isUnrevealed);
657
+ if (pending.length === 0) pending = unrevealedBoundaries();
658
+ if (pending.length > 0) requestAnimationFrame(check);
659
+ else resolve();
660
+ };
661
+ check();
662
+ };
663
+ if (document.readyState !== "loading") start();
664
+ else document.addEventListener("DOMContentLoaded", start, { once: true });
665
+ }),
630
666
  };
631
667
  browserAppContext = context;
632
668
 
@@ -634,27 +670,27 @@ export async function initBrowserApp(
634
670
  return context;
635
671
  }
636
672
 
673
+ /**
674
+ * A Suspense boundary of the document React has not revealed yet: Fizz marks
675
+ * one `$?` while its content streams and `$~` once its reveal is queued.
676
+ */
677
+ function isUnrevealed(node: Comment): boolean {
678
+ return node.isConnected && (node.data === "$?" || node.data === "$~");
679
+ }
680
+
681
+ function unrevealedBoundaries(): Comment[] {
682
+ const found: Comment[] = [];
683
+ const walker = document.createTreeWalker(document, NodeFilter.SHOW_COMMENT);
684
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
685
+ if (isUnrevealed(node as Comment)) found.push(node as Comment);
686
+ }
687
+ return found;
688
+ }
689
+
637
690
  // Once-flag so the hydration-commit boot log fires a single time (StrictMode
638
691
  // re-runs the root effect; the second flush is not a second hydration).
639
692
  let hydrationCommitLogged = false;
640
693
 
641
- // Hydration-commit barrier for the late handle channel: the late consumer
642
- // applies nothing before the root's effects have run. A hydrating render
643
- // reads the handle state frozen before hydrateRoot (useHandle,
644
- // EventController.getHydrationHandleState), so an earlier application would
645
- // no longer mismatch (it did while the initializer read the LIVE state: seen
646
- // when a loader's push lost the handler-barrier race by milliseconds and its
647
- // late yield landed mid-hydration). What the barrier still buys: a reader
648
- // that hydrates after the live state moved on is rendered a second time
649
- // right after, since React re-renders a reader whose hydration state is not
650
- // the client one. Readers that hydrate with the root would all pay that;
651
- // after the commit they are subscribed and take the update as an ordinary
652
- // one. The root effect below resolves this.
653
- let resolveHydrationCommitted!: () => void;
654
- const hydrationCommitted: Promise<void> = new Promise((resolve) => {
655
- resolveHydrationCommitted = resolve;
656
- });
657
-
658
694
  /**
659
695
  * Get the browser app context. Throws if initBrowserApp hasn't been called.
660
696
  */
@@ -691,13 +727,18 @@ export interface RangoProps {}
691
727
  * import * as rscBrowser from "@vitejs/plugin-rsc/browser";
692
728
  *
693
729
  * async function main() {
694
- * await initBrowserApp({ rscStream, deps: rscBrowser });
730
+ * const { initialPayload } = await initBrowserApp({ rscStream, deps: rscBrowser });
731
+ * // Rejects when the action's state is not Flight-serializable.
732
+ * const formState = await Promise.resolve(initialPayload.formState).catch(
733
+ * () => undefined,
734
+ * );
695
735
  *
696
736
  * hydrateRoot(
697
737
  * document,
698
738
  * <React.StrictMode>
699
739
  * <Rango />
700
- * </React.StrictMode>
740
+ * </React.StrictMode>,
741
+ * formState ? { formState } : undefined
701
742
  * );
702
743
  * }
703
744
  * main();
@@ -716,6 +757,7 @@ export function Rango(_props: RangoProps): React.ReactElement {
716
757
  version,
717
758
  appShellRef,
718
759
  linkInterceptionEnabled,
760
+ documentRevealed,
719
761
  } = getBrowserAppContext();
720
762
 
721
763
  // Signal that the React tree has hydrated. useEffect only fires after
@@ -730,9 +772,6 @@ export function Rango(_props: RangoProps): React.ReactElement {
730
772
  hydrationCommitLogged = true;
731
773
  bootLog("hydration commit (root effect flushed)");
732
774
  }
733
- // Release the late handle channel (see hydrationCommitted above).
734
- // StrictMode double-invoke: resolving twice is a no-op.
735
- resolveHydrationCommitted();
736
775
  return cleanupPrefetch;
737
776
  }, [bridge, linkInterceptionEnabled]);
738
777
 
@@ -748,6 +787,7 @@ export function Rango(_props: RangoProps): React.ReactElement {
748
787
  version={version}
749
788
  basename={initialPayload.metadata?.basename}
750
789
  appShellRef={appShellRef}
790
+ hydration={{ settled: documentRevealed }}
751
791
  />
752
792
  );
753
793
  }
@@ -201,9 +201,9 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
201
201
  // inside renderSegments, causing React to unmount the entire chain
202
202
  // (LoaderBoundary > Suspense > LoaderResolver > RouteContentWrapper >
203
203
  // Suspender) every time the user opened an intercept or navigated back
204
- // to a cached page. The flicker is now prevented by renderSegments'
205
- // promise memoization keeping React's use() in "known fulfilled" state,
206
- // so preserving `loading` keeps the element tree stable.
204
+ // to a cached page. The flicker is now prevented by renderSegments
205
+ // handing the boundary settled content and loader data, so preserving
206
+ // `loading` keeps the element tree stable.
207
207
  return fromCache;
208
208
  })
209
209
  .filter(Boolean) as ResolvedSegment[];
@@ -13,6 +13,7 @@ import {
13
13
  reconcileErrorSegments,
14
14
  } from "./segment-reconciler.js";
15
15
  import { startTransition } from "react";
16
+ import { liveHandleDataForCache } from "./navigation-store.js";
16
17
  import type { EventController, ActionHandle } from "./event-controller.js";
17
18
  import {
18
19
  toNetworkError,
@@ -46,6 +47,7 @@ import {
46
47
  type TransitionWhenActionInput,
47
48
  } from "./transition-when.js";
48
49
  import { CLIENT_REVALIDATION_HEADER } from "../client-urls/revalidation-protocol.js";
50
+ import { auditTreeCause } from "../suspense-audit.js";
49
51
 
50
52
  // Polyfill Symbol.dispose/asyncDispose for Safari and older browsers
51
53
  if (typeof Symbol.dispose === "undefined") {
@@ -648,7 +650,11 @@ export function createServerActionBridge(
648
650
  // handle pushed by the error-boundary render still applies — see the
649
651
  // "normal" case below for why caching after onUpdate dropped it.
650
652
  store.setSegmentIds(segmentIdsAfterError);
651
- const currentHandleData = eventController.getHandleState().data;
653
+ const currentHandleData = liveHandleDataForCache(
654
+ eventController,
655
+ store,
656
+ currentKey,
657
+ );
652
658
  store.cacheSegmentsForHistory(
653
659
  currentKey,
654
660
  errorResult.segments,
@@ -659,6 +665,7 @@ export function createServerActionBridge(
659
665
  // Update UI with error boundary: urgent when transition({ when })
660
666
  // gated it off, like every gated-off commit.
661
667
  const errorUpdate = { root: errorTree, metadata: metadata! };
668
+ if (process.env.NODE_ENV !== "production") auditTreeCause("action");
662
669
  if (errorGatedOff) onUpdate(errorUpdate);
663
670
  else startTransition(() => onUpdate(errorUpdate));
664
671
 
@@ -850,7 +857,11 @@ export function createServerActionBridge(
850
857
  // Location state already applied above (pre-switch); this action's
851
858
  // UI render is skipped because a later sibling consolidates.
852
859
  store.setSegmentIds(matched);
853
- const currentHandleData = eventController.getHandleState().data;
860
+ const currentHandleData = liveHandleDataForCache(
861
+ eventController,
862
+ store,
863
+ currentKey,
864
+ );
854
865
  store.cacheSegmentsForHistory(
855
866
  currentKey,
856
867
  fullSegments,
@@ -862,9 +873,13 @@ export function createServerActionBridge(
862
873
 
863
874
  case "normal": {
864
875
  // transition({ when }) for the action commit (kind "action"): the
865
- // page does not move, so `to` is `from`. False commits urgently (a
866
- // re-suspending segment streams its loading()) and switches every
867
- // <ViewTransition> class to "none"; true keeps the startTransition.
876
+ // page does not move, so `to` is `from`. False commits urgently and
877
+ // switches every <ViewTransition> class to "none"; the tree below
878
+ // is built with isAction, which awaits segment content and loader
879
+ // data, so a boundary on screen is handed settled values. A build
880
+ // then shows no loading(); the dev server still does, because the
881
+ // client references of a fresh payload are still importing in that
882
+ // first render. True keeps the startTransition.
868
883
  // This commit runs after awaits, outside the transition React opens
869
884
  // for a form action / useActionState call, so React does not force
870
885
  // a transition on it. The tree shape never changes, so
@@ -910,7 +925,11 @@ export function createServerActionBridge(
910
925
  //
911
926
  // Location state already applied above (pre-switch). Update store.
912
927
  store.setSegmentIds(matched);
913
- const currentHandleData = eventController.getHandleState().data;
928
+ const currentHandleData = liveHandleDataForCache(
929
+ eventController,
930
+ store,
931
+ currentKey,
932
+ );
914
933
  store.cacheSegmentsForHistory(
915
934
  currentKey,
916
935
  fullSegments,
@@ -919,6 +938,7 @@ export function createServerActionBridge(
919
938
  store.rememberDisplayedEntry(metadata?.routeName);
920
939
 
921
940
  const actionUpdate = { root: newTree, metadata: metadata! };
941
+ if (process.env.NODE_ENV !== "production") auditTreeCause("action");
922
942
  if (gatedOff) onUpdate(actionUpdate);
923
943
  else startTransition(() => onUpdate(actionUpdate));
924
944
  // Invalidation deferred to finalizeAction() (runs after this caches
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode, ComponentType } from "react";
2
+ import type { ReactFormState } from "react-dom/client";
2
3
  import type { ResolvedSegment, SlotState } from "../types.js";
3
4
  import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
4
5
  import type { RenderSegmentsOptions } from "../segment-system.js";
@@ -14,6 +15,11 @@ import type { RenderSegmentsOptions } from "../segment-system.js";
14
15
  export interface RscPayload<TMetadata = RscMetadata> {
15
16
  metadata?: TMetadata;
16
17
  returnValue?: ActionResult;
18
+ /**
19
+ * Form state of a PE POST document, for hydrateRoot's `formState` option.
20
+ * Rejects when the action's state is not Flight-serializable.
21
+ */
22
+ formState?: Promise<ReactFormState>;
17
23
  }
18
24
 
19
25
  /**
@@ -459,6 +465,8 @@ export interface NavigateOptionsInternal extends Omit<
459
465
  state?: unknown;
460
466
  /** Skip segment cache (used by redirect-with-state to force re-render) */
461
467
  _skipCache?: boolean;
468
+ /** Server redirects already followed by this navigation chain (0 for a user navigation) */
469
+ _redirectHops?: number;
462
470
  }
463
471
 
464
472
  /**
@@ -554,6 +562,9 @@ export type StateListener = () => void;
554
562
  * EventController owns the public navigation lifecycle exposed by hooks.
555
563
  */
556
564
  export interface NavigationStore {
565
+ /** Client loader data for useLoader / useFetchLoader (loader-store.ts). */
566
+ readonly loaders: import("../loader-store.js").LoaderStore;
567
+
557
568
  // Internal segment state (for bridges)
558
569
  getSegmentState(): SegmentState;
559
570
  setPath(path: string): void;
@@ -61,6 +61,8 @@ export interface GeneratedManifest {
61
61
  prerenderRoutes?: string[];
62
62
  /** Route names wrapped with Passthrough() (live handler for runtime fallback) */
63
63
  passthroughRoutes?: string[];
64
+ /** Route names opted into on-demand prerender (Prerender(..., { onDemand })) */
65
+ onDemandRoutes?: string[];
64
66
  /** Route name → response type for non-RSC routes */
65
67
  responseTypeRoutes?: Record<string, string>;
66
68
  /** Route name -> search schema descriptor for typed URL helpers */
@@ -172,6 +174,7 @@ async function buildPrefixTreeNode(
172
174
  prerenderRoutes?: string[],
173
175
  prerenderDefs?: Record<string, any>,
174
176
  passthroughRoutes?: string[],
177
+ onDemandRoutes?: string[],
175
178
  responseTypeRoutes?: Record<string, string>,
176
179
  routeSearchSchemas?: Record<string, Record<string, string>>,
177
180
  routerId?: string,
@@ -264,6 +267,13 @@ async function buildPrefixTreeNode(
264
267
  passthroughRoutes.push(name);
265
268
  }
266
269
  }
270
+ if (
271
+ onDemandRoutes &&
272
+ entry.type === "route" &&
273
+ entry.isOnDemand === true
274
+ ) {
275
+ onDemandRoutes.push(name);
276
+ }
267
277
  }
268
278
  }
269
279
 
@@ -289,6 +299,7 @@ async function buildPrefixTreeNode(
289
299
  prerenderRoutes,
290
300
  prerenderDefs,
291
301
  passthroughRoutes,
302
+ onDemandRoutes,
292
303
  responseTypeRoutes,
293
304
  routeSearchSchemas,
294
305
  routerId,
@@ -455,6 +466,7 @@ export async function generateManifestFull<TEnv>(
455
466
  const prerenderRoutes: string[] = [];
456
467
  const prerenderDefs: Record<string, any> = {};
457
468
  const passthroughRoutes: string[] = [];
469
+ const onDemandRoutes: string[] = [];
458
470
  const responseTypeRoutes: Record<string, string> = {};
459
471
  for (const [name, entry] of manifest) {
460
472
  if (entry.type === "route" && entry.isPrerender) {
@@ -465,6 +477,9 @@ export async function generateManifestFull<TEnv>(
465
477
  if (entry.isPassthrough === true) {
466
478
  passthroughRoutes.push(name);
467
479
  }
480
+ if (entry.isOnDemand === true) {
481
+ onDemandRoutes.push(name);
482
+ }
468
483
  }
469
484
  if (entry.type === "route" && entry.responseType) {
470
485
  responseTypeRoutes[name] = entry.responseType;
@@ -485,6 +500,7 @@ export async function generateManifestFull<TEnv>(
485
500
  prerenderRoutes,
486
501
  prerenderDefs,
487
502
  passthroughRoutes,
503
+ onDemandRoutes,
488
504
  responseTypeRoutes,
489
505
  routeSearchSchemas,
490
506
  options?.routerId,
@@ -502,6 +518,7 @@ export async function generateManifestFull<TEnv>(
502
518
  prerenderRoutes: prerenderRoutes.length > 0 ? prerenderRoutes : undefined,
503
519
  passthroughRoutes:
504
520
  passthroughRoutes.length > 0 ? passthroughRoutes : undefined,
521
+ onDemandRoutes: onDemandRoutes.length > 0 ? onDemandRoutes : undefined,
505
522
  responseTypeRoutes:
506
523
  Object.keys(responseTypeRoutes).length > 0
507
524
  ? responseTypeRoutes
@@ -136,6 +136,11 @@ export function mergeFullManifests(
136
136
  manifest.passthroughRoutes,
137
137
  overwrittenRoutes,
138
138
  ),
139
+ onDemandRoutes: mergeRouteList(
140
+ merged.onDemandRoutes,
141
+ manifest.onDemandRoutes,
142
+ overwrittenRoutes,
143
+ ),
139
144
  responseTypeRoutes: mergeRouteRecord(
140
145
  merged.responseTypeRoutes,
141
146
  manifest.responseTypeRoutes,
@@ -50,5 +50,6 @@ export function buildPerRouterTrie(manifest: FullManifest): TrieNode | null {
50
50
  ? new Set(manifest.passthroughRoutes)
51
51
  : undefined,
52
52
  manifest.responseTypeRoutes,
53
+ manifest.onDemandRoutes ? new Set(manifest.onDemandRoutes) : undefined,
53
54
  );
54
55
  }
@@ -7,6 +7,8 @@
7
7
  * skipping.
8
8
  */
9
9
 
10
+ import type { ExecutionContext } from "../types/request-scope.js";
11
+
10
12
  interface WaitUntilHost {
11
13
  waitUntil?: (fn: () => Promise<void>) => void;
12
14
  }
@@ -85,3 +87,62 @@ export async function settleGrowing(
85
87
  }
86
88
  return true;
87
89
  }
90
+
91
+ /** Macrotask turns `awaitLaterMillisecond` waits at most. */
92
+ export const LATER_MILLISECOND_MAX_TURNS = 10;
93
+
94
+ /**
95
+ * Yield until `Date.now()` passes `since`, so a store's whole-millisecond tag
96
+ * gate (putShell vs an invalidation made just before) sees a later
97
+ * millisecond. Bounded: on Cloudflare the clock stands still between I/O
98
+ * events, so an unbounded wait could hang. At the bound the caller proceeds
99
+ * and a store may refuse the write, which its result reports.
100
+ */
101
+ export async function awaitLaterMillisecond(
102
+ since: number,
103
+ maxTurns: number = LATER_MILLISECOND_MAX_TURNS,
104
+ ): Promise<void> {
105
+ for (let turn = 0; turn < maxTurns && Date.now() === since; turn++) {
106
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
107
+ }
108
+ }
109
+
110
+ /** An ExecutionContext that keeps every promise handed to `waitUntil`. */
111
+ export interface CollectingExecutionContext extends ExecutionContext {
112
+ readonly tasks: Promise<unknown>[];
113
+ }
114
+
115
+ /**
116
+ * Wrap the caller's ExecutionContext (or none) so a caller can wait for the
117
+ * request's background work: the deferred cache writes, the shell capture, a
118
+ * store's own writes. Every other member reads through to `inner`, bound to
119
+ * it (workerd's methods reject a foreign `this`).
120
+ */
121
+ export function createCollectingExecutionContext(
122
+ inner?: ExecutionContext,
123
+ ): CollectingExecutionContext {
124
+ const tasks: Promise<unknown>[] = [];
125
+ const own: CollectingExecutionContext = {
126
+ tasks,
127
+ waitUntil(promise) {
128
+ const task = Promise.resolve(promise);
129
+ // The list only waits; the host (or the log below) owns the failure.
130
+ tasks.push(task.catch(() => {}));
131
+ if (inner) inner.waitUntil(task);
132
+ else {
133
+ task.catch((error) =>
134
+ console.error("[waitUntil] Background task failed:", error),
135
+ );
136
+ }
137
+ },
138
+ passThroughOnException() {},
139
+ };
140
+ if (!inner) return own;
141
+ return new Proxy(own, {
142
+ get(target, prop) {
143
+ if (prop in target) return target[prop as keyof typeof target];
144
+ const value = (inner as unknown as Record<PropertyKey, unknown>)[prop];
145
+ return typeof value === "function" ? value.bind(inner) : value;
146
+ },
147
+ });
148
+ }
@@ -22,7 +22,7 @@ import type { SearchParamsFilter } from "./search-params-filter.js";
22
22
  */
23
23
  const RESERVED_SEARCH_PARAMS = new Set(["__no_cache", "__rsc", "__html"]);
24
24
 
25
- function isReservedSearchParam(key: string): boolean {
25
+ export function isReservedSearchParam(key: string): boolean {
26
26
  return key.startsWith("_rsc") || RESERVED_SEARCH_PARAMS.has(key);
27
27
  }
28
28
 
@@ -65,20 +65,37 @@ export function sortedRouteParams(
65
65
  }
66
66
 
67
67
  /**
68
- * Host-namespaced cache key base: `${host}${pathname}[:params][?search]`.
68
+ * THE ROUTER RULE (#1065). A key that names one router's output for a host
69
+ * and path starts with the router part, `{encodeURIComponent(routerId)}@`. A
70
+ * key that names a function and its arguments, or a tag, does not.
69
71
  *
70
- * The ONE composition of the host-namespacing rule, shared by the segment tier
71
- * (cache-scope.ts) and the document tier (document-cache.ts) so the rule cannot
72
- * drift between them. Host prefixing matters because VercelCacheStore /
73
- * MemorySegmentCacheStore key by the raw string (only CFCacheStore adds host
74
- * internally) -- on a single function serving multiple domains an
75
- * un-namespaced key bleeds tenant A's cached response to tenant B.
72
+ * - With the part: PPR shells and the build-shell manifest (buildShellKey,
73
+ * buildShellManifestKey); route cache() records, the document cache and
74
+ * response routes (cacheKeyBase); a loader's own cache() entry; a
75
+ * `"use cache"` entry of a call that takes a ctx (a ctx is one router's:
76
+ * its variables, env and reverse() map).
77
+ * - Without: a `"use cache"` entry of plain arguments or a bare Request
78
+ * (requestArgKey); tag markers; a key the app builds itself
79
+ * (`cache({ key })`, a `keyGenerator`).
76
80
  *
77
- * Output is BYTE-STABLE by contract: changing the composition silently
78
- * invalidates every persisted cache entry on upgrade. Callers append their own
79
- * tier-specific suffixes (`:rsc`/`:html`, segment hash) after this base.
81
+ * Why: a host and path do not name a router. A `hostOverride` cookie picks
82
+ * the router and forwards the request unmodified, and a `router.prerender()`
83
+ * warm requests whatever origin it resolved, so two routers on one store
84
+ * built one key and read each other's entry.
85
+ *
86
+ * The id (router.ts) has to be the same in every isolate and in build-time
87
+ * discovery: an explicit `id` and the injected `$$id` are, the `router_{n}`
88
+ * fallback is for a single router only (discover-routers.ts warns about
89
+ * more). Encoded, the id holds no `@` or `/`, and a host holds no `@`, so a
90
+ * key has a router part exactly when its first `@` comes before its first
91
+ * `/`: no id can be chosen to spell another router's key.
80
92
  */
81
- export function cacheKeyBase(
93
+ export function routerKeyPrefix(routerId: string): string {
94
+ return `${encodeURIComponent(routerId)}@`;
95
+ }
96
+
97
+ /** `{host}{pathname}[:params][?search]`: what follows the router part. */
98
+ function hostPathKey(
82
99
  host: string,
83
100
  pathname: string,
84
101
  searchParams?: URLSearchParams,
@@ -96,6 +113,68 @@ export function cacheKeyBase(
96
113
  return key;
97
114
  }
98
115
 
116
+ /**
117
+ * Key base of one router's output (the router rule, routerKeyPrefix):
118
+ * `${routerId}@${host}${pathname}[:params][?search]`.
119
+ *
120
+ * The ONE composition of the namespacing rule, shared by the segment tier
121
+ * (cache-scope.ts), the document tier (document-cache.ts), the response tier
122
+ * (rsc/response-cache-serve.ts) and the loader cache so the rule cannot drift
123
+ * between them. Host prefixing matters because VercelCacheStore /
124
+ * MemorySegmentCacheStore key by the raw string (only CFCacheStore adds host
125
+ * internally) -- on a single function serving multiple domains an
126
+ * un-namespaced key bleeds tenant A's cached response to tenant B.
127
+ *
128
+ * Output is BYTE-STABLE by contract: changing the composition silently
129
+ * invalidates every persisted cache entry on upgrade. Callers append their own
130
+ * tier-specific suffixes (`:rsc`/`:html`, segment hash) after this base.
131
+ */
132
+ export function cacheKeyBase(
133
+ routerId: string,
134
+ host: string,
135
+ pathname: string,
136
+ searchParams?: URLSearchParams,
137
+ params?: Record<string, string>,
138
+ filter?: SearchParamsFilter,
139
+ ): string {
140
+ return `${routerKeyPrefix(routerId)}${hostPathKey(host, pathname, searchParams, params, filter)}`;
141
+ }
142
+
143
+ /**
144
+ * cacheKeyBase for the request context `ctx`. rsc/handler.ts sets `_routerId`
145
+ * before the request scope opens and derived contexts inherit it, so a served
146
+ * request always has one. Only a hand-built context has none (a unit test,
147
+ * the runLoader / renderHandler primitives): its key has no router part,
148
+ * which no router's key equals (routerKeyPrefix).
149
+ */
150
+ export function requestKeyBase(
151
+ ctx: { readonly _routerId?: string } | null | undefined,
152
+ host: string,
153
+ pathname: string,
154
+ searchParams?: URLSearchParams,
155
+ params?: Record<string, string>,
156
+ filter?: SearchParamsFilter,
157
+ ): string {
158
+ const routerId = ctx?._routerId;
159
+ return routerId === undefined
160
+ ? hostPathKey(host, pathname, searchParams, params, filter)
161
+ : cacheKeyBase(routerId, host, pathname, searchParams, params, filter);
162
+ }
163
+
164
+ /**
165
+ * The `"use cache"` key part of a bare Request argument: its URL, with no
166
+ * router part (the router rule, routerKeyPrefix).
167
+ */
168
+ export function requestArgKey(url: URL, filter?: SearchParamsFilter): string {
169
+ return hostPathKey(
170
+ url.host,
171
+ url.pathname,
172
+ url.searchParams,
173
+ undefined,
174
+ filter,
175
+ );
176
+ }
177
+
99
178
  /**
100
179
  * The prefix of a `key()` result's part in a record key (#975), and of a
101
180
  * loader's own `key()` result after its `loader:<id>:` namespace (#1009,