@rangojs/router 0.21.0 → 0.22.1

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 (244) 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 +180 -497
  88. package/package.json +13 -3
  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/loader-registry.ts +6 -1
  215. package/src/server/request-context.ts +87 -1
  216. package/src/ssr/ssr-root.tsx +20 -13
  217. package/src/suspense-audit-react.tsx +98 -0
  218. package/src/suspense-audit.ts +1030 -0
  219. package/src/testing/action-form.ts +66 -0
  220. package/src/testing/flight-runtime.d.ts +27 -0
  221. package/src/testing/flight.entry.ts +3 -0
  222. package/src/testing/index.ts +19 -0
  223. package/src/testing/internal/server-modules.ts +32 -0
  224. package/src/testing/render-route.tsx +25 -12
  225. package/src/testing/serve-shell-request.ts +190 -34
  226. package/src/testing/shell-status.ts +17 -6
  227. package/src/testing/vitest-stubs/plugin-rsc.ts +37 -11
  228. package/src/thenable-status.ts +10 -0
  229. package/src/types/handler-context.ts +7 -0
  230. package/src/types/route-entry.ts +6 -0
  231. package/src/urls/path-helper.ts +69 -9
  232. package/src/use-loader.tsx +57 -30
  233. package/src/vite/discovery/bundle-postprocess.ts +4 -0
  234. package/src/vite/discovery/discover-routers.ts +15 -0
  235. package/src/vite/discovery/shell-prerender-phase.ts +5 -5
  236. package/src/vite/discovery/state.ts +17 -1
  237. package/src/vite/inject-client-debug.ts +42 -20
  238. package/src/vite/plugins/virtual-entries.ts +8 -1
  239. package/src/vite/router-discovery.ts +14 -0
  240. package/src/vite/utils/bundle-analysis.ts +39 -7
  241. package/dist/types/browser/react/subscribe-to-nothing.d.ts +0 -7
  242. package/dist/types/segment-content-promise.d.ts +0 -13
  243. package/src/browser/react/subscribe-to-nothing.ts +0 -7
  244. package/src/segment-content-promise.ts +0 -67
@@ -7,7 +7,6 @@ import React, {
7
7
  useEffect,
8
8
  useMemo,
9
9
  useRef,
10
- useSyncExternalStore,
11
10
  type ForwardRefExoticComponent,
12
11
  type RefAttributes,
13
12
  } from "react";
@@ -20,7 +19,6 @@ import {
20
19
  subscribeToPrefetchScopeChange,
21
20
  } from "../link-interceptor.js";
22
21
  import { subscribeToLocationChange } from "../event-controller.js";
23
- import { subscribeToNothing } from "./subscribe-to-nothing.js";
24
22
  import { isLocationStateDefinition } from "./location-state-shared.js";
25
23
 
26
24
  export type StateOrGetter<T = unknown> = T | (() => T);
@@ -39,7 +37,7 @@ import {
39
37
  } from "../prefetch/loader.js";
40
38
  import { observeForPrefetch } from "../prefetch/observer.js";
41
39
  import {
42
- getDefaultPrefetchStrategy,
40
+ ENVIRONMENT_DEFAULT_PREFETCH,
43
41
  resolveAdaptiveStrategy,
44
42
  subscribeToAdaptiveStrategyChange,
45
43
  } from "../prefetch/default-strategy.js";
@@ -240,19 +238,11 @@ export const Link: ForwardRefExoticComponent<
240
238
  ref,
241
239
  ) {
242
240
  const ctx = useContext(NavigationStoreContext);
243
- // Origin from the store location — the same both-sides source
244
- // useSearchParams seeds from (SSR: the live request's URL; browser:
245
- // window.location), so data-external agrees across hydration. Origin is
246
- // immutable per document, so the inline getState() read cannot tear.
247
- // window is the provider-less browser fallback (tests, portals outside
248
- // the app root); provider-less SSR has no origin and keeps links internal.
249
- const storeLocation = ctx?.eventController.getState().location as
250
- | URL
251
- | undefined;
252
- const origin =
253
- storeLocation?.origin ??
254
- (typeof window !== "undefined" ? window.location.origin : undefined);
255
- const isExternal = isExternalUrl(to, origin);
241
+ // The document's origin from the router store, the same on both sides
242
+ // (SSR: the live request's URL; browser: the store location), so
243
+ // data-external agrees across hydration. Without a provider there is no
244
+ // store and no origin: links stay internal.
245
+ const isExternal = isExternalUrl(to, ctx?.origin);
256
246
 
257
247
  // Auto-prefix with basename for app-local paths.
258
248
  // Skip if external, already prefixed, or not a root-relative path.
@@ -264,19 +254,11 @@ export const Link: ForwardRefExoticComponent<
264
254
  return to === "/" ? bn : bn + to;
265
255
  }, [to, isExternal, ctx?.basename]);
266
256
 
267
- // No explicit `prefetch` prop: fall back to the router-wide default
268
- // (server-resolved, applied at browser init — before hydration, so this
269
- // render-time read never races the metadata). Adaptive reads the current
270
- // input capability rather than a module-load snapshot.
257
+ // No explicit `prefetch` prop: fall back to the router-wide default from
258
+ // the router store. Adaptive is resolved against the input capability when a
259
+ // prefetch is armed or triggered, never during render.
271
260
  const configuredStrategy =
272
- prefetch ?? ctx?.defaultPrefetch ?? getDefaultPrefetchStrategy();
273
- const resolvedStrategy = useSyncExternalStore(
274
- configuredStrategy === "adaptive"
275
- ? subscribeToAdaptiveStrategyChange
276
- : subscribeToNothing,
277
- () => resolveAdaptiveStrategy(configuredStrategy),
278
- () => (configuredStrategy === "adaptive" ? "hover" : configuredStrategy),
279
- );
261
+ prefetch ?? ctx?.defaultPrefetch ?? ENVIRONMENT_DEFAULT_PREFETCH;
280
262
 
281
263
  // Internal ref for viewport observation; merge with forwarded ref
282
264
  const internalRef = useRef<HTMLAnchorElement | null>(null);
@@ -375,8 +357,9 @@ export const Link: ForwardRefExoticComponent<
375
357
 
376
358
  const handleMouseEnter = useCallback(() => {
377
359
  const element = internalRef.current;
360
+ const strategy = resolveAdaptiveStrategy(configuredStrategy);
378
361
  if (
379
- (resolvedStrategy === "hover" || resolvedStrategy === "viewport") &&
362
+ (strategy === "hover" || strategy === "viewport") &&
380
363
  !isExternal &&
381
364
  ctx?.store &&
382
365
  (!element ||
@@ -395,17 +378,26 @@ export const Link: ForwardRefExoticComponent<
395
378
  prefetchKey,
396
379
  );
397
380
  }
398
- }, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
381
+ }, [configuredStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
399
382
 
400
383
  // Viewport/render prefetch: waits for idle before starting,
401
384
  // uses concurrency-limited queue to avoid flooding.
402
385
  useEffect(() => {
403
386
  if (isExternal || !ctx?.store) return;
404
- const isViewport = resolvedStrategy === "viewport";
405
- const isRender = resolvedStrategy === "render";
406
- if (!isViewport && !isRender) return;
387
+ if (
388
+ configuredStrategy !== "viewport" &&
389
+ configuredStrategy !== "render" &&
390
+ configuredStrategy !== "adaptive"
391
+ ) {
392
+ return;
393
+ }
407
394
 
408
395
  const armPrefetch = (): (() => void) => {
396
+ const strategy = resolveAdaptiveStrategy(configuredStrategy);
397
+ const isViewport = strategy === "viewport";
398
+ const isRender = strategy === "render";
399
+ if (!isViewport && !isRender) return () => {};
400
+
409
401
  const element = internalRef.current;
410
402
  if (
411
403
  element &&
@@ -457,27 +449,30 @@ export const Link: ForwardRefExoticComponent<
457
449
  };
458
450
 
459
451
  let disarmPrefetch = armPrefetch();
452
+ const rearm = () => {
453
+ disarmPrefetch();
454
+ disarmPrefetch = armPrefetch();
455
+ };
460
456
  const element = internalRef.current;
461
457
  const unsubscribeScope = element
462
- ? subscribeToPrefetchScopeChange(element, () => {
463
- disarmPrefetch();
464
- disarmPrefetch = armPrefetch();
465
- })
458
+ ? subscribeToPrefetchScopeChange(element, rearm)
466
459
  : undefined;
467
460
  const unsubscribeLocation = subscribeToLocationChange(
468
461
  ctx.eventController,
469
- () => {
470
- disarmPrefetch();
471
- disarmPrefetch = armPrefetch();
472
- },
462
+ rearm,
473
463
  );
464
+ const unsubscribeAdaptive =
465
+ configuredStrategy === "adaptive"
466
+ ? subscribeToAdaptiveStrategyChange(rearm)
467
+ : undefined;
474
468
 
475
469
  return () => {
476
470
  unsubscribeScope?.();
477
471
  unsubscribeLocation();
472
+ unsubscribeAdaptive?.();
478
473
  disarmPrefetch();
479
474
  };
480
- }, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
475
+ }, [configuredStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
481
476
 
482
477
  return (
483
478
  <a
@@ -8,9 +8,11 @@ import React, {
8
8
  useMemo,
9
9
  useRef,
10
10
  use,
11
+ startTransition,
11
12
  type ReactNode,
12
13
  } from "react";
13
14
  import {
15
+ HydrationBarrierContext,
14
16
  LocationStateContext,
15
17
  NavigationStoreContext,
16
18
  type NavigationStoreContextValue,
@@ -37,6 +39,7 @@ import {
37
39
  deferredHandleNames,
38
40
  resolveDeferredHandleValues,
39
41
  } from "../../handles/deferred-resolution.js";
42
+ import { auditTreeUpdate } from "../../suspense-audit.js";
40
43
 
41
44
  /**
42
45
  * Process handles from an async generator, updating the event controller
@@ -278,6 +281,18 @@ export interface NavigationProviderProps {
278
281
  * it to seed a nonce so components calling useNonce() can be exercised.
279
282
  */
280
283
  nonce?: string;
284
+
285
+ /**
286
+ * The document this provider hydrates (initBrowserApp; renderRoute with
287
+ * `hydrate: true`), whose store EventController.lockHydration locked.
288
+ * `settled` resolves once the stream has ended and React has revealed every
289
+ * boundary. Until then location state is `undefined`, as SSR rendered it;
290
+ * then one transition gives the entry's state and changes
291
+ * HydrationBarrierContext, and the store is released after that commit. A
292
+ * navigation committed earlier releases the store before its update.
293
+ * Absent for a provider mounted on the client.
294
+ */
295
+ hydration?: { settled: Promise<void> };
281
296
  }
282
297
 
283
298
  /**
@@ -313,14 +328,18 @@ export function NavigationProvider({
313
328
  basename,
314
329
  appShellRef,
315
330
  nonce,
331
+ hydration,
316
332
  }: NavigationProviderProps): ReactNode {
317
333
  // Track current payload for rendering (this triggers re-renders)
318
334
  const [payload, setPayload] = useState(initialPayload);
319
335
  // The location state of the entry `payload` renders (LocationStateContext).
320
336
  // Only ever set next to setPayload, or for a commit that has no payload.
321
- const [locationState, setLocationState] = useState(
322
- eventController.getLocationState,
337
+ const [locationState, setLocationState] = useState(() =>
338
+ hydration ? undefined : eventController.getLocationState(),
323
339
  );
340
+ // HydrationBarrierContext's value; see NavigationProviderProps.hydration.
341
+ const [hydrationBarrier, setHydrationBarrier] = useState(0);
342
+ const hydrationOpen = useRef(hydration !== undefined);
324
343
 
325
344
  /**
326
345
  * Navigate to a URL (delegates to bridge)
@@ -356,6 +375,8 @@ export function NavigationProvider({
356
375
  navigate,
357
376
  refresh,
358
377
  defaultPrefetch: initialPayload.metadata.defaultPrefetch,
378
+ // Fixed for the document: read once, with the stable value.
379
+ origin: (eventController.getState().location as URL | undefined)?.origin,
359
380
  } as NavigationStoreContextValue;
360
381
  Object.defineProperty(value, "basename", {
361
382
  configurable: true,
@@ -379,6 +400,32 @@ export function NavigationProvider({
379
400
  return startConnectionWarmup();
380
401
  }, [warmupEnabled]);
381
402
 
403
+ // The document has been revealed: one transition gives the entry's location
404
+ // state and moves the barrier, so React hydrates every boundary still
405
+ // dehydrated before it commits.
406
+ useEffect(() => {
407
+ if (!hydration) return;
408
+ let cancelled = false;
409
+ void hydration.settled.then(() => {
410
+ if (cancelled || !hydrationOpen.current) return;
411
+ startTransition(() => {
412
+ setLocationState(eventController.getLocationState());
413
+ setHydrationBarrier((n) => n + 1);
414
+ });
415
+ });
416
+ return () => {
417
+ cancelled = true;
418
+ };
419
+ }, []);
420
+
421
+ // After the barrier's commit no boundary under this provider is dehydrated:
422
+ // release the store, and its handle writes reach their readers.
423
+ useEffect(() => {
424
+ if (hydrationBarrier === 0) return;
425
+ hydrationOpen.current = false;
426
+ eventController.releaseHydrationLock();
427
+ }, [hydrationBarrier]);
428
+
382
429
  // A commit no payload follows (a shallow navigation, a commit that keeps
383
430
  // every segment, merged server-set state): the tree on screen is already
384
431
  // the entry's, so its state is taken with the commit's notification.
@@ -430,12 +477,23 @@ export function NavigationProvider({
430
477
  // Subscribe to UI updates (for re-rendering the tree)
431
478
  useEffect(() => {
432
479
  const unsubscribe = store.onUpdate((update) => {
480
+ if (process.env.NODE_ENV !== "production") auditTreeUpdate(update.root);
433
481
  // Capture scroll intent — it will be applied in useLayoutEffect
434
482
  // after React commits this state update to the DOM.
435
483
  // Always assign (even undefined) to clear stale scroll from prior navigations,
436
484
  // so server actions or error updates don't accidentally replay old scroll.
437
485
  pendingScrollRef.current = update.scroll;
438
486
 
487
+ // Committed before the document finished hydrating: end the window
488
+ // first, so the page this update mounts reads the live store. A
489
+ // boundary of the document still dehydrated then hydrates against the
490
+ // live store; where that differs from its HTML, React reports the
491
+ // mismatch and renders the boundary on the client.
492
+ if (hydrationOpen.current) {
493
+ hydrationOpen.current = false;
494
+ eventController.releaseHydrationLock();
495
+ }
496
+
439
497
  setPayload({
440
498
  root: update.root,
441
499
  metadata: update.metadata,
@@ -529,7 +587,9 @@ export function NavigationProvider({
529
587
  return (
530
588
  <NavigationStoreContext.Provider value={contextValue}>
531
589
  <LocationStateContext.Provider value={locationState}>
532
- {content}
590
+ <HydrationBarrierContext.Provider value={hydrationBarrier}>
591
+ {content}
592
+ </HydrationBarrierContext.Provider>
533
593
  </LocationStateContext.Provider>
534
594
  </NavigationStoreContext.Provider>
535
595
  );
@@ -57,6 +57,13 @@ export interface NavigationStoreContextValue {
57
57
 
58
58
  /** Router default from this instance's initial payload. */
59
59
  defaultPrefetch?: PrefetchStrategy;
60
+
61
+ /**
62
+ * The document's origin, fixed for its lifetime. Link compares absolute
63
+ * hrefs against it (data-external) and useLinkStatus resolves link targets
64
+ * with it.
65
+ */
66
+ origin: string | undefined;
60
67
  }
61
68
 
62
69
  /**
@@ -79,3 +86,13 @@ export const NavigationStoreContext: Context<NavigationStoreContextValue | null>
79
86
  */
80
87
  export const LocationStateContext: Context<LocationStateSnapshot> =
81
88
  createContext<LocationStateSnapshot>(undefined);
89
+
90
+ /**
91
+ * Read by no component. NavigationProvider changes its value in a transition so
92
+ * that React hydrates every boundary still dehydrated beneath it before that
93
+ * commit: React hydrates a dehydrated boundary first when a provider above it
94
+ * changes, and nothing re-renders for a value nobody reads. See
95
+ * NavigationProviderProps.hydration.
96
+ */
97
+ export const HydrationBarrierContext: Context<number> =
98
+ createContext<number>(0);
@@ -1,7 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useContext, useEffect, useSyncExternalStore } from "react";
4
- import { subscribeToNothing } from "./subscribe-to-nothing.js";
3
+ import { useContext, useEffect } from "react";
5
4
  import { LocationStateContext } from "./context.js";
6
5
  import { OptimisticLocationContext } from "../../client-urls/optimistic-location.js";
7
6
  import {
@@ -19,15 +18,6 @@ export {
19
18
  type LocationStateOptions,
20
19
  } from "./location-state-shared.js";
21
20
 
22
- /**
23
- * Server and hydration renders must match the SSR output (`undefined`).
24
- * React then re-renders with `getSnapshot`, including a reader whose Suspense
25
- * boundary hydrates after the root has set `data-hydrated`.
26
- */
27
- function getServerSnapshot(): undefined {
28
- return undefined;
29
- }
30
-
31
21
  /**
32
22
  * Hook to read the location state of the history entry on screen
33
23
  *
@@ -73,16 +63,12 @@ export function useLocationState<TArgs extends unknown[], TState>(
73
63
  // Typed state: the slot under its key. Plain state: the `state` slot.
74
64
  // Inside an optimistically rendered clientUrls() destination the provider
75
65
  // still holds the entry being left: read the entry the navigation will push.
76
- const shown = (optimistic ? readableLocationState(optimistic) : entry)?.[
66
+ // While the document hydrates, the provider holds `undefined`, what SSR
67
+ // rendered, and gives the entry's state once every boundary has hydrated
68
+ // (NavigationProvider's hydration barrier, issue #992).
69
+ const state = (optimistic ? readableLocationState(optimistic) : entry)?.[
77
70
  key || "state"
78
71
  ] as TState | undefined;
79
- // useSyncExternalStore only for its server snapshot: no other hook tells a
80
- // hydrating render from a client one.
81
- const state = useSyncExternalStore<TState | undefined>(
82
- subscribeToNothing,
83
- () => shown,
84
- getServerSnapshot,
85
- );
86
72
 
87
73
  // Flash: removed from history.state after paint, so a reload or a return to
88
74
  // the entry does not show it again. The provider's snapshot keeps it, so it
@@ -149,7 +149,10 @@ export function useAction<T>(
149
149
  if (!ctx) {
150
150
  return selector ? selector(DEFAULT_ACTION_STATE) : DEFAULT_ACTION_STATE;
151
151
  }
152
- const state = ctx.eventController.getActionState(actionId);
152
+ // The document renders no action in flight.
153
+ const state = ctx.eventController.getHydrationSnapshot()
154
+ ? DEFAULT_ACTION_STATE
155
+ : ctx.eventController.getActionState(actionId);
153
156
  return selector ? selector(state) : state;
154
157
  });
155
158
  const prevSelected = useRef(baseState);
@@ -6,7 +6,6 @@ import {
6
6
  useEffect,
7
7
  useRef,
8
8
  useOptimistic,
9
- useSyncExternalStore,
10
9
  startTransition,
11
10
  } from "react";
12
11
  import type { Handle } from "../../handle.js";
@@ -14,9 +13,6 @@ import { collectHandleData } from "../../handle.js";
14
13
  import type { HandleData } from "../types.js";
15
14
  import { NavigationStoreContext } from "./context.js";
16
15
  import { shallowEqual } from "./shallow-equal.js";
17
- import { subscribeToNothing } from "./subscribe-to-nothing.js";
18
-
19
- const noHydrationState = (): undefined => undefined;
20
16
 
21
17
  /**
22
18
  * Hook to access collected handle data.
@@ -47,21 +43,6 @@ export function useHandle<T, A, S>(
47
43
  ): Rango.FlightSerialize<A> | S {
48
44
  const ctx = useContext(NavigationStoreContext);
49
45
 
50
- // useSyncExternalStore only for its server snapshot, as useLocationState
51
- // (#992): no other hook tells a hydrating render from a client one. A
52
- // hydrating render gets the handle state the document's HTML was rendered
53
- // with (EventController.getHydrationHandleState), whenever its boundary
54
- // hydrates; the mount effect below moves it on to the live state. That
55
- // state is undefined while the live state is still it, and the client
56
- // snapshot is always undefined: a reader that hydrated before anything
57
- // arrived late is not rendered again, and to React the store never
58
- // changes (issue #1035).
59
- const hydrationState = useSyncExternalStore(
60
- subscribeToNothing,
61
- noHydrationState,
62
- () => ctx?.eventController.getHydrationHandleState(),
63
- );
64
-
65
46
  const [value, setValue] = useState<Rango.FlightSerialize<A> | S>(() => {
66
47
  if (!ctx) {
67
48
  const collected = collectHandleData(
@@ -72,7 +53,10 @@ export function useHandle<T, A, S>(
72
53
  return selector ? selector(collected) : collected;
73
54
  }
74
55
 
75
- const state = hydrationState ?? ctx.eventController.getHandleState();
56
+ // Until every boundary has hydrated the store returns the state the
57
+ // document's HTML was rendered with (EventController.lockHydration), so a
58
+ // reader hydrating late reads what SSR rendered (issue #1035).
59
+ const state = ctx.eventController.getHandleState();
76
60
  const collected = collectHandleData(
77
61
  handle,
78
62
  state.data,
@@ -82,14 +82,15 @@ export function useLinkStatus(): LinkStatus {
82
82
  const linkTo = useContext(LinkContext);
83
83
  const ctx = useContext(NavigationStoreContext);
84
84
 
85
- const origin =
86
- typeof window !== "undefined" ? window.location.origin : "http://localhost";
85
+ const origin = ctx?.origin ?? "http://localhost";
87
86
 
88
87
  const [basePending, setBasePending] = useState<boolean>(() => {
89
88
  if (!ctx || linkTo === null) {
90
89
  return false;
91
90
  }
92
- const state = ctx.eventController.getState();
91
+ const state =
92
+ ctx.eventController.getHydrationSnapshot()?.state ??
93
+ ctx.eventController.getState();
93
94
  return isPendingFor(linkTo, state.pendingUrl, origin);
94
95
  });
95
96
 
@@ -47,7 +47,10 @@ export function useNavigation<T>(
47
47
  }
48
48
 
49
49
  const [baseValue, setBaseValue] = useState<T | PublicNavigationState>(() => {
50
- const publicState = toPublicState(ctx.eventController.getState());
50
+ const publicState = toPublicState(
51
+ ctx.eventController.getHydrationSnapshot()?.state ??
52
+ ctx.eventController.getState(),
53
+ );
51
54
  return selector ? selector(publicState) : publicState;
52
55
  });
53
56
  const prevState = useRef(baseValue);
@@ -49,7 +49,10 @@ export function useParams<T>(
49
49
  const optimistic = useContext(OptimisticLocationContext);
50
50
 
51
51
  const [value, setValue] = useState<T | Record<string, string>>(() => {
52
- const params = ctx ? ctx.eventController.getParams() : EMPTY_PARAMS;
52
+ const params = ctx
53
+ ? (ctx.eventController.getHydrationSnapshot()?.params ??
54
+ ctx.eventController.getParams())
55
+ : EMPTY_PARAMS;
53
56
  return selector ? selector(params) : params;
54
57
  });
55
58
 
@@ -26,7 +26,10 @@ export function usePathname(): string {
26
26
  if (!ctx) {
27
27
  return "/";
28
28
  }
29
- return (ctx.eventController.getState().location as URL).pathname;
29
+ const state =
30
+ ctx.eventController.getHydrationSnapshot()?.state ??
31
+ ctx.eventController.getState();
32
+ return (state.location as URL).pathname;
30
33
  });
31
34
 
32
35
  const prevPathname = useRef(pathname);
@@ -119,9 +119,11 @@ export function useSearchParams(): [ReadonlyURLSearchParams, SetSearchParams] {
119
119
  // (no provider) seeds empty.
120
120
  const [searchParams, setSearchParamsState] =
121
121
  useState<ReadonlyURLSearchParams>(() => {
122
- const location = ctx?.eventController.getState().location as
123
- | URL
124
- | undefined;
122
+ const state = ctx
123
+ ? (ctx.eventController.getHydrationSnapshot()?.state ??
124
+ ctx.eventController.getState())
125
+ : undefined;
126
+ const location = state?.location as URL | undefined;
125
127
  return new URLSearchParams(location?.searchParams);
126
128
  });
127
129
 
@@ -64,16 +64,22 @@ export function useSegments<T>(
64
64
  ): T | SegmentsState {
65
65
  const ctx = useContext(NavigationStoreContext);
66
66
 
67
- // Build initial state from event controller when context exists.
68
- // Inlined rather than calling recompute() because the segmentsCache ref
69
- // is not yet initialized during the useState initializer.
67
+ // The selector is applied when the store changes, never during render, as in
68
+ // useNavigation and useAction: the hook renders its own state only. A new
69
+ // selector identity is not a reactive input; it applies from the next store
70
+ // change.
71
+ const selectorRef = useRef(selector);
72
+ selectorRef.current = selector;
73
+
70
74
  const [state, setState] = useState<T | SegmentsState>(() => {
71
75
  if (!ctx) {
72
76
  const fallbackLocation = new URL("/", "http://localhost");
73
77
  const fallbackState = buildSegmentsState(fallbackLocation, []);
74
78
  return selector ? selector(fallbackState) : fallbackState;
75
79
  }
76
- const location = ctx.eventController.getLocation();
80
+ const location =
81
+ ctx.eventController.getHydrationSnapshot()?.state.location ??
82
+ ctx.eventController.getLocation();
77
83
  const handleState = ctx.eventController.getHandleState();
78
84
  const segmentsState = buildSegmentsState(
79
85
  location as URL,
@@ -83,69 +89,45 @@ export function useSegments<T>(
83
89
  });
84
90
 
85
91
  const prevState = useRef(state);
86
- const selectorRef = useRef(selector);
87
- selectorRef.current = selector;
88
-
89
- const prevSelectorIdentity = useRef(selector);
90
-
91
- const segmentsCache = useRef<{
92
- location: URL;
93
- routeSegmentIds: string[];
94
- state: SegmentsState;
95
- } | null>(null);
96
-
97
- function recompute(
98
- sel: ((state: SegmentsState) => T) | undefined,
99
- ): T | SegmentsState {
100
- const location = ctx!.eventController.getLocation();
101
- const handleState = ctx!.eventController.getHandleState();
102
92
 
103
- const cache = segmentsCache.current;
104
- let segmentsState: SegmentsState;
105
- if (
106
- cache &&
107
- cache.location === location &&
108
- cache.routeSegmentIds === handleState.routeSegmentIds
109
- ) {
110
- segmentsState = cache.state;
111
- } else {
112
- segmentsState = buildSegmentsState(
113
- location as URL,
114
- handleState.routeSegmentIds,
115
- );
116
- segmentsCache.current = {
117
- location: location as URL,
118
- routeSegmentIds: handleState.routeSegmentIds,
119
- state: segmentsState,
120
- };
121
- }
122
- return sel ? sel(segmentsState) : segmentsState;
123
- }
124
-
125
- if (ctx && selector !== prevSelectorIdentity.current) {
126
- prevSelectorIdentity.current = selector;
127
- const nextSelected = recompute(selector);
128
- if (!shallowEqual(nextSelected, prevState.current)) {
129
- prevState.current = nextSelected;
130
- setState(nextSelected);
131
- }
132
- }
133
-
134
- // Subscribe to store changes. The eager block above handles selector
135
- // changes and SSR drift, so no initial updateState() call is needed.
136
93
  useEffect(() => {
137
94
  if (!ctx) {
138
95
  return;
139
96
  }
140
97
 
98
+ // Reused while the location and route segments are unchanged, so a
99
+ // selector returning part of it keeps the same references.
100
+ let cache: {
101
+ location: URL;
102
+ routeSegmentIds: string[];
103
+ state: SegmentsState;
104
+ } | null = null;
105
+
141
106
  const updateState = () => {
142
- const nextSelected = recompute(selectorRef.current);
107
+ const location = ctx.eventController.getLocation() as URL;
108
+ const { routeSegmentIds } = ctx.eventController.getHandleState();
109
+ if (
110
+ !cache ||
111
+ cache.location !== location ||
112
+ cache.routeSegmentIds !== routeSegmentIds
113
+ ) {
114
+ cache = {
115
+ location,
116
+ routeSegmentIds,
117
+ state: buildSegmentsState(location, routeSegmentIds),
118
+ };
119
+ }
120
+ const sel = selectorRef.current;
121
+ const nextSelected = sel ? sel(cache.state) : cache.state;
143
122
  if (!shallowEqual(nextSelected, prevState.current)) {
144
123
  prevState.current = nextSelected;
145
124
  setState(nextSelected);
146
125
  }
147
126
  };
148
127
 
128
+ // Catch up with a change between the seeding render and this effect.
129
+ updateState();
130
+
149
131
  const unsubscribeNav = ctx.eventController.subscribe(updateState);
150
132
  const unsubscribeHandles =
151
133
  ctx.eventController.subscribeToHandles(updateState);