@rangojs/router 0.24.4 → 0.25.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 (267) hide show
  1. package/README.md +1 -0
  2. package/dist/bin/rango.js +363 -39
  3. package/dist/testing/vitest.js +37 -4
  4. package/dist/types/browser/event-controller.d.ts +24 -5
  5. package/dist/types/browser/intercept-utils.d.ts +5 -2
  6. package/dist/types/browser/merge-segment-loaders.d.ts +0 -23
  7. package/dist/types/browser/navigation-transaction.d.ts +5 -5
  8. package/dist/types/browser/partial-update.d.ts +21 -13
  9. package/dist/types/browser/prefetch/cache.d.ts +8 -0
  10. package/dist/types/browser/rango-marker.d.ts +7 -0
  11. package/dist/types/browser/react/filter-segment-order.d.ts +10 -0
  12. package/dist/types/browser/react/use-handle.d.ts +20 -0
  13. package/dist/types/browser/react/use-href.d.ts +6 -0
  14. package/dist/types/browser/react/use-router.d.ts +6 -1
  15. package/dist/types/browser/response-adapter.d.ts +5 -3
  16. package/dist/types/browser/scroll-restoration.d.ts +9 -3
  17. package/dist/types/browser/segment-reconciler.d.ts +10 -13
  18. package/dist/types/browser/types.d.ts +38 -8
  19. package/dist/types/build/route-types/per-module-writer.d.ts +12 -0
  20. package/dist/types/cache/cf/cf-shell-frame.d.ts +2 -0
  21. package/dist/types/cache/types.d.ts +8 -0
  22. package/dist/types/client-urls/client-urls.d.ts +6 -2
  23. package/dist/types/client-urls/revalidate-registry.d.ts +1 -1
  24. package/dist/types/client-urls/server-projection.d.ts +9 -5
  25. package/dist/types/client-urls/types.d.ts +29 -16
  26. package/dist/types/client.d.ts +2 -2
  27. package/dist/types/client.rsc.d.ts +2 -2
  28. package/dist/types/decode-loader-results.d.ts +28 -7
  29. package/dist/types/href-client.d.ts +3 -1
  30. package/dist/types/index.rsc.d.ts +1 -1
  31. package/dist/types/loader-redirect.d.ts +2 -3
  32. package/dist/types/outlet-context.d.ts +7 -6
  33. package/dist/types/prefetch-markers.d.ts +14 -5
  34. package/dist/types/route-content-wrapper.d.ts +17 -14
  35. package/dist/types/route-definition/helpers-types.d.ts +7 -2
  36. package/dist/types/route-definition/resolve-handler-use.d.ts +6 -1
  37. package/dist/types/route-definition/rewrite.d.ts +48 -5
  38. package/dist/types/route-types.d.ts +2 -2
  39. package/dist/types/router/handler-context.d.ts +14 -2
  40. package/dist/types/router/intercept-resolution.d.ts +19 -46
  41. package/dist/types/router/lazy-includes.d.ts +7 -0
  42. package/dist/types/router/match-middleware/intercept-resolution.d.ts +2 -2
  43. package/dist/types/router/middleware.d.ts +0 -16
  44. package/dist/types/router/navigation-snapshot.d.ts +2 -0
  45. package/dist/types/router/prefetch-cache-ttl.d.ts +8 -11
  46. package/dist/types/router/revalidation.d.ts +8 -1
  47. package/dist/types/router/router-context.d.ts +4 -16
  48. package/dist/types/router/router-interfaces.d.ts +0 -6
  49. package/dist/types/router/router-options.d.ts +4 -7
  50. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +31 -8
  51. package/dist/types/router/segment-resolution/revalidation.d.ts +38 -4
  52. package/dist/types/router/segment-resolution.d.ts +1 -1
  53. package/dist/types/router/segment-wrappers.d.ts +3 -5
  54. package/dist/types/router/substitute-pattern-params.d.ts +3 -2
  55. package/dist/types/router/tracing.d.ts +2 -2
  56. package/dist/types/rsc/full-payload.d.ts +14 -2
  57. package/dist/types/rsc/helpers.d.ts +8 -0
  58. package/dist/types/rsc/manifest-init.d.ts +20 -0
  59. package/dist/types/rsc/prefetch-render-error.d.ts +22 -0
  60. package/dist/types/rsc/progressive-enhancement.d.ts +20 -0
  61. package/dist/types/rsc/rewrite-passes.d.ts +12 -2
  62. package/dist/types/rsc/server-action.d.ts +6 -0
  63. package/dist/types/rsc/shell-capture-constants.d.ts +10 -1
  64. package/dist/types/rsc/shell-capture.d.ts +7 -0
  65. package/dist/types/rsc/types.d.ts +5 -0
  66. package/dist/types/segment-system.d.ts +15 -0
  67. package/dist/types/server/context.d.ts +64 -4
  68. package/dist/types/server/request-context.d.ts +41 -4
  69. package/dist/types/server/request-headers.d.ts +12 -0
  70. package/dist/types/server/rewrite-trail.d.ts +8 -1
  71. package/dist/types/testing/e2e/index.d.ts +1 -1
  72. package/dist/types/testing/flight.entry.d.ts +1 -1
  73. package/dist/types/testing/index.d.ts +1 -1
  74. package/dist/types/testing/internal/client-urls-projection.d.ts +4 -3
  75. package/dist/types/testing/render-route.d.ts +21 -11
  76. package/dist/types/testing/run-loader.d.ts +7 -0
  77. package/dist/types/testing/run-middleware.d.ts +7 -0
  78. package/dist/types/testing/run-revalidate.d.ts +5 -4
  79. package/dist/types/testing/serve-shell-request.d.ts +41 -4
  80. package/dist/types/testing/shell-status.d.ts +17 -0
  81. package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
  82. package/dist/types/testing/vitest.d.ts +10 -1
  83. package/dist/types/thenable-status.d.ts +0 -6
  84. package/dist/types/types/handler-context.d.ts +12 -1
  85. package/dist/types/types/loader-types.d.ts +4 -3
  86. package/dist/types/types/route-entry.d.ts +2 -1
  87. package/dist/types/urls/path-helper-types.d.ts +4 -2
  88. package/dist/types/vite/discovery/client-urls-projection.d.ts +55 -5
  89. package/dist/types/vite/discovery/state.d.ts +20 -0
  90. package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +28 -16
  91. package/dist/types/vite/plugins/hoist/source.d.ts +8 -7
  92. package/dist/types/vite/plugins/virtual-entries.d.ts +6 -0
  93. package/dist/types/vite/utils/prerender-utils.d.ts +7 -0
  94. package/dist/vite/index.js +6816 -6547
  95. package/package.json +1 -1
  96. package/skills/app-structure/SKILL.md +255 -0
  97. package/skills/app-structure/content-site.md +333 -0
  98. package/skills/app-structure/e-commerce.md +435 -0
  99. package/skills/app-structure/large-app.md +885 -0
  100. package/skills/app-structure/multi-app.md +280 -0
  101. package/skills/breadcrumbs/SKILL.md +6 -1
  102. package/skills/caching/SKILL.md +3 -1
  103. package/skills/catalog.json +12 -0
  104. package/skills/client-urls/SKILL.md +185 -32
  105. package/skills/composability/SKILL.md +10 -8
  106. package/skills/handler-use/SKILL.md +48 -9
  107. package/skills/hooks/handle-and-actions.md +104 -0
  108. package/skills/hooks/navigation.md +24 -10
  109. package/skills/hooks/urls.md +1 -1
  110. package/skills/i18n/SKILL.md +2 -1
  111. package/skills/intercept/SKILL.md +60 -29
  112. package/skills/layout/SKILL.md +6 -3
  113. package/skills/links/SKILL.md +151 -17
  114. package/skills/loader/SKILL.md +32 -10
  115. package/skills/middleware/SKILL.md +111 -15
  116. package/skills/observability/SKILL.md +11 -0
  117. package/skills/parallel/SKILL.md +5 -5
  118. package/skills/ppr/SKILL.md +43 -8
  119. package/skills/prerender/SKILL.md +1 -2
  120. package/skills/rango/SKILL.md +3 -0
  121. package/skills/response-routes/SKILL.md +4 -1
  122. package/skills/scripts/SKILL.md +12 -1
  123. package/skills/server-actions/SKILL.md +60 -20
  124. package/skills/testing/SKILL.md +1 -0
  125. package/skills/testing/client-components.md +13 -10
  126. package/skills/testing/loader.md +25 -24
  127. package/skills/testing/middleware.md +11 -11
  128. package/skills/testing/response-routes.md +4 -1
  129. package/skills/testing/serve-shell-request.md +85 -21
  130. package/skills/ux-architecture/SKILL.md +980 -0
  131. package/skills/view-transitions/SKILL.md +43 -10
  132. package/src/__augment-tests__/augmented.check.ts +28 -0
  133. package/src/browser/event-controller.ts +44 -18
  134. package/src/browser/intercept-utils.ts +3 -2
  135. package/src/browser/merge-segment-loaders.ts +0 -85
  136. package/src/browser/navigation-bridge.ts +112 -84
  137. package/src/browser/navigation-client.ts +20 -15
  138. package/src/browser/navigation-store.ts +4 -2
  139. package/src/browser/navigation-transaction.ts +9 -8
  140. package/src/browser/partial-update.ts +90 -32
  141. package/src/browser/prefetch/cache.ts +8 -0
  142. package/src/browser/prefetch/fetch.ts +13 -3
  143. package/src/browser/rango-marker.ts +13 -0
  144. package/src/browser/react/NavigationProvider.tsx +25 -7
  145. package/src/browser/react/filter-segment-order.ts +13 -0
  146. package/src/browser/react/use-action.ts +9 -15
  147. package/src/browser/react/use-handle.ts +63 -71
  148. package/src/browser/react/use-href.tsx +24 -5
  149. package/src/browser/react/use-link-status.ts +1 -1
  150. package/src/browser/react/use-navigation.ts +30 -40
  151. package/src/browser/react/use-router.ts +37 -6
  152. package/src/browser/react/use-segments.ts +8 -12
  153. package/src/browser/response-adapter.ts +11 -5
  154. package/src/browser/rsc-router.tsx +3 -1
  155. package/src/browser/scroll-restoration.ts +34 -20
  156. package/src/browser/segment-reconciler.ts +11 -29
  157. package/src/browser/server-action-bridge.ts +52 -16
  158. package/src/browser/types.ts +40 -8
  159. package/src/build/generate-manifest.ts +12 -1
  160. package/src/build/route-types/codegen.ts +6 -2
  161. package/src/build/route-types/per-module-writer.ts +94 -61
  162. package/src/cache/cache-runtime.ts +9 -0
  163. package/src/cache/cache-scope.ts +2 -1
  164. package/src/cache/cf/cf-cache-store.ts +2 -0
  165. package/src/cache/cf/cf-shell-frame.ts +3 -0
  166. package/src/cache/types.ts +8 -0
  167. package/src/cache/vercel/vercel-cache-store.ts +6 -1
  168. package/src/client-urls/client-urls.ts +202 -153
  169. package/src/client-urls/revalidate-registry.ts +12 -3
  170. package/src/client-urls/server-projection.ts +137 -66
  171. package/src/client-urls/types.ts +30 -15
  172. package/src/client.rsc.tsx +11 -1
  173. package/src/client.tsx +9 -1
  174. package/src/decode-loader-results.ts +64 -31
  175. package/src/href-client.ts +3 -1
  176. package/src/index.rsc.ts +1 -0
  177. package/src/index.ts +12 -4
  178. package/src/loader-redirect.tsx +2 -3
  179. package/src/outlet-context.ts +7 -6
  180. package/src/prefetch-markers.ts +15 -5
  181. package/src/reverse.ts +3 -3
  182. package/src/route-content-wrapper.tsx +19 -17
  183. package/src/route-definition/dsl-helpers.ts +27 -7
  184. package/src/route-definition/helpers-types.ts +7 -2
  185. package/src/route-definition/resolve-handler-use.ts +23 -10
  186. package/src/route-definition/rewrite.ts +86 -19
  187. package/src/route-types.ts +1 -2
  188. package/src/router/find-match.ts +37 -1
  189. package/src/router/handler-context.ts +85 -40
  190. package/src/router/intercept-resolution.ts +161 -307
  191. package/src/router/lazy-includes.ts +43 -27
  192. package/src/router/loader-resolution.ts +21 -1
  193. package/src/router/manifest.ts +6 -8
  194. package/src/router/match-api.ts +11 -1
  195. package/src/router/match-middleware/background-revalidation.ts +2 -4
  196. package/src/router/match-middleware/cache-lookup.ts +16 -20
  197. package/src/router/match-middleware/intercept-resolution.ts +41 -60
  198. package/src/router/match-result.ts +31 -2
  199. package/src/router/middleware.ts +6 -147
  200. package/src/router/navigation-snapshot.ts +38 -2
  201. package/src/router/prefetch-cache-ttl.ts +10 -18
  202. package/src/router/prerender-match.ts +2 -1
  203. package/src/router/revalidation.ts +24 -3
  204. package/src/router/router-context.ts +5 -18
  205. package/src/router/router-interfaces.ts +0 -7
  206. package/src/router/router-options.ts +4 -7
  207. package/src/router/segment-resolution/fresh.ts +6 -2
  208. package/src/router/segment-resolution/helpers.ts +7 -1
  209. package/src/router/segment-resolution/prefetch-deferral.ts +62 -12
  210. package/src/router/segment-resolution/revalidation.ts +75 -35
  211. package/src/router/segment-resolution.ts +0 -1
  212. package/src/router/segment-wrappers.ts +8 -13
  213. package/src/router/substitute-pattern-params.ts +3 -2
  214. package/src/router/tracing.ts +2 -2
  215. package/src/router.ts +2 -9
  216. package/src/rsc/full-payload.ts +26 -4
  217. package/src/rsc/handler.ts +83 -41
  218. package/src/rsc/helpers.ts +13 -0
  219. package/src/rsc/manifest-init.ts +56 -3
  220. package/src/rsc/prefetch-render-error.ts +38 -0
  221. package/src/rsc/progressive-enhancement.ts +161 -211
  222. package/src/rsc/rewrite-passes.ts +56 -9
  223. package/src/rsc/rsc-rendering.ts +79 -23
  224. package/src/rsc/server-action.ts +25 -2
  225. package/src/rsc/shell-capture-constants.ts +22 -0
  226. package/src/rsc/shell-capture.ts +11 -0
  227. package/src/rsc/types.ts +5 -0
  228. package/src/segment-system.tsx +125 -119
  229. package/src/server/context.ts +96 -5
  230. package/src/server/handle-store.ts +3 -2
  231. package/src/server/request-context.ts +42 -3
  232. package/src/server/request-headers.ts +32 -0
  233. package/src/server/rewrite-trail.ts +55 -9
  234. package/src/ssr/ssr-root.tsx +6 -10
  235. package/src/testing/dispatch.ts +4 -5
  236. package/src/testing/e2e/index.ts +1 -0
  237. package/src/testing/flight.entry.ts +1 -0
  238. package/src/testing/index.ts +1 -0
  239. package/src/testing/internal/client-urls-projection.ts +31 -11
  240. package/src/testing/render-route.tsx +83 -40
  241. package/src/testing/run-loader.ts +26 -2
  242. package/src/testing/run-middleware.ts +10 -0
  243. package/src/testing/run-revalidate.ts +13 -6
  244. package/src/testing/serve-shell-request.ts +77 -4
  245. package/src/testing/shell-status.ts +40 -0
  246. package/src/testing/vitest-stubs/version.ts +1 -0
  247. package/src/testing/vitest.ts +74 -7
  248. package/src/thenable-status.ts +0 -11
  249. package/src/types/handler-context.ts +12 -1
  250. package/src/types/loader-types.ts +4 -3
  251. package/src/types/route-entry.ts +2 -1
  252. package/src/urls/include-helper.ts +38 -11
  253. package/src/urls/path-helper-types.ts +4 -2
  254. package/src/vite/discovery/client-urls-projection.ts +205 -16
  255. package/src/vite/discovery/discover-routers.ts +2 -2
  256. package/src/vite/discovery/prerender-collection.ts +2 -6
  257. package/src/vite/discovery/state.ts +26 -0
  258. package/src/vite/plugins/client-urls-revalidate-hoist.ts +190 -77
  259. package/src/vite/plugins/expose-internal-ids.ts +46 -40
  260. package/src/vite/plugins/hoist/source.ts +8 -7
  261. package/src/vite/plugins/transition-when-hoist.ts +1 -0
  262. package/src/vite/plugins/version-plugin.ts +7 -0
  263. package/src/vite/plugins/version.d.ts +7 -0
  264. package/src/vite/plugins/virtual-entries.ts +20 -0
  265. package/src/vite/rango.ts +4 -1
  266. package/src/vite/router-discovery.ts +39 -7
  267. package/src/vite/utils/prerender-utils.ts +17 -0
@@ -1,18 +1,12 @@
1
1
  "use client";
2
2
 
3
- import {
4
- useContext,
5
- useState,
6
- useEffect,
7
- useRef,
8
- useOptimistic,
9
- startTransition,
10
- } from "react";
3
+ import { useContext, useState, useEffect, useRef } from "react";
11
4
  import type { Handle } from "../../handle.js";
12
5
  import { collectHandleData } from "../../handle.js";
13
6
  import type { HandleData } from "../types.js";
14
7
  import { NavigationStoreContext } from "./context.js";
15
8
  import { shallowEqual } from "./shallow-equal.js";
9
+ import type { RouteStateSignal } from "../event-controller.js";
16
10
 
17
11
  /**
18
12
  * Hook to access collected handle data.
@@ -43,77 +37,75 @@ export function useHandle<T, A, S>(
43
37
  ): Rango.FlightSerialize<A> | S {
44
38
  const ctx = useContext(NavigationStoreContext);
45
39
 
46
- const [value, setValue] = useState<Rango.FlightSerialize<A> | S>(() => {
47
- if (!ctx) {
48
- const collected = collectHandleData(
49
- handle,
50
- {},
51
- [],
52
- ) as Rango.FlightSerialize<A>;
53
- return selector ? selector(collected) : collected;
54
- }
40
+ const selectorRef = useRef(selector);
41
+ selectorRef.current = selector;
55
42
 
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();
43
+ // Until every boundary has hydrated the store returns the state the
44
+ // document's HTML was rendered with (EventController.lockHydration), so a
45
+ // reader hydrating late reads what SSR rendered (issue #1035).
46
+ const collect = (): Rango.FlightSerialize<A> | S => {
47
+ const state = ctx?.eventController.getHandleState();
60
48
  const collected = collectHandleData(
61
49
  handle,
62
- state.data,
63
- state.segmentOrder,
50
+ state?.data ?? {},
51
+ state?.segmentOrder ?? [],
64
52
  ) as Rango.FlightSerialize<A>;
65
- return selector ? selector(collected) : collected;
66
- });
67
- const [optimisticValue, setOptimisticValue] = useOptimistic(value);
68
-
69
- const prevValueRef = useRef(value);
70
- prevValueRef.current = value;
53
+ return selectorRef.current ? selectorRef.current(collected) : collected;
54
+ };
71
55
 
72
- const selectorRef = useRef(selector);
73
- selectorRef.current = selector;
56
+ const [value, setValue] = useCommitAnchoredState(collect);
74
57
 
75
58
  useEffect(() => {
76
59
  if (!ctx) return;
77
-
78
- // Sync current state for the (possibly new) handle so that switching
79
- // handles on an idle page doesn't leave stale data from the old handle.
80
- const currentHandleState = ctx.eventController.getHandleState();
81
- const currentCollected = collectHandleData(
82
- handle,
83
- currentHandleState.data,
84
- currentHandleState.segmentOrder,
85
- ) as Rango.FlightSerialize<A>;
86
- const currentValue = selectorRef.current
87
- ? selectorRef.current(currentCollected)
88
- : currentCollected;
89
- if (!shallowEqual(currentValue, prevValueRef.current)) {
90
- prevValueRef.current = currentValue;
91
- setValue(currentValue);
92
- }
93
-
94
- return ctx.eventController.subscribeToHandles(() => {
95
- const state = ctx.eventController.getHandleState();
96
- const isAction =
97
- ctx.eventController.getState().inflightActions.length > 0;
98
- const collected = collectHandleData(
99
- handle,
100
- state.data,
101
- state.segmentOrder,
102
- ) as Rango.FlightSerialize<A>;
103
- const nextValue = selectorRef.current
104
- ? selectorRef.current(collected)
105
- : collected;
106
-
107
- if (!shallowEqual(nextValue, prevValueRef.current)) {
108
- prevValueRef.current = nextValue;
109
- startTransition(() => {
110
- // Skip optimistic update during actions to prevent Suspense fallback
111
- if (!isAction) setOptimisticValue(nextValue);
112
- setValue(nextValue);
113
- });
114
- }
115
- });
60
+ const update = (signal?: RouteStateSignal): void =>
61
+ setValue(collect(), signal);
62
+ // A (possibly new) handle on an idle page reads the current state.
63
+ update();
64
+ return ctx.eventController.subscribeToHandles(update);
116
65
  }, [handle]);
117
66
 
118
- return optimisticValue;
67
+ return value;
68
+ }
69
+
70
+ /**
71
+ * Route state the controller's handle notifications drive (useHandle,
72
+ * useSegments) commits with the page it belongs to. NavigationProvider
73
+ * flushes the controller inside every payload update, so a value known then
74
+ * is set in the page's lane. A page handed over urgently (signal "release")
75
+ * takes a value that arrives before it commits the same way: set at default
76
+ * priority, it renders in the page's lane, and the page's effects read it.
77
+ * A page in a transition (signal "anchor") does not: until it commits
78
+ * (releaseRouteState() from the provider's layout effect, signal "release"),
79
+ * a new value waits, and is applied in the commit's task, right after the
80
+ * page's own commit. In the page's lane, a value that suspends in a revealed
81
+ * <Suspense> (a crumb's content) would hold the page; in a lane of its own,
82
+ * it commits over the page being left and takes the page's transition types
83
+ * (#1165); and a transition entangled with the page misses its commit when it
84
+ * is set after React rendered the page, before its view transition's
85
+ * mutation, and starts a view transition of its own. A value shallow-equal to
86
+ * the last one sets nothing, so a reader renders only when its value moves.
87
+ */
88
+ export function useCommitAnchoredState<T>(
89
+ init: () => T,
90
+ ): [T, (value: T, signal?: RouteStateSignal) => void] {
91
+ // An app that enables the React Compiler compiles the router too; its memo
92
+ // cache gains nothing here and cost 107 B gzip in the router chunk.
93
+ "use no memo";
94
+ const [value, setValue] = useState(init);
95
+ const ref = useRef<{ last: T; held?: boolean; waiting?: boolean }>({
96
+ last: value,
97
+ });
98
+ const [set] = useState(() => (next: T, signal?: RouteStateSignal): void => {
99
+ const r = ref.current;
100
+ if (signal) r.waiting = signal === "anchor";
101
+ if (!shallowEqual(next, r.last)) {
102
+ r.last = next;
103
+ r.held = true;
104
+ }
105
+ // The anchor's own change goes in the page's lane, with the page.
106
+ if (!r.held || (r.waiting && signal !== "anchor")) return;
107
+ r.held = false;
108
+ setValue(() => r.last);
109
+ });
110
+ return [value, set];
119
111
  }
@@ -2,7 +2,9 @@
2
2
 
3
3
  import { useCallback } from "react";
4
4
  import { href, type ValidPaths } from "../../href-client.js";
5
+ import { substitutePatternParams } from "../../router/substitute-pattern-params.js";
5
6
  import { useMount } from "./use-mount.js";
7
+ import { useParams } from "./use-params.js";
6
8
 
7
9
  /**
8
10
  * Client-side hook for mount-aware URL resolution.
@@ -11,6 +13,12 @@ import { useMount } from "./use-mount.js";
11
13
  * include() mount prefix. Inside an include("/shop", ...) scope,
12
14
  * the returned function resolves local paths relative to /shop.
13
15
  *
16
+ * Under a parameterized mount (`include("/:accountId", ...)`) the mount's
17
+ * params are filled from the current route params, as `useReverse` does:
18
+ * `href("/issues")` at `/acme/board` returns `/acme/issues`. A mount param
19
+ * missing from the current match throws `Missing param` when the returned
20
+ * function is called.
21
+ *
14
22
  * For absolute paths (outside the current mount), use the bare
15
23
  * href() function directly instead.
16
24
  *
@@ -37,11 +45,22 @@ import { useMount } from "./use-mount.js";
37
45
  */
38
46
  export function useHref(): (path: `/${string}`) => string {
39
47
  const mount = useMount();
40
- // Memoize on `mount` (stable within a route) so the returned function is
41
- // referentially stable across re-renders — a consumer can safely pass it as a
42
- // dependency or a prop to a memoized child without forcing re-renders.
48
+ // The filled mount (it starts with "/"), or the Missing param message. The
49
+ // selector also runs in the params subscription, so it must not throw, and
50
+ // a string lets useParams skip the re-render, and keeps the returned
51
+ // function stable, unless the mount's own values change.
52
+ const base = useParams((p) => {
53
+ try {
54
+ return substitutePatternParams(mount, p, mount);
55
+ } catch (e) {
56
+ return (e as Error).message;
57
+ }
58
+ });
43
59
  return useCallback(
44
- (path: `/${string}`) => href(path as ValidPaths, mount),
45
- [mount],
60
+ (path: `/${string}`) => {
61
+ if (!base.startsWith("/")) throw new Error(base);
62
+ return href(path as ValidPaths, base);
63
+ },
64
+ [base],
46
65
  );
47
66
  }
@@ -100,7 +100,7 @@ export function useLinkStatus(): LinkStatus {
100
100
  // to a non-idle (loading) state. Used to decide whether to emit a release
101
101
  // update when returning to idle, so the optimistic store doesn't stay pinned
102
102
  // to `true` if a parent transition (e.g. the <Link> click / view transition
103
- // commit) is still pending. Mirrors useNavigation's optimisticPinnedRef.
103
+ // commit) is still pending.
104
104
  const optimisticPinnedRef = useRef(false);
105
105
 
106
106
  const [pending, setOptimisticPending] = useOptimistic(basePending);
@@ -4,7 +4,7 @@ import {
4
4
  useContext,
5
5
  useState,
6
6
  useEffect,
7
- useOptimistic,
7
+ useLayoutEffect,
8
8
  startTransition,
9
9
  useRef,
10
10
  } from "react";
@@ -46,22 +46,19 @@ export function useNavigation<T>(
46
46
  throw new Error("useNavigation must be used within NavigationProvider");
47
47
  }
48
48
 
49
- const [baseValue, setBaseValue] = useState<T | PublicNavigationState>(() => {
49
+ const [value, setValue] = useState<T | PublicNavigationState>(() => {
50
50
  const publicState = toPublicState(
51
51
  ctx.eventController.getHydrationSnapshot()?.state ??
52
52
  ctx.eventController.getState(),
53
53
  );
54
54
  return selector ? selector(publicState) : publicState;
55
55
  });
56
- const prevState = useRef(baseValue);
57
-
58
- // Tracks whether the most recent setOptimisticValue call pinned the value
59
- // to a non-idle state. Used to decide whether to emit a release update when
60
- // returning to idle, so the optimistic store doesn't stay pinned if a
61
- // parent transition (e.g. <Link> click) is still pending.
62
- const optimisticPinnedRef = useRef(false);
63
-
64
- const [value, setOptimisticValue] = useOptimistic(baseValue);
56
+ const prevState = useRef(value);
57
+ // The value on screen: a set value that differs has not committed yet.
58
+ const committedRef = useRef(value);
59
+ useLayoutEffect(() => {
60
+ committedRef.current = value;
61
+ });
65
62
 
66
63
  // Store selector in a ref so the subscription callback always uses the
67
64
  // latest selector without re-subscribing on every render (inline functions
@@ -71,10 +68,28 @@ export function useNavigation<T>(
71
68
  const selectorRef = useRef(selector);
72
69
  selectorRef.current = selector;
73
70
 
74
- // Subscribe to event controller state changes (only runs on client)
71
+ // Subscribe to event controller state changes (only runs on client).
72
+ //
73
+ // The value commits in the lane of the notification that carries it, as
74
+ // the location hooks' values do: NavigationProvider flushes a navigation's
75
+ // notification into the transition that commits its tree, so idle commits
76
+ // with the destination's location (useSearchParams, usePathname,
77
+ // useParams). An optimistic update renders in the sync lane, ahead of that
78
+ // transition, and put idle on screen with the location being left (#1164).
75
79
  useEffect(() => {
80
+ let last: DerivedNavigationState | undefined;
76
81
  const update = () => {
77
82
  const currentState = ctx.eventController.getState();
83
+ // Neither the state nor the location moved (the stream starting or
84
+ // ending): while a value of this hook's is held, a transition, which
85
+ // React entangles with it, so it never commits ahead of the location.
86
+ // Otherwise urgent: a transition of its own would start a view
87
+ // transition, and could commit ahead of a held page and take its
88
+ // transition types.
89
+ const quiet =
90
+ currentState.state === last?.state &&
91
+ currentState.location === last.location;
92
+ last = currentState;
78
93
  const publicState = toPublicState(currentState);
79
94
  const nextSelected = selectorRef.current
80
95
  ? selectorRef.current(publicState)
@@ -82,35 +97,10 @@ export function useNavigation<T>(
82
97
 
83
98
  // Check if selected value has changed
84
99
  if (!shallowEqual(nextSelected, prevState.current)) {
100
+ const held = prevState.current !== committedRef.current;
85
101
  prevState.current = nextSelected;
86
-
87
- // Check if any actions are in progress for optimistic updates
88
- const hasInflightActions =
89
- ctx.eventController.getInflightActions().size > 0;
90
-
91
- const shouldPin = hasInflightActions || publicState.state !== "idle";
92
-
93
- if (shouldPin) {
94
- // Pin the optimistic store so the loading value shows immediately
95
- // even if a parent transition (e.g. <Link> click) defers the
96
- // urgent setBaseValue commit.
97
- startTransition(() => {
98
- setOptimisticValue(nextSelected);
99
- });
100
- optimisticPinnedRef.current = true;
101
- } else if (optimisticPinnedRef.current) {
102
- // Release a previously-pinned optimistic value. Without this,
103
- // useOptimistic keeps returning the stale loading value while
104
- // any parent transition is still pending, even after baseValue
105
- // flipped to idle.
106
- startTransition(() => {
107
- setOptimisticValue(nextSelected);
108
- });
109
- optimisticPinnedRef.current = false;
110
- }
111
-
112
- // Always update base state so UI reflects current state
113
- setBaseValue(nextSelected);
102
+ if (quiet && held) startTransition(() => setValue(nextSelected));
103
+ else setValue(nextSelected);
114
104
  }
115
105
  };
116
106
 
@@ -6,6 +6,8 @@ import { useMount } from "./use-mount.js";
6
6
  import { prefetchDirect } from "../prefetch/loader.js";
7
7
  import { getAppVersion } from "../app-version.js";
8
8
  import { prefixBasename } from "../../router/basename.js";
9
+ import { substitutePatternParams } from "../../router/substitute-pattern-params.js";
10
+ import { href, type ValidPaths } from "../../href-client.js";
9
11
  import type { RouterInstance, RouterNavigateOptions } from "../types.js";
10
12
 
11
13
  /**
@@ -22,7 +24,12 @@ import type { RouterInstance, RouterNavigateOptions } from "../types.js";
22
24
  *
23
25
  * RELATIVE paths (no leading `/`, no scheme/query/hash) resolve against the
24
26
  * current include() mount: `router.push("cart")` inside `include("/shop")`
25
- * navigates to `/shop/cart`. Absolute paths stay APP-absolute — unlike
27
+ * navigates to `/shop/cart`. Under a parameterized mount
28
+ * (`include("/:accountId", ...)`) the mount's params are filled from the
29
+ * current route params: `router.push("issues")` at `/acme/board` navigates
30
+ * to `/acme/issues`, and a missing mount param rejects the push/replace
31
+ * promise with `Missing param` (a prefetch skips the target).
32
+ * Absolute paths stay APP-absolute — unlike
26
33
  * basename, the mount is scoped, and absolute pushes legitimately target
27
34
  * outside it, so they are never auto-prefixed.
28
35
  *
@@ -70,16 +77,32 @@ export function useRouter(): RouterInstance {
70
77
  ) {
71
78
  return withBasename(url);
72
79
  }
73
- const base = mount === "/" ? "" : mount.replace(/\/$/, "");
74
- return withBasename(`${base}/${relative}`);
80
+ // The mount is the include()'s URL pattern: fill its params from the
81
+ // committed params at call time (not render time, so this object stays
82
+ // stable across navigations), as useHref does.
83
+ return withBasename(
84
+ href(
85
+ `/${relative}` as ValidPaths,
86
+ substitutePatternParams(
87
+ mount,
88
+ ctx!.eventController.getParams(),
89
+ mount,
90
+ ),
91
+ ),
92
+ );
75
93
  }
76
94
 
95
+ // push/replace are async so a target that cannot resolve (a missing
96
+ // mount param) rejects the returned promise instead of throwing.
77
97
  return {
78
- push(url: string, options?: RouterNavigateOptions): Promise<void> {
98
+ async push(url: string, options?: RouterNavigateOptions): Promise<void> {
79
99
  return ctx.navigate(resolveTarget(url), { ...options, replace: false });
80
100
  },
81
101
 
82
- replace(url: string, options?: RouterNavigateOptions): Promise<void> {
102
+ async replace(
103
+ url: string,
104
+ options?: RouterNavigateOptions,
105
+ ): Promise<void> {
83
106
  return ctx.navigate(resolveTarget(url), { ...options, replace: true });
84
107
  },
85
108
 
@@ -90,8 +113,16 @@ export function useRouter(): RouterInstance {
90
113
  prefetch(url: string, options?: { key?: ":source" }): void {
91
114
  const segmentState = ctx.store?.getSegmentState();
92
115
  if (segmentState) {
116
+ let target: string;
117
+ try {
118
+ target = resolveTarget(url);
119
+ } catch {
120
+ // A prefetch is a hint: a target that cannot resolve (missing
121
+ // mount param) is skipped; push/replace report the error.
122
+ return;
123
+ }
93
124
  prefetchDirect(
94
- resolveTarget(url),
125
+ target,
95
126
  segmentState.currentSegmentIds,
96
127
  getAppVersion(),
97
128
  ctx.store?.getRouterId?.(),
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
 
3
- import { useContext, useState, useEffect, useRef } from "react";
3
+ import { useContext, useEffect, useRef } from "react";
4
4
  import { NavigationStoreContext } from "./context.js";
5
- import { shallowEqual } from "./shallow-equal.js";
5
+ import { useCommitAnchoredState } from "./use-handle.js";
6
+ import type { RouteStateSignal } from "../event-controller.js";
6
7
 
7
8
  /**
8
9
  * Segments state returned by useSegments hook
@@ -71,7 +72,8 @@ export function useSegments<T>(
71
72
  const selectorRef = useRef(selector);
72
73
  selectorRef.current = selector;
73
74
 
74
- const [state, setState] = useState<T | SegmentsState>(() => {
75
+ // Commits with the page it describes (useCommitAnchoredState).
76
+ const [state, setState] = useCommitAnchoredState<T | SegmentsState>(() => {
75
77
  if (!ctx) {
76
78
  const fallbackLocation = new URL("/", "http://localhost");
77
79
  const fallbackState = buildSegmentsState(fallbackLocation, []);
@@ -88,8 +90,6 @@ export function useSegments<T>(
88
90
  return selector ? selector(segmentsState) : segmentsState;
89
91
  });
90
92
 
91
- const prevState = useRef(state);
92
-
93
93
  useEffect(() => {
94
94
  if (!ctx) {
95
95
  return;
@@ -103,7 +103,7 @@ export function useSegments<T>(
103
103
  state: SegmentsState;
104
104
  } | null = null;
105
105
 
106
- const updateState = () => {
106
+ const updateState = (signal?: RouteStateSignal) => {
107
107
  const location = ctx.eventController.getLocation() as URL;
108
108
  const { routeSegmentIds } = ctx.eventController.getHandleState();
109
109
  if (
@@ -118,17 +118,13 @@ export function useSegments<T>(
118
118
  };
119
119
  }
120
120
  const sel = selectorRef.current;
121
- const nextSelected = sel ? sel(cache.state) : cache.state;
122
- if (!shallowEqual(nextSelected, prevState.current)) {
123
- prevState.current = nextSelected;
124
- setState(nextSelected);
125
- }
121
+ setState(sel ? sel(cache.state) : cache.state, signal);
126
122
  };
127
123
 
128
124
  // Catch up with a change between the seeding render and this effect.
129
125
  updateState();
130
126
 
131
- const unsubscribeNav = ctx.eventController.subscribe(updateState);
127
+ const unsubscribeNav = ctx.eventController.subscribe(() => updateState());
132
128
  const unsubscribeHandles =
133
129
  ctx.eventController.subscribeToHandles(updateState);
134
130
 
@@ -1,4 +1,5 @@
1
1
  import { validateRedirectOrigin } from "./validate-redirect-origin.js";
2
+ import { PREFETCH_FAILED_ROW } from "../prefetch-markers.js";
2
3
 
3
4
  type HeaderResult = { url: string } | "blocked" | null;
4
5
 
@@ -105,9 +106,11 @@ export function handleReloadHeader(
105
106
  * end-of-stream, calling onComplete when done.
106
107
  *
107
108
  * `onComplete` receives `endedCleanly`: true only on a normal EOF drain, false
108
- * on a read error or an abort (the signal cancelled the reader). Callers that
109
- * gate a "fully complete" fast path (e.g. prefetch's `entry.complete`) must
110
- * treat a non-clean end as incomplete — a broken stream is not complete data.
109
+ * on a read error, an abort (the signal cancelled the reader), or a body that
110
+ * ends with PREFETCH_FAILED_ROW (a prefetch whose render reported an error,
111
+ * rsc/prefetch-render-error.ts). Callers that gate a "fully complete" fast
112
+ * path (e.g. prefetch's `entry.complete`) must treat a non-clean end as
113
+ * incomplete — a broken stream is not complete data.
111
114
  *
112
115
  * If the response has no body, onComplete fires synchronously with `true`
113
116
  * (an empty body is a clean, complete stream).
@@ -156,13 +159,16 @@ export function teeWithCompletion(
156
159
  // loop breaks here normally, NOT in the .catch — re-check the signal to
157
160
  // catch that case).
158
161
  let cleanEof = false;
162
+ // The last five bytes read, across chunk boundaries.
163
+ let tail = "";
159
164
  try {
160
165
  while (true) {
161
- const { done } = await reader.read();
166
+ const { done, value } = await reader.read();
162
167
  if (done) {
163
- cleanEof = true;
168
+ cleanEof = tail !== PREFETCH_FAILED_ROW;
164
169
  break;
165
170
  }
171
+ tail = (tail + String.fromCharCode(...value.slice(-5))).slice(-5);
166
172
  }
167
173
  } finally {
168
174
  if (onAbort) signal!.removeEventListener("abort", onAbort);
@@ -37,6 +37,7 @@ import {
37
37
  setPrefetchConcurrency,
38
38
  setPrefetchDecoder,
39
39
  } from "./prefetch/loader.js";
40
+ import { publishRangoMarker } from "./rango-marker.js";
40
41
  import { setAppVersion } from "./app-version.js";
41
42
  import {
42
43
  isInterceptSegment,
@@ -185,6 +186,7 @@ export async function initBrowserApp(
185
186
  } = options;
186
187
 
187
188
  bootLog("initBrowserApp start");
189
+ publishRangoMarker();
188
190
  // Before any location read (the history key, the navigation store, the
189
191
  // event controller): the server rendered the marked request for the clean
190
192
  // URL, so the client starts from the same one and hydration agrees.
@@ -248,7 +250,7 @@ export async function initBrowserApp(
248
250
 
249
251
  // The document's entry: the transition({ when }) source of the first
250
252
  // navigation, and of a later back/forward that leaves it.
251
- store.rememberDisplayedEntry(initialPayload.metadata?.routeName);
253
+ store.rememberDisplayedEntry(initialPayload.metadata);
252
254
 
253
255
  // Register the active store on the module-level handle and wire the
254
256
  // jar-divergence observer before any getRangoState() read can detect a
@@ -75,7 +75,8 @@ let customGetKey: GetScrollKeyFunction | null = null;
75
75
  /**
76
76
  * Scroll key of the entry on screen. At popstate history.state already belongs
77
77
  * to the destination, so this is the only way to save the page being left
78
- * under its own key (see handleTraversalStart).
78
+ * under its own key (see handleTraversalStart). A traversal moves it when its
79
+ * restore commits (handleNavigationEnd), not at its event.
79
80
  */
80
81
  let displayedKey: string | null = null;
81
82
 
@@ -283,10 +284,12 @@ export function cancelScrollRestorationPolling(): void {
283
284
  *
284
285
  * @param options.retryIfStreaming - If true, poll while streaming until we can scroll to target
285
286
  * @param options.isStreaming - Function to check if streaming is in progress
287
+ * @param options.committed - React has committed the restored tree (a layout effect)
286
288
  */
287
289
  export function restoreScrollPosition(options?: {
288
290
  retryIfStreaming?: boolean;
289
291
  isStreaming?: () => boolean;
292
+ committed?: boolean;
290
293
  }): boolean {
291
294
  // Clear any pending polling
292
295
  cancelScrollRestorationPolling();
@@ -298,6 +301,11 @@ export function restoreScrollPosition(options?: {
298
301
  return false;
299
302
  }
300
303
 
304
+ // Once React committed the restored tree, scroll now, so a view
305
+ // transition's new snapshot is taken at the restored position, while the
306
+ // page still streams too.
307
+ if (options?.committed) window.scrollTo(0, savedY);
308
+
301
309
  // If streaming, poll until streaming ends then scroll to saved position
302
310
  if (options?.retryIfStreaming && options?.isStreaming?.()) {
303
311
  const startTime = Date.now();
@@ -319,9 +327,8 @@ export function restoreScrollPosition(options?: {
319
327
  return true;
320
328
  }
321
329
 
322
- // Not streaming — scroll after React commits and browser paints.
323
- // startTransition defers the DOM commit, so scrolling synchronously
324
- // would be overwritten when React replaces the content.
330
+ // Not streaming: scroll (again) on the next frame, for layout that settles
331
+ // after the commit and for a caller that ran before it.
325
332
  deferToNextPaint(() => {
326
333
  window.scrollTo(0, savedY);
327
334
  debugLog("[Scroll] Restored position:", savedY, "for key:", key);
@@ -374,18 +381,18 @@ export function handleNavigationStart(): void {
374
381
 
375
382
  /**
376
383
  * Handle scroll for a back/forward traversal, at popstate before anything
377
- * renders: save the page being left under ITS key (history has already moved,
378
- * so saveCurrentScrollPosition would use the destination's key) and track the
379
- * destination as the displayed entry.
384
+ * renders: save the page on screen under ITS key (history has already moved,
385
+ * so saveCurrentScrollPosition would use the destination's key). A second
386
+ * traversal before the first one's restore commits (a rapid double Back, a
387
+ * deferred animated restore) saves the page still on screen, never an entry
388
+ * it did not show.
380
389
  */
381
390
  export function handleTraversalStart(): void {
382
391
  if (!initialized) return;
383
- const leftKey = displayedKey;
384
- trackDisplayedEntry();
385
392
  // A key shared with the destination (a getKey that collapses entries, e.g.
386
393
  // by pathname) keeps the destination's saved position.
387
- if (leftKey !== null && leftKey !== displayedKey) {
388
- saveScrollPosition(leftKey, window.scrollY);
394
+ if (displayedKey !== null && displayedKey !== getScrollKey()) {
395
+ saveScrollPosition(displayedKey, window.scrollY);
389
396
  }
390
397
  }
391
398
 
@@ -394,13 +401,15 @@ export function handleTraversalStart(): void {
394
401
  * @param options.restore - If true, restore saved position (for popstate)
395
402
  * @param options.scroll - If false, don't scroll at all
396
403
  * @param options.isStreaming - Function to check if streaming is in progress (for retry logic)
404
+ * @param options.committed - Called after React committed the new tree
397
405
  */
398
406
  export function handleNavigationEnd(options: {
399
407
  restore?: boolean;
400
408
  scroll?: boolean;
401
409
  isStreaming?: () => boolean;
410
+ committed?: boolean;
402
411
  }): void {
403
- const { restore = false, scroll = true, isStreaming } = options;
412
+ const { restore = false, scroll = true, isStreaming, committed } = options;
404
413
 
405
414
  // Don't scroll if explicitly disabled or not in a browser
406
415
  if (scroll === false || typeof window === "undefined") {
@@ -412,11 +421,17 @@ export function handleNavigationEnd(options: {
412
421
  // matters during cross-app navigation where ScrollRestoration unmounts
413
422
  // and remounts, creating a brief window where initialized is false.
414
423
  if (restore) {
415
- if (
416
- initialized &&
417
- restoreScrollPosition({ retryIfStreaming: true, isStreaming })
418
- ) {
419
- return;
424
+ if (initialized) {
425
+ trackDisplayedEntry();
426
+ if (
427
+ restoreScrollPosition({
428
+ retryIfStreaming: true,
429
+ isStreaming,
430
+ committed,
431
+ })
432
+ ) {
433
+ return;
434
+ }
420
435
  }
421
436
  // An "auto" entry (no <Html.ScrollRestoration>, or created before it mounted)
422
437
  // is restored by the browser. A scrollTo here races that restore, and
@@ -438,9 +453,8 @@ export function handleNavigationEnd(options: {
438
453
  // against unmeasured layout, so sync scroll is correct here even
439
454
  // before the new tree's scrollHeight settles.
440
455
  //
441
- // (The restore branch above keeps deferToNextPaint because savedY
442
- // depends on the new tree's max scroll; sync scrollTo against an
443
- // unmeasured DOM would clamp savedY to whatever the old/zero max was.)
456
+ // (The restore branch above also scrolls again on the next frame: savedY
457
+ // depends on the new tree's max scroll, which can grow after the commit.)
444
458
  if (scrollToHash()) {
445
459
  return;
446
460
  }