@rangojs/router 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/dist/bin/rango.js +1 -1
  2. package/dist/types/__internal.d.ts +7 -1
  3. package/dist/types/browser/action-fence.d.ts +2 -1
  4. package/dist/types/browser/event-controller.d.ts +8 -2
  5. package/dist/types/browser/history-state.d.ts +3 -9
  6. package/dist/types/browser/navigation-store.d.ts +8 -6
  7. package/dist/types/browser/partial-update.d.ts +11 -0
  8. package/dist/types/browser/react/filter-segment-order.d.ts +2 -2
  9. package/dist/types/browser/react/use-action.d.ts +7 -2
  10. package/dist/types/browser/react/use-handle.d.ts +19 -16
  11. package/dist/types/browser/react/use-link-status.d.ts +5 -2
  12. package/dist/types/browser/segment-reconciler.d.ts +32 -4
  13. package/dist/types/browser/types.d.ts +24 -5
  14. package/dist/types/browser/wire-protocol.d.ts +26 -0
  15. package/dist/types/cache/cache-key-utils.d.ts +11 -5
  16. package/dist/types/cache/cache-scope.d.ts +34 -22
  17. package/dist/types/cache/cf/cf-cache-constants.d.ts +2 -2
  18. package/dist/types/cache/cf/cf-cache-store.d.ts +47 -65
  19. package/dist/types/cache/cf/cf-kv-utils.d.ts +1 -1
  20. package/dist/types/cache/handle-snapshot.d.ts +9 -4
  21. package/dist/types/cache/segment-codec.d.ts +14 -0
  22. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  23. package/dist/types/cache/taint.d.ts +5 -3
  24. package/dist/types/cache/types.d.ts +7 -3
  25. package/dist/types/cache/vercel/vercel-cache-store.d.ts +8 -6
  26. package/dist/types/prefetch-markers.d.ts +5 -14
  27. package/dist/types/prerender/store.d.ts +31 -0
  28. package/dist/types/prerender/warm-request.d.ts +3 -3
  29. package/dist/types/prerender.d.ts +2 -2
  30. package/dist/types/route-name.d.ts +5 -0
  31. package/dist/types/router/error-handling.d.ts +11 -5
  32. package/dist/types/router/instrument.d.ts +4 -3
  33. package/dist/types/router/intercept-resolution.d.ts +25 -9
  34. package/dist/types/router/match-context.d.ts +6 -11
  35. package/dist/types/router/match-handlers.d.ts +2 -6
  36. package/dist/types/router/match-middleware/background-revalidation.d.ts +13 -22
  37. package/dist/types/router/match-middleware/cache-lookup.d.ts +34 -86
  38. package/dist/types/router/match-middleware/segment-resolution.d.ts +8 -6
  39. package/dist/types/router/navigation-snapshot.d.ts +7 -0
  40. package/dist/types/router/prerender-match.d.ts +21 -6
  41. package/dist/types/router/preview-match.d.ts +2 -7
  42. package/dist/types/router/request-classification.d.ts +5 -0
  43. package/dist/types/router/router-context.d.ts +7 -46
  44. package/dist/types/router/router-interfaces.d.ts +8 -14
  45. package/dist/types/router/segment-resolution/helpers.d.ts +19 -3
  46. package/dist/types/router/segment-resolution/loader-cache.d.ts +3 -3
  47. package/dist/types/router/segment-resolution/loader-mask.d.ts +4 -4
  48. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +20 -18
  49. package/dist/types/router/segment-resolution/resolution-pass.d.ts +95 -0
  50. package/dist/types/router/segment-resolution/revalidation.d.ts +82 -31
  51. package/dist/types/router/segment-resolution/streamed-handler-boundary.d.ts +2 -0
  52. package/dist/types/router/segment-resolution.d.ts +1 -2
  53. package/dist/types/router/segment-wrappers.d.ts +11 -48
  54. package/dist/types/router/telemetry.d.ts +2 -0
  55. package/dist/types/router/types.d.ts +11 -1
  56. package/dist/types/rsc/full-payload.d.ts +2 -2
  57. package/dist/types/rsc/handler-context.d.ts +6 -6
  58. package/dist/types/rsc/json-route-result.d.ts +5 -9
  59. package/dist/types/rsc/request-stages.d.ts +203 -0
  60. package/dist/types/rsc/response-cache-serve.d.ts +7 -12
  61. package/dist/types/rsc/response-route-handler.d.ts +2 -2
  62. package/dist/types/rsc/routine-plan.d.ts +1 -1
  63. package/dist/types/rsc/server-action.d.ts +2 -2
  64. package/dist/types/rsc/shell-capture-constants.d.ts +1 -1
  65. package/dist/types/rsc/shell-capture.d.ts +132 -15
  66. package/dist/types/rsc/shell-serve.d.ts +37 -12
  67. package/dist/types/server/context.d.ts +9 -8
  68. package/dist/types/server/handle-store.d.ts +16 -0
  69. package/dist/types/server/render-pass.d.ts +72 -0
  70. package/dist/types/server/request-context.d.ts +53 -104
  71. package/dist/types/server/rewrite-trail.d.ts +2 -2
  72. package/dist/types/testing/dispatch.d.ts +35 -18
  73. package/dist/types/testing/render-route.d.ts +43 -8
  74. package/dist/types/testing/serve-shell-request.d.ts +8 -1
  75. package/dist/types/types/cache-types.d.ts +6 -3
  76. package/dist/types/types/error-types.d.ts +30 -3
  77. package/dist/types/types/handler-context.d.ts +9 -1
  78. package/dist/types/types/segments.d.ts +1 -1
  79. package/dist/types/vite/discovery/build-discovery-phase.d.ts +40 -0
  80. package/dist/types/vite/discovery/build-env.d.ts +22 -0
  81. package/dist/types/vite/discovery/bundle-scan-phase.d.ts +12 -0
  82. package/dist/types/vite/discovery/dev-prerender-cache.d.ts +3 -2
  83. package/dist/types/vite/discovery/dev-server-phase.d.ts +26 -0
  84. package/dist/types/vite/discovery/discovery-errors.d.ts +1 -1
  85. package/dist/types/vite/discovery/state.d.ts +12 -0
  86. package/dist/types/vite/plugins/version-plugin.d.ts +1 -1
  87. package/dist/types/vite/plugins/virtual-entries.d.ts +1 -1
  88. package/dist/types/vite/router-discovery.d.ts +8 -47
  89. package/dist/types/vite/temp-rsc-server.d.ts +39 -0
  90. package/dist/types/vite/utils/prerender-utils.d.ts +9 -0
  91. package/dist/types/wire-params.d.ts +39 -0
  92. package/dist/vite/index.js +3822 -3727
  93. package/package.json +1 -1
  94. package/skills/cache-guide/SKILL.md +8 -5
  95. package/skills/caching/SKILL.md +37 -18
  96. package/skills/client-urls/SKILL.md +4 -2
  97. package/skills/document-cache/SKILL.md +13 -1
  98. package/skills/handler-use/SKILL.md +1 -1
  99. package/skills/hooks/SKILL.md +1 -1
  100. package/skills/hooks/data.md +8 -1
  101. package/skills/hooks/handle-and-actions.md +33 -2
  102. package/skills/hooks/navigation.md +121 -15
  103. package/skills/hooks/state.md +6 -4
  104. package/skills/hooks/urls.md +16 -5
  105. package/skills/intercept/SKILL.md +23 -7
  106. package/skills/middleware/SKILL.md +18 -0
  107. package/skills/observability/SKILL.md +30 -0
  108. package/skills/ppr/SKILL.md +18 -3
  109. package/skills/prerender/SKILL.md +39 -15
  110. package/skills/rango/SKILL.md +5 -1
  111. package/skills/server-actions/SKILL.md +15 -1
  112. package/skills/testing/client-components.md +25 -18
  113. package/skills/testing/response-routes.md +6 -4
  114. package/skills/testing/serve-shell-request.md +7 -7
  115. package/skills/use-cache/SKILL.md +46 -8
  116. package/skills/view-transitions/SKILL.md +1 -1
  117. package/src/__internal.ts +7 -1
  118. package/src/browser/action-fence.ts +2 -1
  119. package/src/browser/connection-warmup.ts +3 -1
  120. package/src/browser/event-controller.ts +37 -13
  121. package/src/browser/history-state.ts +6 -12
  122. package/src/browser/navigation-bridge.ts +59 -26
  123. package/src/browser/navigation-client.ts +20 -25
  124. package/src/browser/navigation-store.ts +25 -27
  125. package/src/browser/navigation-transaction.ts +4 -1
  126. package/src/browser/network-error-handler.ts +4 -0
  127. package/src/browser/partial-update.ts +33 -6
  128. package/src/browser/prefetch/cache.ts +2 -1
  129. package/src/browser/prefetch/fetch.ts +8 -16
  130. package/src/browser/prefetch/queue.ts +4 -4
  131. package/src/browser/react/NavigationProvider.tsx +2 -1
  132. package/src/browser/react/filter-segment-order.ts +2 -2
  133. package/src/browser/react/use-action.ts +24 -48
  134. package/src/browser/react/use-handle.ts +22 -18
  135. package/src/browser/react/use-link-status.ts +48 -60
  136. package/src/browser/react/use-navigation.ts +92 -41
  137. package/src/browser/rsc-router.tsx +1 -0
  138. package/src/browser/segment-reconciler.ts +109 -53
  139. package/src/browser/server-action-bridge.ts +81 -61
  140. package/src/browser/types.ts +25 -5
  141. package/src/browser/wire-protocol.ts +43 -0
  142. package/src/cache/cache-key-utils.ts +11 -5
  143. package/src/cache/cache-runtime.ts +132 -75
  144. package/src/cache/cache-scope.ts +110 -64
  145. package/src/cache/cf/cf-cache-constants.ts +2 -2
  146. package/src/cache/cf/cf-cache-store.ts +486 -874
  147. package/src/cache/cf/cf-kv-utils.ts +1 -1
  148. package/src/cache/document-cache.ts +64 -46
  149. package/src/cache/handle-snapshot.ts +19 -7
  150. package/src/cache/read-through-swr.ts +20 -18
  151. package/src/cache/segment-codec.ts +55 -0
  152. package/src/cache/shell-snapshot.ts +2 -2
  153. package/src/cache/tag-invalidation.ts +6 -2
  154. package/src/cache/taint.ts +5 -3
  155. package/src/cache/types.ts +7 -3
  156. package/src/cache/vercel/vercel-cache-store.ts +243 -310
  157. package/src/handles/pushed-by.ts +1 -1
  158. package/src/prefetch-markers.ts +5 -16
  159. package/src/prerender/store.ts +76 -0
  160. package/src/prerender/warm-request.ts +8 -4
  161. package/src/prerender.ts +2 -2
  162. package/src/route-name.ts +8 -0
  163. package/src/router/error-handling.ts +22 -10
  164. package/src/router/instrument.ts +4 -3
  165. package/src/router/intercept-resolution.ts +82 -75
  166. package/src/router/loader-resolution.ts +5 -4
  167. package/src/router/match-api.ts +5 -5
  168. package/src/router/match-context.ts +23 -12
  169. package/src/router/match-handlers.ts +8 -20
  170. package/src/router/match-middleware/background-revalidation.ts +46 -57
  171. package/src/router/match-middleware/cache-lookup.ts +677 -726
  172. package/src/router/match-middleware/cache-store.ts +4 -8
  173. package/src/router/match-middleware/intercept-resolution.ts +3 -23
  174. package/src/router/match-middleware/segment-resolution.ts +34 -61
  175. package/src/router/middleware.ts +69 -11
  176. package/src/router/navigation-snapshot.ts +14 -4
  177. package/src/router/prerender-match.ts +100 -44
  178. package/src/router/preview-match.ts +6 -41
  179. package/src/router/request-classification.ts +13 -7
  180. package/src/router/route-snapshot.ts +2 -5
  181. package/src/router/router-context.ts +10 -95
  182. package/src/router/router-interfaces.ts +8 -14
  183. package/src/router/segment-resolution/helpers.ts +49 -13
  184. package/src/router/segment-resolution/loader-cache.ts +15 -9
  185. package/src/router/segment-resolution/loader-mask.ts +5 -5
  186. package/src/router/segment-resolution/prefetch-deferral.ts +30 -27
  187. package/src/router/segment-resolution/resolution-pass.ts +139 -0
  188. package/src/router/segment-resolution/revalidation.ts +939 -789
  189. package/src/router/segment-resolution/streamed-handler-boundary.ts +3 -0
  190. package/src/router/segment-resolution.ts +4 -13
  191. package/src/router/segment-wrappers.ts +51 -269
  192. package/src/router/telemetry.ts +5 -0
  193. package/src/router/types.ts +11 -1
  194. package/src/router.ts +19 -17
  195. package/src/rsc/full-payload.ts +6 -5
  196. package/src/rsc/handler-context.ts +8 -14
  197. package/src/rsc/handler.ts +126 -507
  198. package/src/rsc/json-route-result.ts +5 -9
  199. package/src/rsc/loader-fetch.ts +6 -5
  200. package/src/rsc/progressive-enhancement.ts +5 -0
  201. package/src/rsc/request-stages.ts +759 -0
  202. package/src/rsc/response-cache-serve.ts +10 -14
  203. package/src/rsc/response-route-handler.ts +13 -5
  204. package/src/rsc/rewrite-passes.ts +2 -1
  205. package/src/rsc/routine-plan.ts +1 -1
  206. package/src/rsc/rsc-rendering.ts +164 -349
  207. package/src/rsc/server-action.ts +87 -113
  208. package/src/rsc/shell-capture-constants.ts +1 -1
  209. package/src/rsc/shell-capture.ts +275 -227
  210. package/src/rsc/shell-serve.ts +121 -18
  211. package/src/rsc/ssr-setup.ts +5 -4
  212. package/src/segment-system.tsx +3 -3
  213. package/src/server/context.ts +22 -25
  214. package/src/server/handle-store.ts +55 -0
  215. package/src/server/render-pass.ts +291 -0
  216. package/src/server/request-context.ts +75 -115
  217. package/src/server/rewrite-trail.ts +6 -4
  218. package/src/ssr/index.tsx +1 -1
  219. package/src/testing/dispatch.ts +226 -710
  220. package/src/testing/e2e/page-helpers.ts +2 -2
  221. package/src/testing/render-route.tsx +199 -13
  222. package/src/testing/serve-shell-request.ts +36 -40
  223. package/src/types/cache-types.ts +6 -3
  224. package/src/types/error-types.ts +30 -3
  225. package/src/types/handler-context.ts +12 -1
  226. package/src/types/segments.ts +1 -1
  227. package/src/vite/discovery/build-discovery-phase.ts +204 -0
  228. package/src/vite/discovery/build-env.ts +148 -0
  229. package/src/vite/discovery/bundle-scan-phase.ts +128 -0
  230. package/src/vite/discovery/dev-prerender-cache.ts +8 -3
  231. package/src/vite/discovery/dev-server-phase.ts +1776 -0
  232. package/src/vite/discovery/discover-routers.ts +1 -1
  233. package/src/vite/discovery/discovery-errors.ts +1 -1
  234. package/src/vite/discovery/prerender-collection.ts +24 -39
  235. package/src/vite/discovery/state.ts +15 -0
  236. package/src/vite/plugins/version-plugin.ts +1 -1
  237. package/src/vite/plugins/virtual-entries.ts +1 -1
  238. package/src/vite/router-discovery.ts +29 -2428
  239. package/src/vite/temp-rsc-server.ts +223 -0
  240. package/src/vite/utils/prerender-utils.ts +47 -0
  241. package/src/wire-params.ts +40 -0
  242. package/dist/types/router/segment-resolution/fresh.d.ts +0 -72
  243. package/src/router/segment-resolution/fresh.ts +0 -945
@@ -38,6 +38,7 @@ import { SEGMENT_FRAGMENT_CAPABILITY_HEADER } from "../../segment-fragments.js";
38
38
  import { PREFETCH_HEADER } from "../../prefetch-markers.js";
39
39
  import type { RscPayload } from "../types.js";
40
40
  import { getRegisteredStore } from "../navigation-store-handle.js";
41
+ import { writePartialParams } from "../wire-protocol.js";
41
42
 
42
43
  /**
43
44
  * Decoder injected at app startup (see setPrefetchDecoder). This is
@@ -153,24 +154,15 @@ function buildPrefetchUrl(
153
154
  if (targetUrl.origin !== window.location.origin) {
154
155
  return null;
155
156
  }
156
- targetUrl.searchParams.set("_rsc_partial", "true");
157
- if (segmentIds.length > 0) {
158
- targetUrl.searchParams.set("_rsc_segments", segmentIds.join(","));
159
- }
160
- if (version) {
161
- targetUrl.searchParams.set("_rsc_v", version);
162
- }
163
- if (routerId) {
164
- targetUrl.searchParams.set("_rsc_rid", routerId);
165
- }
166
157
  // The page's rewrite target, so the server diffs against the route that
167
158
  // rendered it, as for the navigation.
168
- const rewriteTarget =
169
- getRegisteredStore()?.getHistoryEntryMemory()?.rewriteTarget;
170
- if (rewriteTarget) {
171
- targetUrl.searchParams.set("_rsc_rt", rewriteTarget);
172
- }
173
- return targetUrl;
159
+ return writePartialParams(targetUrl, {
160
+ segmentIds,
161
+ emptySegments: "omit",
162
+ version,
163
+ routerId,
164
+ rewriteTarget: getRegisteredStore()?.getHistoryEntryMemory()?.rewriteTarget,
165
+ });
174
166
  }
175
167
 
176
168
  /**
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { wait, waitForIdle, waitForViewportImages } from "./resource-ready.js";
18
+ import { WIRE_PREFIX } from "../../wire-params.js";
18
19
 
19
20
  // Max prefetches executing at once. Mirrors DEFAULT_PREFETCH_CONCURRENCY
20
21
  // (router/prefetch-limits.ts); kept as a local literal so the client bundle
@@ -128,16 +129,15 @@ export function enqueuePrefetch(
128
129
 
129
130
  /**
130
131
  * Normalize a URL-like string for keep-alive matching: parse against a
131
- * placeholder origin and strip internal `_rsc_*` query params. Returns
132
+ * placeholder origin and strip every `WIRE_PREFIX` query param. Returns
132
133
  * `pathname + search` so comparisons ignore hash and the internal params
133
- * that prefetch appends to targets (`_rsc_partial`, `_rsc_segments`,
134
- * `_rsc_v`, `_rsc_rid`, `_rsc_stale`).
134
+ * that prefetch appends to targets.
135
135
  */
136
136
  function normalizeForMatch(urlish: string): string {
137
137
  try {
138
138
  const u = new URL(urlish, "http://placeholder");
139
139
  for (const k of [...u.searchParams.keys()]) {
140
- if (k.startsWith("_rsc_")) u.searchParams.delete(k);
140
+ if (k.startsWith(WIRE_PREFIX)) u.searchParams.delete(k);
141
141
  }
142
142
  return u.pathname + u.search;
143
143
  } catch {
@@ -78,7 +78,8 @@ async function processHandles(
78
78
 
79
79
  // This nav's instance token, captured before any await — processHandles runs
80
80
  // right after its own commit, so this is that commit's token. generateHistoryKey
81
- // is URL-only, so an A->B->A revisit reuses the key; the token lets a late
81
+ // is the URL (plus the source page for an intercept), so an A->B->A revisit
82
+ // reuses the key; the token lets a late
82
83
  // resolution tell its own visit apart from a newer same-URL visit, so a stale
83
84
  // nav can never clobber a fresher one's live state or cache (P1).
84
85
  const myInstance = store.getNavInstance();
@@ -33,8 +33,8 @@ export function loaderParentId(id: string): string {
33
33
  * 2. Place each parallel slot id (contains ".@") immediately after its
34
34
  * parent layout/route id. Raw segment-resolution emission order does NOT
35
35
  * guarantee this: route-mounted parallels are resolved/pushed BEFORE the
36
- * route handler's segment is appended (see fresh.ts:resolveSegment for
37
- * routes, and revalidation.ts ~915-919), so matched can read
36
+ * route handler's segment is appended (see resolveSegment in
37
+ * segment-resolution/revalidation.ts), so matched can read
38
38
  * `[..., R0.@panel, R0]`. collectHandleData consumes segmentOrder verbatim
39
39
  * with later-wins semantics, so without normalization the route handler's
40
40
  * Meta would override the slot's more-specific Meta — backwards.
@@ -1,13 +1,6 @@
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 { NavigationStoreContext } from "./context.js";
12
5
  import { shallowEqual } from "./shallow-equal.js";
13
6
  import type { TrackedActionState } from "../types.js";
@@ -83,14 +76,19 @@ export type ServerActionFunction = ((...args: any[]) => Promise<any>) & {
83
76
  * Features:
84
77
  * - Tracks action lifecycle: idle → loading → streaming → idle
85
78
  * - Captures result/error locally (React handles cleanup)
86
- * - If multiple actions fire, tracks only the last one
79
+ * - If the same action fires several times, reports the latest call to
80
+ * start. An earlier call that settles later changes nothing it reports;
81
+ * a latest call that was aborted reports idle with nothing. One call at a
82
+ * time, not one per argument: a reader filtering on `payload` cannot track
83
+ * an earlier call once a later one starts.
87
84
  * - Supports selector pattern like useNavigation
88
85
  *
89
86
  * Matching behavior:
90
87
  * - **Function reference**: Uses full $$id for exact matching. This is precise
91
88
  * and distinguishes between actions with the same name in different files.
92
89
  * - **String**: Matches by suffix (action name after #). This is convenient
93
- * but may be ambiguous if multiple files export the same action name.
90
+ * but may be ambiguous if multiple files export the same action name: it
91
+ * follows the latest call of any action with that export name.
94
92
  *
95
93
  * @param action - Either a server action function or a string action name.
96
94
  * - **Function**: Must be directly imported in the client component.
@@ -138,8 +136,10 @@ export function useAction<T>(
138
136
  ? getActionId(action)
139
137
  : "";
140
138
 
141
- // Base state for useOptimistic
142
- const [baseState, setBaseState] = useState<T | TrackedActionState>(() => {
139
+ // Set urgently from notifyAction's task, also while React holds a page: a
140
+ // transition of its own would take the held page's transition types (see
141
+ // useNavigation).
142
+ const [value, setValue] = useState<T | TrackedActionState>(() => {
143
143
  if (!ctx) {
144
144
  return selector ? selector(DEFAULT_ACTION_STATE) : DEFAULT_ACTION_STATE;
145
145
  }
@@ -149,11 +149,8 @@ export function useAction<T>(
149
149
  : ctx.eventController.getActionState(actionId);
150
150
  return selector ? selector(state) : state;
151
151
  });
152
- const prevSelected = useRef(baseState);
153
- prevSelected.current = baseState;
154
- const [optimisticState, setOptimisticState] = useOptimistic<
155
- T | TrackedActionState
156
- >(null!);
152
+ const prevSelected = useRef(value);
153
+ prevSelected.current = value;
157
154
 
158
155
  // Ref keeps the latest selector for subscription callbacks without
159
156
  // re-subscribing on every render. Selector changes themselves are not
@@ -166,41 +163,20 @@ export function useAction<T>(
166
163
  useEffect(() => {
167
164
  if (!ctx) return;
168
165
 
166
+ const sync = (state: TrackedActionState) => {
167
+ const selected = selectorRef.current ? selectorRef.current(state) : state;
168
+ if (!shallowEqual(selected, prevSelected.current)) {
169
+ prevSelected.current = selected;
170
+ setValue(selected);
171
+ }
172
+ };
169
173
  // Sync current state for the (possibly new) actionId so that switching
170
174
  // actions on an idle page doesn't leave stale data from the old action.
171
- const currentState = ctx.eventController.getActionState(actionId);
172
- const currentSelected = selectorRef.current
173
- ? selectorRef.current(currentState)
174
- : currentState;
175
- if (!shallowEqual(currentSelected, prevSelected.current)) {
176
- prevSelected.current = currentSelected;
177
- setBaseState(currentSelected);
178
- }
179
-
180
- // Subscribe to action-specific updates
181
- const unsubscribe = ctx.eventController.subscribeToAction(
182
- actionId,
183
- (state) => {
184
- const selectedState = selectorRef.current
185
- ? selectorRef.current(state)
186
- : state;
187
-
188
- if (!shallowEqual(selectedState, prevSelected.current)) {
189
- prevSelected.current = selectedState;
190
- setBaseState(selectedState);
191
- startTransition(() => {
192
- setOptimisticState(selectedState);
193
- });
194
- }
195
- },
196
- );
197
-
198
- return () => {
199
- unsubscribe();
200
- };
175
+ sync(ctx.eventController.getActionState(actionId));
176
+ return ctx.eventController.subscribeToAction(actionId, sync);
201
177
  }, [actionId]);
202
178
 
203
- return (optimisticState ?? baseState) as T | TrackedActionState;
179
+ return value;
204
180
  }
205
181
 
206
182
  export type { TrackedActionState };
@@ -53,7 +53,7 @@ export function useHandle<T, A, S>(
53
53
  return selectorRef.current ? selectorRef.current(collected) : collected;
54
54
  };
55
55
 
56
- const [value, setValue] = useCommitAnchoredState(collect);
56
+ const [value, setValue] = useCommitAnchoredState(collect, true);
57
57
 
58
58
  useEffect(() => {
59
59
  if (!ctx) return;
@@ -68,25 +68,29 @@ export function useHandle<T, A, S>(
68
68
  }
69
69
 
70
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
71
+ * Route state the controller's handle notifications drive commits with the
72
+ * page it belongs to. NavigationProvider flushes the controller inside every
73
+ * payload update, with signal "anchor" for a page in a transition and
74
+ * "release" for an urgent one, so the page's own change is set in the page's
75
+ * lane and the page's effects read it. From the anchor until the page commits
78
76
  * (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.
77
+ * a later change waits and is applied in the commit's task, right after the
78
+ * page's own commit: set in a lane of its own, it would commit over the page
79
+ * being left (#1165).
80
+ *
81
+ * `waitForRelease`, set by useHandle only: the anchor's own change waits too,
82
+ * so a handle value lands right after its page's commit, never in its lane. A
83
+ * handle value can carry a promise that suspends, and in the page's lane one
84
+ * that suspends in a revealed <Suspense> would hold the page. Nothing
85
+ * useSegments reads can suspend, so it leaves it off and its path and
86
+ * location commit with their page.
87
+ *
88
+ * A value shallow-equal to the last one sets nothing, so a reader renders only
89
+ * when its value moves.
87
90
  */
88
91
  export function useCommitAnchoredState<T>(
89
92
  init: () => T,
93
+ waitForRelease?: boolean,
90
94
  ): [T, (value: T, signal?: RouteStateSignal) => void] {
91
95
  // An app that enables the React Compiler compiles the router too; its memo
92
96
  // cache gains nothing here and cost 107 B gzip in the router chunk.
@@ -102,8 +106,8 @@ export function useCommitAnchoredState<T>(
102
106
  r.last = next;
103
107
  r.held = true;
104
108
  }
105
- // The anchor's own change goes in the page's lane, with the page.
106
- if (!r.held || (r.waiting && signal !== "anchor")) return;
109
+ if (!r.held || (r.waiting && (waitForRelease || signal !== "anchor")))
110
+ return;
107
111
  r.held = false;
108
112
  setValue(() => r.last);
109
113
  });
@@ -5,12 +5,10 @@ import {
5
5
  useContext,
6
6
  useState,
7
7
  useEffect,
8
- useRef,
9
- useOptimistic,
10
- startTransition,
11
8
  type Context,
12
9
  } from "react";
13
10
  import { NavigationStoreContext } from "./context.js";
11
+ import type { EventController, NavigationEntry } from "../event-controller.js";
14
12
 
15
13
  /**
16
14
  * Context for Link component to provide its destination URL
@@ -43,24 +41,45 @@ function normalizeUrl(url: string, origin: string): string {
43
41
  }
44
42
 
45
43
  /**
46
- * Check if this link's destination matches the pending navigation URL
44
+ * The navigation in flight a link can be pending for. One that shows no
45
+ * loading state (a background revalidation, an action's refetch) is none: it
46
+ * goes to no link and ends none whose page React still holds.
47
+ */
48
+ function visibleNavigation(ec: EventController): NavigationEntry | null {
49
+ const navigation = ec.getCurrentNavigation();
50
+ return navigation && !navigation.options?.skipLoadingState
51
+ ? navigation
52
+ : null;
53
+ }
54
+
55
+ /**
56
+ * Whether `navigation` goes to `linkTo`: its URL or, after a server redirect,
57
+ * the URL its chain started from.
47
58
  */
48
59
  function isPendingFor(
49
60
  linkTo: string | null,
50
- pendingUrl: string | null,
61
+ navigation: NavigationEntry | null,
51
62
  origin: string,
52
63
  ): boolean {
53
- if (linkTo === null || pendingUrl === null) {
64
+ if (linkTo === null || !navigation) {
54
65
  return false;
55
66
  }
56
- return normalizeUrl(pendingUrl, origin) === normalizeUrl(linkTo, origin);
67
+ const to = normalizeUrl(linkTo, origin);
68
+ const from = navigation.options?._redirectFrom;
69
+ return (
70
+ normalizeUrl(navigation.url, origin) === to ||
71
+ (from !== undefined && normalizeUrl(from, origin) === to)
72
+ );
57
73
  }
58
74
 
59
75
  /**
60
76
  * Hook to track the pending state of a Link component
61
77
  *
62
- * Must be used inside a Link component. Returns `{ pending: true }`
63
- * when navigation to this link's destination is in progress.
78
+ * Must be used inside a Link component. Returns `{ pending: true }` while a
79
+ * navigation to the link's destination is in flight, until the page it
80
+ * brings commits: under transition() while React holds that page, and
81
+ * through a server redirect until the target page commits. A newer
82
+ * navigation ends it at once.
64
83
  *
65
84
  * Useful for showing inline loading indicators on individual links.
66
85
  *
@@ -84,63 +103,32 @@ export function useLinkStatus(): LinkStatus {
84
103
 
85
104
  const origin = ctx?.origin ?? "http://localhost";
86
105
 
87
- const [basePending, setBasePending] = useState<boolean>(() => {
88
- if (!ctx || linkTo === null) {
89
- return false;
90
- }
91
- const state =
92
- ctx.eventController.getHydrationSnapshot()?.state ??
93
- ctx.eventController.getState();
94
- return isPendingFor(linkTo, state.pendingUrl, origin);
95
- });
96
-
97
- const prevPending = useRef(basePending);
98
-
99
- // Tracks whether the most recent setOptimisticPending call pinned the value
100
- // to a non-idle (loading) state. Used to decide whether to emit a release
101
- // update when returning to idle, so the optimistic store doesn't stay pinned
102
- // to `true` if a parent transition (e.g. the <Link> click / view transition
103
- // commit) is still pending.
104
- const optimisticPinnedRef = useRef(false);
105
-
106
- const [pending, setOptimisticPending] = useOptimistic(basePending);
107
-
106
+ // The document renders no navigation in flight.
107
+ const [pending, setPending] = useState<boolean>(
108
+ () =>
109
+ !!ctx &&
110
+ !ctx.eventController.getHydrationSnapshot() &&
111
+ isPendingFor(linkTo, visibleNavigation(ctx.eventController), origin),
112
+ );
113
+
114
+ // The value commits in the lane of the notification that carries it, as
115
+ // useNavigation's does: at once from the click's task, and with the page
116
+ // when its commit clears the navigation (NavigationProvider flushes that
117
+ // notification inside the page's update). Set once per navigation: a
118
+ // repeated urgent set would commit ahead of a held page's settle. A
119
+ // background navigation (an action's refetch) is not a newer one, so a
120
+ // held link stays pending.
108
121
  useEffect(() => {
109
122
  if (!ctx || linkTo === null) {
110
123
  return;
111
124
  }
112
125
 
126
+ let last: NavigationEntry | null | undefined;
113
127
  const update = () => {
114
- const state = ctx.eventController.getState();
115
- const isPending = isPendingFor(linkTo, state.pendingUrl, origin);
116
-
117
- if (isPending !== prevPending.current) {
118
- prevPending.current = isPending;
119
-
120
- const shouldPin = isPending && state.state !== "idle";
121
-
122
- if (shouldPin) {
123
- // Pin the optimistic value so the spinner shows immediately even if
124
- // a parent transition (e.g. <Link> click) defers the urgent
125
- // setBasePending commit.
126
- startTransition(() => {
127
- setOptimisticPending(isPending);
128
- });
129
- optimisticPinnedRef.current = true;
130
- } else if (optimisticPinnedRef.current) {
131
- // Release a previously-pinned optimistic value. Without this,
132
- // useOptimistic keeps returning the stale `true` while any parent
133
- // transition is still pending, even after basePending flipped to
134
- // false at navigation completion — leaving the link spinner stuck.
135
- startTransition(() => {
136
- setOptimisticPending(isPending);
137
- });
138
- optimisticPinnedRef.current = false;
139
- }
140
-
141
- // Always update base state
142
- setBasePending(isPending);
143
- }
128
+ const navigation = visibleNavigation(ctx.eventController);
129
+ if (navigation === last) return;
130
+ last = navigation;
131
+ setPending(isPendingFor(linkTo, navigation, origin));
144
132
  };
145
133
 
146
134
  // Catch-up: re-read state synchronously on mount before subscribing, so a
@@ -5,13 +5,15 @@ import {
5
5
  useState,
6
6
  useEffect,
7
7
  useLayoutEffect,
8
- startTransition,
9
8
  useRef,
10
9
  } from "react";
11
10
  import { NavigationStoreContext } from "./context.js";
12
11
  import { shallowEqual } from "./shallow-equal.js";
13
12
  import type { PublicNavigationState } from "../types.js";
14
- import type { DerivedNavigationState } from "../event-controller.js";
13
+ import type {
14
+ DerivedNavigationState,
15
+ RouteStateSignal,
16
+ } from "../event-controller.js";
15
17
 
16
18
  /**
17
19
  * Convert derived state to public version (strips inflightActions)
@@ -21,6 +23,32 @@ function toPublicState(state: DerivedNavigationState): PublicNavigationState {
21
23
  return publicState;
22
24
  }
23
25
 
26
+ /** The state a value was selected from, and the value. */
27
+ interface Snapshot<T> {
28
+ state: PublicNavigationState;
29
+ value: T;
30
+ }
31
+
32
+ /**
33
+ * A notification while React holds a page: work that starts shows at once
34
+ * (loading, streaming, a newer click's URL), what settles waits for the
35
+ * page's commit (rango-d152), and location moves only with the notification
36
+ * that moves it (the page on screen until the next one shows).
37
+ */
38
+ function hold(
39
+ base: PublicNavigationState,
40
+ live: PublicNavigationState,
41
+ moved: boolean,
42
+ ): PublicNavigationState {
43
+ return {
44
+ state: live.state === "loading" ? live.state : base.state,
45
+ isStreaming: live.isStreaming || base.isStreaming,
46
+ isNavigating: live.isNavigating || base.isNavigating,
47
+ pendingUrl: live.pendingUrl ?? base.pendingUrl,
48
+ location: moved ? live.location : base.location,
49
+ };
50
+ }
51
+
24
52
  /**
25
53
  * Hook to access reactive navigation state with optional selector for performance.
26
54
  *
@@ -46,18 +74,20 @@ export function useNavigation<T>(
46
74
  throw new Error("useNavigation must be used within NavigationProvider");
47
75
  }
48
76
 
49
- const [value, setValue] = useState<T | PublicNavigationState>(() => {
50
- const publicState = toPublicState(
51
- ctx.eventController.getHydrationSnapshot()?.state ??
52
- ctx.eventController.getState(),
53
- );
54
- return selector ? selector(publicState) : publicState;
55
- });
56
- const prevState = useRef(value);
57
- // The value on screen: a set value that differs has not committed yet.
58
- const committedRef = useRef(value);
77
+ const [snapshot, setSnapshot] = useState<Snapshot<T | PublicNavigationState>>(
78
+ () => {
79
+ const state = toPublicState(
80
+ ctx.eventController.getHydrationSnapshot()?.state ??
81
+ ctx.eventController.getState(),
82
+ );
83
+ return { state, value: selector ? selector(state) : state };
84
+ },
85
+ );
86
+ // What React ends on once every set so far commits, and what it committed.
87
+ const latest = useRef(snapshot);
88
+ const committed = useRef(snapshot);
59
89
  useLayoutEffect(() => {
60
- committedRef.current = value;
90
+ committed.current = snapshot;
61
91
  });
62
92
 
63
93
  // Store selector in a ref so the subscription callback always uses the
@@ -74,34 +104,48 @@ export function useNavigation<T>(
74
104
  // the location hooks' values do: NavigationProvider flushes a navigation's
75
105
  // notification into the transition that commits its tree, so idle commits
76
106
  // 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).
107
+ // useParams). From that page's anchor to its release every other
108
+ // notification goes through hold(), urgently, so React rebases the same
109
+ // update onto the page when it commits. Never a transition of its own.
79
110
  useEffect(() => {
80
- let last: DerivedNavigationState | undefined;
81
- const update = () => {
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;
93
- const publicState = toPublicState(currentState);
94
- const nextSelected = selectorRef.current
95
- ? selectorRef.current(publicState)
96
- : publicState;
97
-
98
- // Check if selected value has changed
99
- if (!shallowEqual(nextSelected, prevState.current)) {
100
- const held = prevState.current !== committedRef.current;
101
- prevState.current = nextSelected;
102
- if (quiet && held) startTransition(() => setValue(nextSelected));
103
- else setValue(nextSelected);
111
+ let pageHeld = false;
112
+ let location: PublicNavigationState["location"] | undefined;
113
+ const select = (state: PublicNavigationState) =>
114
+ selectorRef.current ? selectorRef.current(state) : state;
115
+ const update = (signal?: RouteStateSignal) => {
116
+ if (signal) pageHeld = signal === "anchor";
117
+ const live = toPublicState(ctx.eventController.getState());
118
+ const moved = live.location !== location;
119
+ location = live.location;
120
+ const held = pageHeld && !signal;
121
+ const next = (
122
+ base: Snapshot<T | PublicNavigationState>,
123
+ ): Snapshot<T | PublicNavigationState> => {
124
+ const state = held ? hold(base.state, live, moved) : live;
125
+ return { state, value: select(state) };
126
+ };
127
+ const target = next(latest.current);
128
+ // A release is compared with the state React renders it on, not
129
+ // `latest`: an urgent page can end the hold before React commits the
130
+ // anchor's set. An anchor sets inside its page's transition, which
131
+ // renders every set so far, so `latest` is that state. A release that
132
+ // finds every set committed with its value sets nothing: the updater
133
+ // would render the reader again only to bail out.
134
+ if (signal === "release") {
135
+ if (
136
+ latest.current === committed.current &&
137
+ shallowEqual(target.value, latest.current.value)
138
+ )
139
+ return;
140
+ latest.current = target;
141
+ setSnapshot((prev) =>
142
+ shallowEqual(prev.value, target.value) ? prev : target,
143
+ );
144
+ return;
104
145
  }
146
+ if (shallowEqual(target.value, latest.current.value)) return;
147
+ latest.current = target;
148
+ setSnapshot(held ? next : target);
105
149
  };
106
150
 
107
151
  // Catch-up: re-read state synchronously on mount before subscribing, so a
@@ -110,8 +154,15 @@ export function useNavigation<T>(
110
154
  // useSearchParams.
111
155
  update();
112
156
 
113
- return ctx.eventController.subscribe(update);
157
+ const unsubscribe = ctx.eventController.subscribe(update);
158
+ // The anchor and release signals ride the handle channel. One without a
159
+ // signal re-reads the same state, as the state channel's own would.
160
+ const unsubscribeSignals = ctx.eventController.subscribeToHandles(update);
161
+ return () => {
162
+ unsubscribe();
163
+ unsubscribeSignals();
164
+ };
114
165
  }, []);
115
166
 
116
- return value as T | PublicNavigationState;
167
+ return snapshot.value as T | PublicNavigationState;
117
168
  }
@@ -614,6 +614,7 @@ export async function initBrowserApp(
614
614
 
615
615
  const historyKey = generateHistoryKey(window.location.href, {
616
616
  intercept: responseIsIntercept,
617
+ interceptSource: interceptSourceUrl ?? undefined,
617
618
  });
618
619
  store.setHistoryKey(historyKey);
619
620
  const currentHandleData = liveHandleDataForCache(