@rangojs/router 0.20.0 → 0.21.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 (178) hide show
  1. package/dist/bin/rango.js +26 -14
  2. package/dist/types/browser/event-controller.d.ts +32 -0
  3. package/dist/types/browser/history-state.d.ts +16 -4
  4. package/dist/types/browser/navigation-transaction.d.ts +7 -0
  5. package/dist/types/browser/rango-state.d.ts +3 -1
  6. package/dist/types/browser/react/context.d.ts +11 -0
  7. package/dist/types/browser/react/location-state-shared.d.ts +84 -10
  8. package/dist/types/browser/react/location-state.d.ts +7 -2
  9. package/dist/types/browser/react/subscribe-to-nothing.d.ts +7 -0
  10. package/dist/types/cache/cache-scope.d.ts +4 -5
  11. package/dist/types/cache/cf/cf-cache-store.d.ts +27 -6
  12. package/dist/types/cache/cf/cf-cache-types.d.ts +12 -4
  13. package/dist/types/cache/handle-snapshot.d.ts +36 -50
  14. package/dist/types/cache/index.d.ts +2 -0
  15. package/dist/types/cache/shell-snapshot.d.ts +19 -3
  16. package/dist/types/cache/types.d.ts +21 -10
  17. package/dist/types/cache/vercel/vercel-cache-store.d.ts +28 -16
  18. package/dist/types/client-urls/navigation.d.ts +7 -1
  19. package/dist/types/client-urls/optimistic-location.d.ts +10 -4
  20. package/dist/types/handles/deferred-resolution.d.ts +11 -1
  21. package/dist/types/prerender/build-shell-capture.d.ts +8 -6
  22. package/dist/types/router/match-middleware/cache-lookup.d.ts +1 -1
  23. package/dist/types/router/navigation-snapshot.d.ts +8 -1
  24. package/dist/types/router/router-interfaces.d.ts +2 -1
  25. package/dist/types/router/router-options.d.ts +17 -7
  26. package/dist/types/router/segment-resolution/loader-cache.d.ts +21 -9
  27. package/dist/types/router-versions.d.ts +39 -0
  28. package/dist/types/rsc/handler-context.d.ts +5 -0
  29. package/dist/types/rsc/shell-build-manifest.d.ts +3 -2
  30. package/dist/types/rsc/types.d.ts +4 -6
  31. package/dist/types/server/build-version-table.d.ts +44 -0
  32. package/dist/types/server/handle-store.d.ts +87 -46
  33. package/dist/types/server/request-context.d.ts +22 -8
  34. package/dist/types/testing/build-versions.d.ts +32 -0
  35. package/dist/types/testing/dom.entry.d.ts +1 -1
  36. package/dist/types/testing/flight.entry.d.ts +1 -1
  37. package/dist/types/testing/index.d.ts +2 -0
  38. package/dist/types/testing/internal/seed-vars.d.ts +7 -2
  39. package/dist/types/testing/location-state-key.d.ts +5 -1
  40. package/dist/types/testing/render-route.d.ts +77 -6
  41. package/dist/types/testing/serve-shell-request.d.ts +21 -0
  42. package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
  43. package/dist/types/types/handler-context.d.ts +8 -5
  44. package/dist/types/vite/discovery/build-versions.d.ts +393 -0
  45. package/dist/types/vite/discovery/installed-packages.d.ts +26 -0
  46. package/dist/types/vite/discovery/router-versions-phase.d.ts +52 -0
  47. package/dist/types/vite/discovery/shell-prerender-phase.d.ts +7 -17
  48. package/dist/types/vite/discovery/state.d.ts +14 -0
  49. package/dist/types/vite/encryption-key.d.ts +18 -0
  50. package/dist/types/vite/plugin-types.d.ts +27 -0
  51. package/dist/types/vite/plugins/{version-injector.d.ts → entry-bootstrap-injector.d.ts} +4 -6
  52. package/dist/types/vite/plugins/expose-ids/export-analysis.d.ts +1 -0
  53. package/dist/types/vite/plugins/expose-ids/router-transform.d.ts +14 -0
  54. package/dist/types/vite/plugins/stable-rsc-output.d.ts +51 -0
  55. package/dist/types/vite/plugins/version-plugin.d.ts +9 -11
  56. package/dist/types/vite/plugins/virtual-entries.d.ts +18 -1
  57. package/dist/types/vite/router-discovery.d.ts +6 -0
  58. package/dist/types/vite/utils/client-chunks.d.ts +2 -0
  59. package/dist/types/vite/utils/compare-strings.d.ts +7 -0
  60. package/dist/types/vite/utils/package-resolution.d.ts +8 -0
  61. package/dist/types/vite/utils/prerender-utils.d.ts +2 -0
  62. package/dist/vite/index.js +1537 -444
  63. package/package.json +1 -2
  64. package/skills/cache-guide/SKILL.md +84 -20
  65. package/skills/caching/SKILL.md +16 -5
  66. package/skills/client-urls/SKILL.md +16 -0
  67. package/skills/cloudflare/SKILL.md +2 -1
  68. package/skills/comparison/references/framework-comparison.md +16 -12
  69. package/skills/hooks/SKILL.md +3 -0
  70. package/skills/hooks/handle-and-actions.md +6 -0
  71. package/skills/hooks/navigation.md +28 -0
  72. package/skills/hooks/state.md +209 -8
  73. package/skills/hooks/urls.md +40 -1
  74. package/skills/host-router/SKILL.md +13 -0
  75. package/skills/intercept/SKILL.md +6 -0
  76. package/skills/loader/SKILL.md +19 -1
  77. package/skills/middleware/SKILL.md +2 -0
  78. package/skills/ppr/SKILL.md +119 -40
  79. package/skills/prerender/SKILL.md +7 -3
  80. package/skills/rango/SKILL.md +8 -7
  81. package/skills/router-setup/SKILL.md +15 -1
  82. package/skills/testing/SKILL.md +30 -26
  83. package/skills/testing/client-components.md +193 -8
  84. package/skills/testing/handles.md +2 -0
  85. package/skills/testing/loader.md +1 -1
  86. package/skills/testing/middleware.md +1 -1
  87. package/skills/testing/render-handler.md +18 -18
  88. package/skills/testing/serve-shell-request.md +63 -14
  89. package/skills/testing/server-actions.md +17 -17
  90. package/skills/testing/server-tree.md +1 -1
  91. package/skills/typesafety/generated-files-and-cli.md +1 -1
  92. package/skills/use-cache/SKILL.md +16 -5
  93. package/skills/vercel/SKILL.md +3 -3
  94. package/src/bin/rango.ts +1 -1
  95. package/src/browser/event-controller.ts +75 -2
  96. package/src/browser/history-state.ts +39 -17
  97. package/src/browser/navigation-bridge.ts +41 -18
  98. package/src/browser/navigation-transaction.ts +15 -10
  99. package/src/browser/partial-update.ts +10 -2
  100. package/src/browser/rango-state.ts +3 -1
  101. package/src/browser/react/Link.tsx +2 -3
  102. package/src/browser/react/NavigationProvider.tsx +25 -1
  103. package/src/browser/react/context.ts +13 -0
  104. package/src/browser/react/location-state-shared.ts +200 -34
  105. package/src/browser/react/location-state.ts +40 -75
  106. package/src/browser/react/subscribe-to-nothing.ts +7 -0
  107. package/src/browser/react/use-handle.ts +20 -1
  108. package/src/browser/rsc-router.tsx +33 -11
  109. package/src/browser/server-action-bridge.ts +17 -1
  110. package/src/cache/cache-scope.ts +18 -8
  111. package/src/cache/cf/cf-cache-store.ts +75 -55
  112. package/src/cache/cf/cf-cache-types.ts +12 -4
  113. package/src/cache/handle-snapshot.ts +69 -87
  114. package/src/cache/index.ts +4 -0
  115. package/src/cache/shell-snapshot.ts +35 -5
  116. package/src/cache/types.ts +21 -10
  117. package/src/cache/vercel/vercel-cache-store.ts +48 -20
  118. package/src/client-urls/client-root.tsx +1 -0
  119. package/src/client-urls/navigation.ts +10 -0
  120. package/src/client-urls/optimistic-location.ts +10 -4
  121. package/src/handles/deferred-resolution.ts +31 -0
  122. package/src/prerender/build-shell-capture.ts +11 -9
  123. package/src/router/manifest.ts +2 -0
  124. package/src/router/match-middleware/cache-lookup.ts +11 -18
  125. package/src/router/navigation-snapshot.ts +25 -6
  126. package/src/router/request-classification.ts +15 -12
  127. package/src/router/router-interfaces.ts +2 -1
  128. package/src/router/router-options.ts +17 -7
  129. package/src/router/segment-resolution/loader-cache.ts +95 -60
  130. package/src/router-versions.ts +60 -0
  131. package/src/rsc/full-payload.ts +6 -2
  132. package/src/rsc/handler-context.ts +5 -0
  133. package/src/rsc/handler.ts +14 -4
  134. package/src/rsc/rsc-rendering.ts +12 -1
  135. package/src/rsc/shell-build-manifest.ts +10 -7
  136. package/src/rsc/shell-capture.ts +36 -26
  137. package/src/rsc/types.ts +4 -6
  138. package/src/server/build-version-table.ts +115 -0
  139. package/src/server/handle-store.ts +344 -127
  140. package/src/server/request-context.ts +62 -11
  141. package/src/ssr/ssr-root.tsx +16 -4
  142. package/src/testing/build-versions.ts +64 -0
  143. package/src/testing/dispatch.ts +6 -0
  144. package/src/testing/dom.entry.ts +1 -0
  145. package/src/testing/flight.entry.ts +1 -0
  146. package/src/testing/index.ts +3 -0
  147. package/src/testing/internal/context.ts +2 -1
  148. package/src/testing/internal/seed-vars.ts +14 -2
  149. package/src/testing/location-state-key.ts +18 -8
  150. package/src/testing/render-handler.ts +2 -1
  151. package/src/testing/render-route.tsx +277 -43
  152. package/src/testing/serve-shell-request.ts +80 -11
  153. package/src/testing/vitest-stubs/version.ts +4 -1
  154. package/src/types/handler-context.ts +8 -5
  155. package/src/vite/discovery/build-versions.ts +1281 -0
  156. package/src/vite/discovery/installed-packages.ts +159 -0
  157. package/src/vite/discovery/prerender-collection.ts +38 -1
  158. package/src/vite/discovery/router-versions-phase.ts +336 -0
  159. package/src/vite/discovery/shell-prerender-phase.ts +28 -38
  160. package/src/vite/discovery/state.ts +19 -0
  161. package/src/vite/discovery/virtual-module-codegen.ts +7 -2
  162. package/src/vite/encryption-key.ts +101 -9
  163. package/src/vite/plugin-types.ts +28 -0
  164. package/src/vite/plugins/{version-injector.ts → entry-bootstrap-injector.ts} +7 -22
  165. package/src/vite/plugins/expose-ids/export-analysis.ts +1 -1
  166. package/src/vite/plugins/expose-ids/router-transform.ts +36 -15
  167. package/src/vite/plugins/expose-internal-ids.ts +12 -3
  168. package/src/vite/plugins/stable-rsc-output.ts +147 -0
  169. package/src/vite/plugins/version-plugin.ts +19 -24
  170. package/src/vite/plugins/version.d.ts +17 -2
  171. package/src/vite/plugins/virtual-entries.ts +31 -5
  172. package/src/vite/rango.ts +28 -3
  173. package/src/vite/router-discovery.ts +82 -13
  174. package/src/vite/utils/client-chunks.ts +5 -1
  175. package/src/vite/utils/compare-strings.ts +9 -0
  176. package/src/vite/utils/package-resolution.ts +9 -0
  177. package/src/vite/utils/prerender-utils.ts +4 -1
  178. package/src/vite/utils/shared-utils.ts +1 -1
@@ -35,11 +35,19 @@
35
35
  * useRouter().replace kind "replace", refresh() kind "revalidate".
36
36
  * Back/forward, action and optimistic clientUrls() swap decisions are
37
37
  * e2e territory.
38
+ * - Location state reaches readers the way it does in production:
39
+ * NavigationProvider holds the committed entry's state next to the
40
+ * payload, so a navigation whose loader the test holds keeps the entry
41
+ * being left on screen with its own state. A `popstate` event is a
42
+ * back/forward onto the entry history.state holds; the tree has one
43
+ * location, so no page is restored with it. A server action's state, a
44
+ * redirect's and an intercept's are e2e territory.
38
45
  * What it DOES cover: client hooks that read NavigationProvider /
39
46
  * OutletContext — useParams, useReverse, useHref, useMount, useNavigation,
40
47
  * useRouter, usePathname, useSearchParams, Outlet/useOutlet nesting and seeded
41
48
  * descendant pending state, useLoader/useFetchLoader (seeded data),
42
- * useLocationState (seeded), and useHandle (seeded).
49
+ * useLocationState (seeded, or written by a navigation), and useHandle
50
+ * (seeded).
43
51
  * Basename-mounted apps: pass the `basename` option so useRouter().basename,
44
52
  * <Link> prefixing, and useMount/useHref resolve against the mount prefix
45
53
  * (without it they resolve at the root "/"). For an include("/shop", ...)
@@ -48,7 +56,12 @@
48
56
  */
49
57
 
50
58
  import { decideTransitionGatedOff } from "../browser/transition-when.js";
51
- import { useEffect, type ReactNode, type ComponentType } from "react";
59
+ import {
60
+ StrictMode,
61
+ useEffect,
62
+ type ReactNode,
63
+ type ComponentType,
64
+ } from "react";
52
65
  import type { RenderResult } from "@testing-library/react";
53
66
  import { renderSegments } from "../segment-system.js";
54
67
  import {
@@ -62,6 +75,7 @@ import {
62
75
  shouldStartViewTransition,
63
76
  } from "../browser/partial-update.js";
64
77
  import type {
78
+ NavigateOptions,
65
79
  NavigateOptionsInternal,
66
80
  NavigationStore,
67
81
  NavigationBridge,
@@ -80,10 +94,13 @@ import type {
80
94
  TransitionConfig,
81
95
  TransitionWhenKind,
82
96
  } from "../types.js";
83
- import type { LocationStateDefinition } from "../browser/react/location-state-shared.js";
97
+ import {
98
+ setLocationStateVersion,
99
+ type LocationStateDefinition,
100
+ } from "../browser/react/location-state-shared.js";
84
101
  import {
85
102
  buildHistoryState,
86
- hasLocationState,
103
+ clearLocationStateOnDocumentLoad,
87
104
  pushHistoryWithIdx,
88
105
  resolveNavigationState,
89
106
  } from "../browser/history-state.js";
@@ -230,6 +247,8 @@ export interface RenderRouteOptions {
230
247
  * `useLocationState(StateDef)`. Like loaders, a real `createLocationState()`
231
248
  * handle has an empty injected key in a bare test, so pass `[def, value]`
232
249
  * pairs; renderRoute assigns a synthetic key and writes it to `history.state`.
250
+ * With `hydrate: true` a `clearOnReload` definition's seed is removed before
251
+ * hydration, as on a document load.
233
252
  *
234
253
  * @example
235
254
  * renderRoute([{ path: "/", Component: FlashBanner }], {
@@ -265,6 +284,24 @@ export interface RenderRouteOptions {
265
284
  * computes the segment id for you). Merged with `handles`.
266
285
  */
267
286
  handle?: HandleDataSeed;
287
+ /**
288
+ * Handle values a document delivers after hydration, as a loader's push
289
+ * that is not in the document's handle snapshot is (a push made after the
290
+ * handler output settled, or any loader push on a PPR shell HIT). Each
291
+ * entry is `[handle, values]`: the handle's values after the update, which
292
+ * replace its `handles` seed once the root has hydrated (or mounted). With
293
+ * `hydrate: true` the server HTML is rendered from `handles`, and a reader
294
+ * inside a `<Suspense>` boundary hydrates after the update was applied, as
295
+ * a boundary that hydrates after the root does in production.
296
+ *
297
+ * @example
298
+ * const { serverHtml, recoverableErrors } = await renderRoute(routes, {
299
+ * hydrate: true,
300
+ * handles: [[Notes, ["from-handler"]]],
301
+ * lateHandles: [[Notes, ["from-handler", "from-loader"]]],
302
+ * });
303
+ */
304
+ lateHandles?: ReadonlyArray<readonly [Handle<any, any>, unknown[]]>;
268
305
  /**
269
306
  * Route name -> pattern map. Informational for parity with the server test
270
307
  * context; client useReverse takes its map directly as an argument, so this
@@ -326,6 +363,25 @@ export interface RenderRouteOptions {
326
363
  * not override a `"none"` default.
327
364
  */
328
365
  defaultPrefetch?: PrefetchStrategy;
366
+ /**
367
+ * Hydrate instead of mounting fresh: the same element is rendered to HTML
368
+ * (react-dom/server `renderToString`), placed in the container and hydrated.
369
+ * The result gains `serverHtml` and `recoverableErrors`
370
+ * (RenderRouteHydrateResult). `data-hydrated` is set on `<html>` after
371
+ * hydration, as production's root does, and removed on unmount.
372
+ *
373
+ * Limits:
374
+ * - The server pass runs without `window` and `document`, and only those. A
375
+ * hook that reads a global that stays defined there (`history`,
376
+ * `localStorage`, `navigator`) outside a server snapshot renders the same
377
+ * in both passes here and can still mismatch in real SSR.
378
+ * - Content that suspends in the server pass (`lazy`, a pending or untracked
379
+ * `use()` promise) is emitted as its fallback and client-rendered, not
380
+ * hydrated; React reports the boundary in `recoverableErrors`. Outside any
381
+ * `<Suspense>` it rejects.
382
+ * - Needs @testing-library/react >= 16.2.0; throws on older versions.
383
+ */
384
+ hydrate?: boolean;
329
385
  }
330
386
 
331
387
  /**
@@ -356,10 +412,20 @@ export interface TestRouterHandle {
356
412
  * `options.transition: false` is the per-navigation opt-out
357
413
  * (`router.push(url, { transition: false })`, `<Link transition={false}>`):
358
414
  * the commit is urgent and no transition({ when }) predicate is called.
415
+ *
416
+ * `options.state` and `options.replace: true` write the history entry the
417
+ * way `useRouter().push(url, { state })` and `.replace(url, { state })` do,
418
+ * so a test can carry location state on a navigation whose loader it
419
+ * holds: `state` pushes an entry with it, `replace: true` replaces the
420
+ * current entry (with `state`, or with none, which drops the entry's
421
+ * location state as a production replace does). With neither, history is
422
+ * left alone and readers keep the state they have: `navigate(url)` and
423
+ * `navigate(url, { replace: false })` are the same call.
359
424
  */
360
425
  navigate(
361
426
  url: string,
362
- options?: Pick<RenderRouteOptions, "loaders"> & { transition?: boolean },
427
+ options?: Pick<RenderRouteOptions, "loaders"> &
428
+ Pick<NavigateOptions, "state" | "replace" | "transition">,
363
429
  ): Promise<void>;
364
430
  /**
365
431
  * Re-render the current location, as router.refresh() does: a spec's
@@ -379,6 +445,21 @@ export interface TestRouterHandle {
379
445
  /** Result of renderRoute: RTL's render result plus the router handle. */
380
446
  export type RenderRouteResult = RenderResult & { router: TestRouterHandle };
381
447
 
448
+ /** Result of renderRoute with `hydrate: true`. */
449
+ export type RenderRouteHydrateResult = RenderRouteResult & {
450
+ /** The HTML the server pass produced, as placed in the container. */
451
+ serverHtml: string;
452
+ /**
453
+ * Messages of the errors React recovered from (`onRecoverableError`): a
454
+ * hydration mismatch that made it re-render on the client, or a boundary the
455
+ * server pass could not finish. Empty means the tree hydrated as rendered.
456
+ * Live: React appends for as long as the root is mounted. An attribute-only
457
+ * mismatch is NOT here — React keeps the server attribute and only logs it
458
+ * with `console.error` in development.
459
+ */
460
+ recoverableErrors: string[];
461
+ };
462
+
382
463
  interface ResolvedMatch {
383
464
  params: Record<string, string>;
384
465
  pathname: string;
@@ -419,6 +500,45 @@ function DelegatedPrefetchRegistration({
419
500
  return null;
420
501
  }
421
502
 
503
+ /**
504
+ * initBrowserApp calls the bridge's registerLinkInterception, which is where
505
+ * production listens for `popstate` (navigation-bridge.ts); a rendered tree
506
+ * has no initBrowserApp.
507
+ */
508
+ function LinkInterceptionRegistration({
509
+ bridge,
510
+ }: {
511
+ bridge: NavigationBridge;
512
+ }): null {
513
+ useEffect(() => bridge.registerLinkInterception(), [bridge]);
514
+ return null;
515
+ }
516
+
517
+ /**
518
+ * The marker production's root sets from its effect after hydration
519
+ * (browser/rsc-router.tsx Rango), so a boundary that hydrates in a later pass
520
+ * finds it set, as in production. Removed on unmount: a test unmounts its
521
+ * root, production never does.
522
+ */
523
+ function HydratedMarker(): null {
524
+ useEffect(() => {
525
+ document.documentElement.dataset.hydrated = "";
526
+ return () => document.documentElement.removeAttribute("data-hydrated");
527
+ }, []);
528
+ return null;
529
+ }
530
+
531
+ /**
532
+ * Applies `lateHandles` from an effect after the tree's: where production's
533
+ * root effect releases the document's late handle channel
534
+ * (browser/rsc-router.tsx hydrationCommitted). A boundary that hydrates in a
535
+ * later pass finds the update applied.
536
+ */
537
+ function LateHandles({ apply }: { apply: () => void }): null {
538
+ useEffect(apply, [apply]);
539
+ return null;
540
+ }
541
+
422
542
  function matchLeaf(
423
543
  pattern: string,
424
544
  pathname: string,
@@ -504,6 +624,14 @@ function buildSegments(
504
624
  return segments;
505
625
  }
506
626
 
627
+ export function renderRoute(
628
+ routes: RenderRouteSpec[],
629
+ options: RenderRouteOptions & { hydrate: true },
630
+ ): Promise<RenderRouteHydrateResult>;
631
+ export function renderRoute(
632
+ routes: RenderRouteSpec[],
633
+ options?: RenderRouteOptions,
634
+ ): Promise<RenderRouteResult>;
507
635
  export async function renderRoute(
508
636
  routes: RenderRouteSpec[],
509
637
  options: RenderRouteOptions = {},
@@ -539,11 +667,17 @@ export async function renderRoute(
539
667
  const loaderData = seedLoaders(options.loaderData ?? {}, options.loaders);
540
668
 
541
669
  if (typeof window !== "undefined") {
670
+ // The tree has no app version (its bridge reports none), so a seed needs
671
+ // none either. Reset what an initBrowserApp in the same file may have left.
672
+ setLocationStateVersion(undefined);
542
673
  const stateObj: Record<string, unknown> = {};
543
674
  for (const [def, value] of options.locationState ?? []) {
544
675
  stateObj[withLocationStateKey(def).__rsc_ls_key] = value;
545
676
  }
546
677
  window.history.replaceState(stateObj, "");
678
+ // Hydrate mode is a document load of the seeded entry: run start-up's
679
+ // step (initBrowserApp), not a copy of it.
680
+ if (options.hydrate) clearLocationStateOnDocumentLoad();
547
681
  }
548
682
 
549
683
  const resolve = (pathname: string): ResolvedMatch => {
@@ -609,14 +743,33 @@ export async function renderRoute(
609
743
 
610
744
  const eventController = createEventController({ initialLocation: url });
611
745
  eventController.setParams(initialMatch.params);
746
+ // The seeded entry, as initBrowserApp commits the document's.
747
+ eventController.commitLocationState(window.history.state);
612
748
  // Resolve-by-default: resolve any deferred (Promise) seeded handle values
613
749
  // before applying, so the seeded handles reach collect/useHandle resolved —
614
750
  // matching what the server/client do in a real app.
615
751
  const resolvedSeed = await resolveDeferredHandleValues(handleSeed);
616
- eventController.setHandleData(
617
- resolvedSeed,
618
- initialSegments.map((s) => s.id),
619
- );
752
+ const initialSegmentIds = initialSegments.map((s) => s.id);
753
+ eventController.setHandleData(resolvedSeed, initialSegmentIds);
754
+ // As initBrowserApp before hydrateRoot: what a hydrating reader reads.
755
+ eventController.freezeHydrationHandleState();
756
+ // A late update carries the full state: the seed with these handles' values
757
+ // replaced, resolved as the browser resolves a late yield before applying.
758
+ let applyLateHandles: (() => void) | undefined;
759
+ if (options.lateHandles && leafRouteSegmentId !== undefined) {
760
+ const lateSeed = cloneHandleSeed(resolvedSeed);
761
+ for (const [handle, values] of options.lateHandles) {
762
+ const id = (handle as unknown as { $$id: string }).$$id;
763
+ (lateSeed[id] ??= {})[leafRouteSegmentId] = values;
764
+ }
765
+ const lateState = await resolveDeferredHandleValues(lateSeed);
766
+ // Flushed, not left to the notify timer: renderRoute's act() has
767
+ // returned by then.
768
+ applyLateHandles = () => {
769
+ eventController.setHandleData(lateState, initialSegmentIds);
770
+ eventController.flushRouteState();
771
+ };
772
+ }
620
773
 
621
774
  let warnedNavLifecycle = false;
622
775
  const navigate = async (
@@ -633,10 +786,11 @@ export async function renderRoute(
633
786
  // A useRouter().push/replace or <Link> navigation (`history`) writes its
634
787
  // entry the way production does (navigation-bridge.ts navigate ->
635
788
  // navigation-transaction.ts commit): the dev state check, typed entries
636
- // spread onto history.state, the idx stamp, and the __rsc_locationstate
637
- // event when the old or new entry carries location state. The URL stays
638
- // put: renderRoute tracks location on the event controller, not
639
- // window.location. router.navigate() and refresh() leave history alone.
789
+ // spread onto history.state, the idx stamp, and the entry state committed
790
+ // on the event controller, which the provider takes with the payload.
791
+ // The URL stays put: renderRoute tracks location on the event
792
+ // controller, not window.location. router.navigate() without `state` or
793
+ // `replace: true`, and refresh(), leave history alone.
640
794
  const history = navOptions?.history;
641
795
  const historyState = history
642
796
  ? buildHistoryState(
@@ -702,16 +856,13 @@ export async function renderRoute(
702
856
  eventController.setParams(match.params);
703
857
  store.setCurrentUrl(nextUrl.href);
704
858
  store.setSegmentIds(segments.map((s) => s.id));
705
- let notifyLocationState = false;
706
859
  if (history) {
707
- notifyLocationState =
708
- hasLocationState(window.history.state) ||
709
- hasLocationState(historyState);
710
860
  pushHistoryWithIdx(
711
861
  historyState ?? null,
712
862
  window.location.href,
713
863
  history.replace ?? false,
714
864
  );
865
+ eventController.commitLocationState(historyState);
715
866
  }
716
867
  const emit: UpdateSubscriber = (update) => store.emitUpdate(update);
717
868
  await act(async () => {
@@ -723,9 +874,6 @@ export async function renderRoute(
723
874
  } else {
724
875
  emit({ root, metadata });
725
876
  }
726
- if (notifyLocationState) {
727
- window.dispatchEvent(new Event("__rsc_locationstate"));
728
- }
729
877
  });
730
878
  };
731
879
 
@@ -739,8 +887,20 @@ export async function renderRoute(
739
887
  navigate: (target, navigateOptions) =>
740
888
  navigate(target, { history: navigateOptions ?? {} }),
741
889
  refresh: () => refresh(),
742
- handlePopstate: async () => {},
743
- registerLinkInterception: () => () => {},
890
+ // Back/forward onto the entry history.state holds. The tree has one
891
+ // location, so nothing is restored: a treeless commit of that entry, as
892
+ // production's traversal that keeps every segment
893
+ // (navigation-transaction.ts commit). Flushed, not left to the notify
894
+ // timer: the dispatching test's act() has returned by then.
895
+ handlePopstate: async () => {
896
+ eventController.commitLocationState(window.history.state, true);
897
+ eventController.flushRouteState();
898
+ },
899
+ registerLinkInterception: () => {
900
+ const onPopstate = (): void => void bridge.handlePopstate();
901
+ window.addEventListener("popstate", onPopstate);
902
+ return () => window.removeEventListener("popstate", onPopstate);
903
+ },
744
904
  registerDelegatedPrefetch: () =>
745
905
  setupNavigationBridgeDelegatedPrefetch(
746
906
  store,
@@ -764,6 +924,52 @@ export async function renderRoute(
764
924
  outletPending: options.outletPending,
765
925
  });
766
926
 
927
+ // The marker sits after the provider so its effect runs after the tree's,
928
+ // like the effect of production's root component.
929
+ const ui = (
930
+ <>
931
+ <NavigationProvider
932
+ store={store}
933
+ eventController={eventController}
934
+ initialPayload={{ root: initialTree, metadata: initialMetadata }}
935
+ bridge={bridge}
936
+ basename={basename}
937
+ themeConfig={
938
+ options.theme === undefined ? null : resolveThemeConfig(options.theme)
939
+ }
940
+ nonce={options.nonce}
941
+ />
942
+ <DelegatedPrefetchRegistration bridge={bridge} />
943
+ <LinkInterceptionRegistration bridge={bridge} />
944
+ {options.hydrate && <HydratedMarker />}
945
+ {applyLateHandles && <LateHandles apply={applyLateHandles} />}
946
+ </>
947
+ );
948
+
949
+ let serverHtml: string | undefined;
950
+ const recoverableErrors: string[] = [];
951
+ if (options.hydrate) {
952
+ // RTL forwards onRecoverableError to hydrateRoot from 16.2.0. 16.0-16.1
953
+ // are inside the peer range and drop it (their render() never names the
954
+ // option), which would leave `recoverableErrors` empty on a real mismatch.
955
+ if (!String(render).includes("onRecoverableError")) {
956
+ throw new Error(
957
+ "renderRoute: `hydrate` needs @testing-library/react >= 16.2.0. Older " +
958
+ "versions do not pass onRecoverableError to hydrateRoot, so a " +
959
+ "hydration mismatch would go unreported.",
960
+ );
961
+ }
962
+ const { getConfig } = await import("@testing-library/react");
963
+ const { renderToString } = await import("react-dom/server");
964
+ // RTL wraps the hydrated element the same way.
965
+ const serverUi = getConfig().reactStrictMode ? (
966
+ <StrictMode>{ui}</StrictMode>
967
+ ) : (
968
+ ui
969
+ );
970
+ serverHtml = withoutBrowserGlobals(() => renderToString(serverUi));
971
+ }
972
+
767
973
  // Wrap render in an awaited async act so a tree that suspends (async loaders,
768
974
  // loading states, deferred handle entries that arrive as a Promise) settles its
769
975
  // Suspense within act — otherwise React orphans the resolution ("a component
@@ -772,30 +978,32 @@ export async function renderRoute(
772
978
  let result!: Awaited<ReturnType<typeof render>>;
773
979
  const container = document.body.appendChild(document.createElement("div"));
774
980
  prefetchRoot = container;
981
+ if (serverHtml !== undefined) container.innerHTML = serverHtml;
775
982
  await act(async () => {
776
- result = render(
777
- <>
778
- <NavigationProvider
779
- store={store}
780
- eventController={eventController}
781
- initialPayload={{ root: initialTree, metadata: initialMetadata }}
782
- bridge={bridge}
783
- basename={basename}
784
- themeConfig={
785
- options.theme === undefined
786
- ? null
787
- : resolveThemeConfig(options.theme)
788
- }
789
- nonce={options.nonce}
790
- />
791
- <DelegatedPrefetchRegistration bridge={bridge} />
792
- </>,
793
- { baseElement: document.body, container },
794
- );
983
+ result = render(ui, {
984
+ baseElement: document.body,
985
+ container,
986
+ ...(serverHtml !== undefined && {
987
+ hydrate: true,
988
+ onRecoverableError: (error: unknown) => {
989
+ recoverableErrors.push(
990
+ error instanceof Error ? error.message : String(error),
991
+ );
992
+ },
993
+ }),
994
+ });
795
995
  });
796
996
 
797
997
  const router: TestRouterHandle = {
798
- navigate,
998
+ navigate: (target, navOptions) =>
999
+ navigate(target, {
1000
+ loaders: navOptions?.loaders,
1001
+ transition: navOptions?.transition,
1002
+ history:
1003
+ navOptions?.state !== undefined || navOptions?.replace
1004
+ ? navOptions
1005
+ : undefined,
1006
+ }),
799
1007
  refresh,
800
1008
  pathname: () => new URL(eventController.getLocation().href).pathname,
801
1009
  params: () => eventController.getParams(),
@@ -803,7 +1011,33 @@ export async function renderRoute(
803
1011
  eventController,
804
1012
  };
805
1013
 
806
- return Object.assign(result, { router });
1014
+ if (serverHtml === undefined) return Object.assign(result, { router });
1015
+ return Object.assign(result, { router, serverHtml, recoverableErrors });
1016
+ }
1017
+
1018
+ /**
1019
+ * Runs the server pass without `window` and `document`, the two globals
1020
+ * SSR-aware code probes. A DOM test environment defines both, so with them in
1021
+ * place a `typeof window` branch takes its client side, renders the client
1022
+ * HTML, and the mismatch it causes in production hydrates clean. `fn` must be
1023
+ * synchronous (renderToString, not a streaming entry): nothing else may run
1024
+ * while they are gone.
1025
+ */
1026
+ function withoutBrowserGlobals<T>(fn: () => T): T {
1027
+ const scope = globalThis as { window?: unknown; document?: unknown };
1028
+ const windowDescriptor = Object.getOwnPropertyDescriptor(scope, "window")!;
1029
+ const documentDescriptor = Object.getOwnPropertyDescriptor(
1030
+ scope,
1031
+ "document",
1032
+ )!;
1033
+ try {
1034
+ delete scope.window;
1035
+ delete scope.document;
1036
+ return fn();
1037
+ } finally {
1038
+ Object.defineProperty(scope, "window", windowDescriptor);
1039
+ Object.defineProperty(scope, "document", documentDescriptor);
1040
+ }
807
1041
  }
808
1042
 
809
1043
  function makeMetadata(
@@ -44,6 +44,8 @@ import {
44
44
  SHELL_CAPTURE_MAX_WAIT_MS,
45
45
  } from "../rsc/shell-capture-constants.js";
46
46
  import { SEGMENT_FRAGMENT_CAPABILITY_HEADER } from "../segment-fragments.js";
47
+ import { resolveDeferredHandleValues } from "../handles/deferred-resolution.js";
48
+ import type { HandleData } from "../server/handle-store.js";
47
49
  import { _getRequestContext } from "../server/request-context.js";
48
50
  import {
49
51
  parsePprReplayStatus,
@@ -77,6 +79,22 @@ export interface ServeShellRequestOptions<TEnv = any> {
77
79
  partial?: true | { from?: string; segments?: readonly string[] };
78
80
  }
79
81
 
82
+ /** A response's handle data, as the browser consumes it. */
83
+ export interface ShellRequestHandles {
84
+ /**
85
+ * What the document hydrates with: `metadata.handles`, read to its end
86
+ * before hydration starts. For a `partial` request, the last state the
87
+ * navigation streamed.
88
+ */
89
+ hydration: HandleData;
90
+ /**
91
+ * The states `metadata.handlesLate` delivered, in order. Each one replaces
92
+ * the client's handle data after hydration. Empty when nothing arrived
93
+ * late, and for a `partial` request.
94
+ */
95
+ late: HandleData[];
96
+ }
97
+
80
98
  /** Result of {@link serveShellRequest}. */
81
99
  export interface ServeShellRequestResult {
82
100
  /** The handler's Response. Its body is already read: see `body`. */
@@ -113,6 +131,11 @@ export interface ServeShellRequestResult {
113
131
  * (CFCacheStore, VercelCacheStore) it warms the memo like any other read.
114
132
  */
115
133
  readEntry(): Promise<ShellCacheEntry | null>;
134
+ /**
135
+ * Decode the handle data of `flight` as the browser reads it, deferred
136
+ * values resolved. Undefined when no Flight was rendered.
137
+ */
138
+ readHandles(): Promise<ShellRequestHandles | undefined>;
116
139
  }
117
140
 
118
141
  type ShellHandler = ReturnType<typeof createRSCHandler>;
@@ -203,8 +226,39 @@ const SSR_STUB: SSRModule = {
203
226
  },
204
227
  };
205
228
 
206
- /** Handlers per router, keyed by the `cacheStore` override or the router. */
207
- const handlers = new WeakMap<object, WeakMap<object, ShellHandler>>();
229
+ type HandleChannel = AsyncIterable<HandleData> | undefined;
230
+
231
+ /**
232
+ * Read a payload's two handle channels as browser/rsc-router.tsx does:
233
+ * `handles` to its end, then every `handlesLate` state.
234
+ */
235
+ async function decodePayloadHandles(
236
+ flight: string,
237
+ ): Promise<ShellRequestHandles> {
238
+ const { deserializeResult } = await import("../cache/segment-codec.js");
239
+ const { metadata } = await deserializeResult<{
240
+ metadata?: { handles?: HandleChannel; handlesLate?: HandleChannel };
241
+ }>(flight);
242
+ let hydration: HandleData = {};
243
+ for await (const data of metadata?.handles ?? []) hydration = data;
244
+ const late: HandleData[] = [];
245
+ for await (const data of metadata?.handlesLate ?? []) {
246
+ late.push(await resolveDeferredHandleValues(data));
247
+ }
248
+ return { hydration: await resolveDeferredHandleValues(hydration), late };
249
+ }
250
+
251
+ /**
252
+ * Handlers per router, keyed by the `cacheStore` override or the router. A
253
+ * handler resolves its router's versions when it is created, so an entry is
254
+ * reused only while the router would still resolve the versions it was made
255
+ * with: a setBuildVersions() call (a simulated deploy) that changes them gets
256
+ * a new handler.
257
+ */
258
+ const handlers = new WeakMap<
259
+ object,
260
+ WeakMap<object, { handler: ShellHandler; versions: string }>
261
+ >();
208
262
 
209
263
  async function getHandler(
210
264
  router: Rango<any, any>,
@@ -212,10 +266,21 @@ async function getHandler(
212
266
  ): Promise<ShellHandler> {
213
267
  let byStore = handlers.get(router);
214
268
  if (!byStore) handlers.set(router, (byStore = new WeakMap()));
269
+ const internal = toInternal(router);
270
+ // Imported here, like the handler below: the module binds the build-only
271
+ // `@rangojs/router:version` virtual, which a flight-only test config (one
272
+ // that never serves a request) does not alias.
273
+ const { resolveRouterVersions } =
274
+ await import("../server/build-version-table.js");
275
+ // What createRSCHandler would resolve for this router now.
276
+ const { data, document } = resolveRouterVersions(
277
+ internal.id,
278
+ internal.version,
279
+ );
280
+ const versions = `${data}\0${document}`;
215
281
  const cached = byStore.get(cacheStore ?? router);
216
- if (cached) return cached;
282
+ if (cached?.versions === versions) return cached.handler;
217
283
 
218
- const internal = toInternal(router);
219
284
  const routerCache = internal.cache;
220
285
  // Resolved per request, as the handler resolves the router's own config.
221
286
  const cache = (env: unknown, ctx?: ExecutionContext): HandlerCacheConfig => {
@@ -230,11 +295,10 @@ async function getHandler(
230
295
  const handler = createRSCHandler({
231
296
  router: internal,
232
297
  nonce: internal.nonce,
233
- version: internal.version,
234
298
  loadSSRModule: async () => SSR_STUB,
235
299
  cache: routerCache || cacheStore ? cache : undefined,
236
300
  });
237
- byStore.set(cacheStore ?? router, handler);
301
+ byStore.set(cacheStore ?? router, { handler, versions });
238
302
  return handler;
239
303
  }
240
304
 
@@ -358,16 +422,18 @@ export async function serveShellRequest<TEnv = any>(
358
422
  const isFlight =
359
423
  recorder.rendered ||
360
424
  response.headers.get("content-type")?.includes("text/x-component");
425
+ const prelude =
426
+ tail !== undefined && body.endsWith(tail)
427
+ ? body.slice(0, body.length - tail.length)
428
+ : undefined;
429
+ const flight = tail ?? (isFlight && body ? body : undefined);
361
430
  return {
362
431
  response,
363
432
  body,
364
433
  shellStatus: parseShellStatus(response),
365
434
  replayStatus: parsePprReplayStatus(response),
366
- prelude:
367
- tail !== undefined && body.endsWith(tail)
368
- ? body.slice(0, body.length - tail.length)
369
- : undefined,
370
- flight: tail ?? (isFlight && body ? body : undefined),
435
+ prelude,
436
+ flight,
371
437
  key,
372
438
  async readEntry() {
373
439
  // Passive, like the serve path's own reads: a stale entry's SWR
@@ -377,5 +443,8 @@ export async function serveShellRequest<TEnv = any>(
377
443
  });
378
444
  return read?.entry ?? null;
379
445
  },
446
+ async readHandles() {
447
+ return flight ? decodePayloadHandles(flight) : undefined;
448
+ },
380
449
  };
381
450
  }
@@ -1,5 +1,8 @@
1
1
  // Stub for the build-only `@rangojs/router:version` virtual module, shipped so
2
2
  // consumers do not have to author it. The rango Vite plugin injects this at
3
3
  // build time; in a bare Vitest process it must be aliased to a stub. Empty
4
- // string keeps generated URLs free of a version path segment.
4
+ // string keeps generated URLs free of a version path segment. There is no
5
+ // build table here; setBuildVersions (@rangojs/router/testing) installs one
6
+ // for a test that needs per-router versions.
5
7
  export const VERSION = "";
8
+ export const ROUTER_VERSIONS: undefined = undefined;
@@ -462,11 +462,14 @@ export type InternalHandlerContext<
462
462
  */
463
463
  _loaderCacheOriginalUse?: (item: any) => any;
464
464
  /**
465
- * @internal A loader-cache HIT asks before replaying a loader's recorded
466
- * pushes: true when that loader has not run in this request and no other
467
- * replay delivered it; its later run then replaces the replayed values
468
- * (HandleStore.pushReplayed). One copy per loader per request. See
469
- * setupLoaderAccess.
465
+ * @internal A loader-cache or "use cache" HIT asks before replaying a
466
+ * loader's recorded pushes: true when that loader has not run in this
467
+ * request and no other replay delivered it; its later run then replaces
468
+ * the replayed values (HandleStore.pushReplayed). One copy per loader per
469
+ * request. A record's restore does not ask (restoreHandles): its copies
470
+ * are the pinned loader's own, or placeholders the claimed replay
471
+ * replaces. Absent on a stale refresh's ctx, whose pushes are diverted
472
+ * (handle-snapshot.ts appendHandles). See setupLoaderAccess.
470
473
  */
471
474
  _claimLoaderPushes?: (loaderId: string) => boolean;
472
475
  /**