@rangojs/router 0.22.1 → 0.24.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 (259) hide show
  1. package/dist/bin/rango.js +10 -4
  2. package/dist/testing/vitest.js +31 -1
  3. package/dist/types/browser/defer.d.ts +48 -0
  4. package/dist/types/browser/document-revealed.d.ts +17 -0
  5. package/dist/types/browser/event-controller.d.ts +7 -1
  6. package/dist/types/browser/merge-segment-loaders.d.ts +5 -2
  7. package/dist/types/browser/navigation-transaction.d.ts +21 -7
  8. package/dist/types/browser/partial-update.d.ts +39 -8
  9. package/dist/types/browser/react/NavigationProvider.d.ts +19 -5
  10. package/dist/types/browser/react/context.d.ts +3 -2
  11. package/dist/types/browser/react/filter-segment-order.d.ts +7 -0
  12. package/dist/types/browser/response-adapter.d.ts +7 -0
  13. package/dist/types/browser/types.d.ts +24 -10
  14. package/dist/types/browser/validate-redirect-origin.d.ts +11 -0
  15. package/dist/types/build/generate-route-types.d.ts +1 -1
  16. package/dist/types/build/route-types/router-processing.d.ts +1 -1
  17. package/dist/types/build/route-types/scan-filter.d.ts +22 -2
  18. package/dist/types/cache/cf/cf-cache-store.d.ts +16 -1
  19. package/dist/types/cache/cf/cf-cache-types.d.ts +23 -2
  20. package/dist/types/cache/document-cache-policy.d.ts +24 -0
  21. package/dist/types/cache/document-cache.d.ts +2 -10
  22. package/dist/types/client-urls/client-urls.d.ts +1 -1
  23. package/dist/types/client-urls/hoisted-revalidate.d.ts +13 -0
  24. package/dist/types/client-urls/navigation.d.ts +0 -37
  25. package/dist/types/client-urls/revalidate-registry.d.ts +4 -0
  26. package/dist/types/client-urls/server-projection.d.ts +29 -1
  27. package/dist/types/client-urls/types.d.ts +34 -69
  28. package/dist/types/client.d.ts +0 -1
  29. package/dist/types/context-var.d.ts +12 -0
  30. package/dist/types/errors.d.ts +15 -0
  31. package/dist/types/handles/deferred-resolution.d.ts +2 -1
  32. package/dist/types/handles/pushed-by.d.ts +31 -0
  33. package/dist/types/index.d.ts +4 -0
  34. package/dist/types/index.rsc.d.ts +2 -0
  35. package/dist/types/loader-store.d.ts +8 -0
  36. package/dist/types/prefetch-markers.d.ts +11 -0
  37. package/dist/types/response-utils.d.ts +7 -0
  38. package/dist/types/reverse.d.ts +8 -8
  39. package/dist/types/route-definition/helpers-types.d.ts +27 -0
  40. package/dist/types/route-definition/rewrite.d.ts +56 -0
  41. package/dist/types/route-map-builder.d.ts +7 -2
  42. package/dist/types/router/basename.d.ts +7 -0
  43. package/dist/types/router/handler-context.d.ts +1 -5
  44. package/dist/types/router/internal-params.d.ts +8 -0
  45. package/dist/types/router/is-action.d.ts +2 -1
  46. package/dist/types/router/middleware-types.d.ts +15 -0
  47. package/dist/types/router/revalidation.d.ts +1 -2
  48. package/dist/types/router/route-trie-builder.d.ts +10 -0
  49. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -2
  50. package/dist/types/router/segment-resolution/mask-nested.d.ts +4 -2
  51. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +264 -0
  52. package/dist/types/rsc/full-payload.d.ts +11 -0
  53. package/dist/types/rsc/handler.d.ts +33 -0
  54. package/dist/types/rsc/helpers.d.ts +8 -0
  55. package/dist/types/rsc/loader-fetch.d.ts +16 -0
  56. package/dist/types/rsc/rewrite-passes.d.ts +74 -0
  57. package/dist/types/rsc/ssr-setup.d.ts +3 -1
  58. package/dist/types/server/context.d.ts +21 -1
  59. package/dist/types/server/handle-store.d.ts +9 -1
  60. package/dist/types/server/request-context.d.ts +52 -2
  61. package/dist/types/server/rewrite-trail.d.ts +49 -0
  62. package/dist/types/server/slot-accessors.d.ts +29 -0
  63. package/dist/types/server.d.ts +1 -0
  64. package/dist/types/testing/flight.entry.d.ts +1 -1
  65. package/dist/types/testing/index.d.ts +4 -4
  66. package/dist/types/testing/internal/action-id.d.ts +10 -1
  67. package/dist/types/testing/internal/client-urls-projection.d.ts +16 -0
  68. package/dist/types/testing/internal/test-action.d.ts +8 -0
  69. package/dist/types/testing/render-route.d.ts +8 -0
  70. package/dist/types/testing/run-loader.d.ts +2 -1
  71. package/dist/types/testing/run-middleware.d.ts +20 -0
  72. package/dist/types/testing/run-revalidate.d.ts +74 -0
  73. package/dist/types/testing/run-transition-when.d.ts +2 -3
  74. package/dist/types/testing/serve-shell-request.d.ts +40 -1
  75. package/dist/types/testing/vitest.d.ts +11 -0
  76. package/dist/types/thenable-status.d.ts +6 -0
  77. package/dist/types/types/boundaries.d.ts +9 -0
  78. package/dist/types/types/handler-context.d.ts +14 -0
  79. package/dist/types/types/loader-types.d.ts +30 -0
  80. package/dist/types/types/request-scope.d.ts +7 -0
  81. package/dist/types/types/segments.d.ts +21 -4
  82. package/dist/types/urls/path-helper-types.d.ts +25 -4
  83. package/dist/types/urls/type-extraction.d.ts +6 -2
  84. package/dist/types/vite/discovery/discover-routers.d.ts +9 -0
  85. package/dist/types/vite/discovery/state.d.ts +14 -4
  86. package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +54 -0
  87. package/dist/types/vite/plugins/hoist/literal.d.ts +83 -0
  88. package/dist/types/vite/plugins/hoist/scope.d.ts +40 -0
  89. package/dist/types/vite/plugins/hoist/source.d.ts +41 -0
  90. package/dist/types/vite/plugins/transition-when-hoist.d.ts +8 -11
  91. package/dist/types/vite/router-discovery.d.ts +10 -0
  92. package/dist/vite/index.js +974 -379
  93. package/package.json +1 -1
  94. package/skills/caching/SKILL.md +54 -5
  95. package/skills/catalog.json +3 -3
  96. package/skills/client-urls/SKILL.md +197 -24
  97. package/skills/cloudflare/SKILL.md +14 -9
  98. package/skills/hooks/SKILL.md +16 -3
  99. package/skills/hooks/data.md +3 -1
  100. package/skills/links/SKILL.md +24 -0
  101. package/skills/loader/SKILL.md +199 -2
  102. package/skills/middleware/SKILL.md +183 -2
  103. package/skills/migrate-nextjs/SKILL.md +2 -2
  104. package/skills/migrate-react-router/SKILL.md +10 -7
  105. package/skills/migrate-react-router/data-and-actions.md +1 -1
  106. package/skills/ppr/SKILL.md +22 -0
  107. package/skills/prerender/SKILL.md +22 -8
  108. package/skills/rango/SKILL.md +13 -7
  109. package/skills/route/SKILL.md +139 -0
  110. package/skills/server-actions/SKILL.md +27 -0
  111. package/skills/testing/SKILL.md +3 -2
  112. package/skills/testing/client-components.md +2 -1
  113. package/skills/testing/middleware.md +31 -10
  114. package/skills/testing/predicates.md +67 -32
  115. package/skills/testing/response-routes.md +1 -0
  116. package/skills/testing/serve-shell-request.md +107 -19
  117. package/skills/testing/setup.md +2 -2
  118. package/skills/typesafety/route-types.md +2 -2
  119. package/skills/use-cache/SKILL.md +4 -2
  120. package/skills/view-transitions/SKILL.md +26 -0
  121. package/src/__augment-tests__/augmented.check.ts +20 -0
  122. package/src/browser/defer.ts +82 -0
  123. package/src/browser/document-revealed.ts +52 -0
  124. package/src/browser/event-controller.ts +46 -32
  125. package/src/browser/merge-segment-loaders.ts +31 -18
  126. package/src/browser/navigation-bridge.ts +82 -16
  127. package/src/browser/navigation-client.ts +48 -36
  128. package/src/browser/navigation-store.ts +13 -0
  129. package/src/browser/navigation-transaction.ts +25 -8
  130. package/src/browser/partial-update.ts +554 -146
  131. package/src/browser/prefetch/fetch.ts +2 -1
  132. package/src/browser/react/Link.tsx +7 -9
  133. package/src/browser/react/NavigationProvider.tsx +88 -49
  134. package/src/browser/react/context.ts +3 -2
  135. package/src/browser/react/filter-segment-order.ts +13 -2
  136. package/src/browser/react/use-router.ts +2 -4
  137. package/src/browser/response-adapter.ts +20 -0
  138. package/src/browser/rsc-router.tsx +17 -45
  139. package/src/browser/server-action-bridge.ts +25 -63
  140. package/src/browser/types.ts +24 -10
  141. package/src/browser/validate-redirect-origin.ts +16 -0
  142. package/src/build/generate-route-types.ts +1 -0
  143. package/src/build/route-types/router-processing.ts +3 -6
  144. package/src/build/route-types/scan-filter.ts +45 -11
  145. package/src/cache/cf/cf-cache-store.ts +59 -21
  146. package/src/cache/cf/cf-cache-types.ts +24 -2
  147. package/src/cache/document-cache-policy.ts +131 -0
  148. package/src/cache/document-cache.ts +21 -107
  149. package/src/client-urls/client-urls.ts +22 -6
  150. package/src/client-urls/hoisted-revalidate.ts +25 -0
  151. package/src/client-urls/navigation.ts +0 -96
  152. package/src/client-urls/revalidate-registry.ts +30 -0
  153. package/src/client-urls/server-projection.ts +151 -65
  154. package/src/client-urls/types.ts +39 -71
  155. package/src/client.tsx +9 -5
  156. package/src/context-var.ts +32 -2
  157. package/src/errors.ts +34 -0
  158. package/src/handles/deferred-resolution.ts +28 -5
  159. package/src/handles/pushed-by.ts +148 -0
  160. package/src/index.rsc.ts +8 -0
  161. package/src/index.ts +7 -0
  162. package/src/loader-store.ts +18 -7
  163. package/src/prefetch-markers.ts +13 -0
  164. package/src/response-utils.ts +19 -0
  165. package/src/reverse.ts +17 -14
  166. package/src/route-content-wrapper.tsx +18 -4
  167. package/src/route-definition/dsl-helpers.ts +27 -4
  168. package/src/route-definition/helpers-types.ts +27 -1
  169. package/src/route-definition/redirect.ts +2 -15
  170. package/src/route-definition/rewrite.ts +146 -0
  171. package/src/route-map-builder.ts +14 -11
  172. package/src/router/basename.ts +20 -0
  173. package/src/router/handler-context.ts +3 -13
  174. package/src/router/intercept-resolution.ts +35 -16
  175. package/src/router/internal-params.ts +16 -0
  176. package/src/router/is-action.ts +2 -1
  177. package/src/router/loader-resolution.ts +85 -4
  178. package/src/router/match-api.ts +36 -1
  179. package/src/router/match-middleware/background-revalidation.ts +16 -4
  180. package/src/router/match-middleware/cache-lookup.ts +35 -7
  181. package/src/router/match-middleware/cache-store.ts +15 -1
  182. package/src/router/match-middleware/intercept-resolution.ts +4 -2
  183. package/src/router/match-result.ts +14 -2
  184. package/src/router/middleware-types.ts +16 -0
  185. package/src/router/middleware.ts +127 -49
  186. package/src/router/navigation-snapshot.ts +2 -1
  187. package/src/router/revalidation.ts +1 -1
  188. package/src/router/route-trie-builder.ts +28 -1
  189. package/src/router/segment-resolution/fresh.ts +22 -18
  190. package/src/router/segment-resolution/loader-cache.ts +2 -1
  191. package/src/router/segment-resolution/loader-mask.ts +5 -3
  192. package/src/router/segment-resolution/loader-snapshot.ts +18 -4
  193. package/src/router/segment-resolution/mask-nested.ts +8 -4
  194. package/src/router/segment-resolution/prefetch-deferral.ts +583 -0
  195. package/src/router/segment-resolution/revalidation.ts +215 -82
  196. package/src/rsc/full-payload.ts +37 -5
  197. package/src/rsc/handler.ts +82 -55
  198. package/src/rsc/helpers.ts +24 -0
  199. package/src/rsc/loader-fetch.ts +79 -24
  200. package/src/rsc/response-cache-serve.ts +18 -2
  201. package/src/rsc/response-route-handler.ts +4 -7
  202. package/src/rsc/rewrite-passes.ts +203 -0
  203. package/src/rsc/rsc-rendering.ts +54 -9
  204. package/src/rsc/shell-capture.ts +2 -1
  205. package/src/rsc/ssr-setup.ts +11 -5
  206. package/src/segment-loader-promise.ts +23 -20
  207. package/src/segment-system.tsx +60 -26
  208. package/src/server/context.ts +39 -5
  209. package/src/server/cookie-parse.ts +36 -12
  210. package/src/server/cookie-store.ts +7 -3
  211. package/src/server/handle-store.ts +152 -25
  212. package/src/server/request-context.ts +175 -64
  213. package/src/server/request-headers.ts +4 -14
  214. package/src/server/rewrite-trail.ts +190 -0
  215. package/src/server/slot-accessors.ts +57 -0
  216. package/src/server.ts +4 -0
  217. package/src/ssr/index.tsx +118 -16
  218. package/src/testing/action-form.ts +2 -11
  219. package/src/testing/dispatch.ts +138 -67
  220. package/src/testing/e2e/page-helpers.ts +3 -1
  221. package/src/testing/flight.entry.ts +1 -0
  222. package/src/testing/index.ts +7 -3
  223. package/src/testing/internal/action-id.ts +16 -1
  224. package/src/testing/internal/client-urls-projection.ts +70 -0
  225. package/src/testing/internal/test-action.ts +25 -0
  226. package/src/testing/render-route.tsx +16 -3
  227. package/src/testing/run-loader.ts +4 -1
  228. package/src/testing/run-middleware.ts +56 -11
  229. package/src/testing/run-revalidate.ts +142 -0
  230. package/src/testing/run-transition-when.ts +7 -8
  231. package/src/testing/serve-shell-request.ts +131 -8
  232. package/src/testing/vitest.ts +67 -1
  233. package/src/thenable-status.ts +11 -0
  234. package/src/types/boundaries.ts +12 -0
  235. package/src/types/handler-context.ts +14 -0
  236. package/src/types/loader-types.ts +30 -0
  237. package/src/types/request-scope.ts +8 -0
  238. package/src/types/segments.ts +26 -4
  239. package/src/urls/path-helper-types.ts +27 -5
  240. package/src/urls/type-extraction.ts +30 -10
  241. package/src/use-loader.tsx +18 -13
  242. package/src/vite/discovery/client-urls-projection.ts +20 -21
  243. package/src/vite/discovery/discover-routers.ts +94 -26
  244. package/src/vite/discovery/state.ts +28 -4
  245. package/src/vite/discovery/virtual-module-codegen.ts +22 -17
  246. package/src/vite/plugins/client-urls-revalidate-hoist.ts +564 -0
  247. package/src/vite/plugins/expose-internal-ids.ts +2 -3
  248. package/src/vite/plugins/hoist/literal.ts +417 -0
  249. package/src/vite/plugins/hoist/scope.ts +370 -0
  250. package/src/vite/plugins/hoist/source.ts +84 -0
  251. package/src/vite/plugins/transition-when-hoist.ts +113 -779
  252. package/src/vite/rango.ts +2 -0
  253. package/src/vite/router-discovery.ts +116 -53
  254. package/dist/types/client-urls/revalidate-chain.d.ts +0 -33
  255. package/dist/types/client-urls/revalidation-protocol.d.ts +0 -25
  256. package/dist/types/testing/run-client-revalidate.d.ts +0 -43
  257. package/src/client-urls/revalidate-chain.ts +0 -83
  258. package/src/client-urls/revalidation-protocol.ts +0 -56
  259. package/src/testing/run-client-revalidate.ts +0 -92
@@ -35,6 +35,7 @@ import { shouldPrefetch } from "./policy.js";
35
35
  import { debugLog, IS_BROWSER_DEBUG } from "../logging.js";
36
36
  import { teeWithCompletion, isForeignRouterId } from "../response-adapter.js";
37
37
  import { SEGMENT_FRAGMENT_CAPABILITY_HEADER } from "../../segment-fragments.js";
38
+ import { PREFETCH_HEADER } from "../../prefetch-markers.js";
38
39
  import type { RscPayload } from "../types.js";
39
40
 
40
41
  /**
@@ -263,7 +264,7 @@ function executePrefetchFetch(
263
264
  headers: {
264
265
  "X-Rango-State": rangoState,
265
266
  "X-RSC-Router-Client-Path": window.location.href,
266
- "X-Rango-Prefetch": "1",
267
+ [PREFETCH_HEADER]: "1",
267
268
  ...(isFragmentPassthroughEnabled() && {
268
269
  [SEGMENT_FRAGMENT_CAPABILITY_HEADER]: "1",
269
270
  }),
@@ -42,6 +42,7 @@ import {
42
42
  subscribeToAdaptiveStrategyChange,
43
43
  } from "../prefetch/default-strategy.js";
44
44
  import { getAppVersion } from "../app-version.js";
45
+ import { prefixBasename } from "../../router/basename.js";
45
46
  import type { PrefetchStrategy } from "../../router/prefetch-default.js";
46
47
 
47
48
  // The PrefetchStrategy union is defined in router/prefetch-default.ts (both
@@ -244,15 +245,12 @@ export const Link: ForwardRefExoticComponent<
244
245
  // store and no origin: links stay internal.
245
246
  const isExternal = isExternalUrl(to, ctx?.origin);
246
247
 
247
- // Auto-prefix with basename for app-local paths.
248
- // Skip if external, already prefixed, or not a root-relative path.
249
- const resolvedTo = useMemo(() => {
250
- if (isExternal) return to;
251
- const bn = ctx?.basename;
252
- if (!bn || !to.startsWith("/") || to.startsWith(bn + "/") || to === bn)
253
- return to;
254
- return to === "/" ? bn : bn + to;
255
- }, [to, isExternal, ctx?.basename]);
248
+ // Auto-prefix with basename for app-local paths (prefixBasename: not when
249
+ // external, already prefixed, or not root-relative).
250
+ const resolvedTo = useMemo(
251
+ () => (isExternal ? to : prefixBasename(to, ctx?.basename)),
252
+ [to, isExternal, ctx?.basename],
253
+ );
256
254
 
257
255
  // No explicit `prefetch` prop: fall back to the router-wide default from
258
256
  // the router store. Adaptive is resolved against the input capability when a
@@ -22,6 +22,7 @@ import type {
22
22
  NavigationUpdate,
23
23
  NavigateOptionsInternal,
24
24
  NavigationBridge,
25
+ RscMetadata,
25
26
  } from "../types.js";
26
27
  import type { EventController } from "../event-controller.js";
27
28
  import { RootErrorBoundary } from "../../root-error-boundary.js";
@@ -211,6 +212,30 @@ async function processHandles(
211
212
  }
212
213
  }
213
214
 
215
+ /**
216
+ * Read the handle stream of `metadata` into the event controller and the
217
+ * entry on screen. The provider's own call for every payload it is handed,
218
+ * and the one write for a response that hands React nothing (a fill,
219
+ * browser/partial-update.ts fillHoles).
220
+ */
221
+ export function consumeHandles(
222
+ metadata: RscMetadata,
223
+ eventController: EventController,
224
+ store: NavigationStore,
225
+ ): void {
226
+ processHandles(metadata.handles!, {
227
+ eventController,
228
+ store,
229
+ matched: metadata.matched,
230
+ isPartial: metadata.isPartial,
231
+ resolvedIds: metadata.resolvedIds,
232
+ // Now: by the time the stream ends the user may be elsewhere.
233
+ historyKey: store.getHistoryKey(),
234
+ }).catch((err) =>
235
+ console.error("[NavigationProvider] Error consuming handles:", err),
236
+ );
237
+ }
238
+
214
239
  /**
215
240
  * Props for NavigationProvider
216
241
  */
@@ -286,10 +311,17 @@ export interface NavigationProviderProps {
286
311
  * The document this provider hydrates (initBrowserApp; renderRoute with
287
312
  * `hydrate: true`), whose store EventController.lockHydration locked.
288
313
  * `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.
314
+ * boundary. Then a transition changes HydrationBarrierContext alone, and
315
+ * React commits it once every boundary has hydrated, one still suspended on
316
+ * the client (a lazy chunk, a deferred-hydration gate) included. Until that
317
+ * commit location state is `undefined`, as SSR rendered it, through any
318
+ * commit no payload follows (a shallow navigation), and the store is
319
+ * locked; after it the entry's state lands in a transition of its own
320
+ * and the store is released (handle data lands in that release
321
+ * notification). A navigation committed earlier releases the store before
322
+ * its update, for the whole hold when the navigation is held: an old-page
323
+ * boundary hydrating meanwhile reads the destination's values and can
324
+ * mismatch.
293
325
  * Absent for a provider mounted on the client.
294
326
  */
295
327
  hydration?: { settled: Promise<void> };
@@ -368,6 +400,11 @@ export function NavigationProvider({
368
400
  }
369
401
  const shellRef = appShellRef ?? fallbackShellRef.current;
370
402
 
403
+ // Fixed for the document: read once on mount.
404
+ const [origin] = useState(
405
+ () => (eventController.getState().location as URL | undefined)?.origin,
406
+ );
407
+
371
408
  const contextValue = useMemo<NavigationStoreContextValue>(() => {
372
409
  const value = {
373
410
  store,
@@ -375,8 +412,7 @@ export function NavigationProvider({
375
412
  navigate,
376
413
  refresh,
377
414
  defaultPrefetch: initialPayload.metadata.defaultPrefetch,
378
- // Fixed for the document: read once, with the stable value.
379
- origin: (eventController.getState().location as URL | undefined)?.origin,
415
+ origin,
380
416
  } as NavigationStoreContextValue;
381
417
  Object.defineProperty(value, "basename", {
382
418
  configurable: true,
@@ -400,18 +436,18 @@ export function NavigationProvider({
400
436
  return startConnectionWarmup();
401
437
  }, [warmupEnabled]);
402
438
 
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.
439
+ // The document has been revealed. The barrier must be the only update in
440
+ // its transition lane: then React holds the commit until every boundary
441
+ // still dehydrated beneath it has hydrated. Any other update in the lane
442
+ // (location state in its own startTransition here included) can let React
443
+ // commit around a boundary still suspended on the client. Why:
444
+ // docs/internal/execution-model.md, "The reveal barrier is alone in its lane".
406
445
  useEffect(() => {
407
446
  if (!hydration) return;
408
447
  let cancelled = false;
409
448
  void hydration.settled.then(() => {
410
449
  if (cancelled || !hydrationOpen.current) return;
411
- startTransition(() => {
412
- setLocationState(eventController.getLocationState());
413
- setHydrationBarrier((n) => n + 1);
414
- });
450
+ startTransition(() => setHydrationBarrier((n) => n + 1));
415
451
  });
416
452
  return () => {
417
453
  cancelled = true;
@@ -419,20 +455,31 @@ export function NavigationProvider({
419
455
  }, []);
420
456
 
421
457
  // After the barrier's commit no boundary under this provider is dehydrated:
422
- // release the store, and its handle writes reach their readers.
458
+ // the entry's location state lands in a transition of its own, and the
459
+ // store is released so its handle writes reach their readers. A navigation
460
+ // that committed first has done both (store onUpdate below).
423
461
  useEffect(() => {
424
- if (hydrationBarrier === 0) return;
462
+ if (hydrationBarrier === 0 || !hydrationOpen.current) return;
425
463
  hydrationOpen.current = false;
464
+ startTransition(() => setLocationState(eventController.getLocationState()));
426
465
  eventController.releaseHydrationLock();
427
466
  }, [hydrationBarrier]);
428
467
 
429
468
  // A commit no payload follows (a shallow navigation, a commit that keeps
430
469
  // every segment, merged server-set state): the tree on screen is already
431
- // the entry's, so its state is taken with the commit's notification.
470
+ // the entry's, so its state is taken with the commit's notification. Not
471
+ // while the hydration window is open: a boundary of the document may still
472
+ // be dehydrated, an update a reader outside it takes commits around it, and
473
+ // it then hydrates against the new state. The barrier's effect gives the
474
+ // controller's state, this commit's included. The window stays open: this
475
+ // commit mounts nothing that must read the live store.
432
476
  useEffect(
433
477
  () =>
434
478
  eventController.subscribe(() => {
435
- if (eventController.takeTreelessLocationState()) {
479
+ if (
480
+ eventController.takeTreelessLocationState() &&
481
+ !hydrationOpen.current
482
+ ) {
436
483
  setLocationState(eventController.getLocationState());
437
484
  }
438
485
  }),
@@ -456,7 +503,7 @@ export function NavigationProvider({
456
503
  return unsub;
457
504
  }, [eventController]);
458
505
 
459
- // Pending scroll action to apply after React commits
506
+ // The last navigation's scroll decision, until React commits its update.
460
507
  const pendingScrollRef = useRef<NavigationUpdate["scroll"]>(undefined);
461
508
 
462
509
  // Apply scroll after React commits the new content to the DOM
@@ -478,11 +525,14 @@ export function NavigationProvider({
478
525
  useEffect(() => {
479
526
  const unsubscribe = store.onUpdate((update) => {
480
527
  if (process.env.NODE_ENV !== "production") auditTreeUpdate(update.root);
481
- // Capture scroll intent — it will be applied in useLayoutEffect
482
- // after React commits this state update to the DOM.
483
- // Always assign (even undefined) to clear stale scroll from prior navigations,
484
- // so server actions or error updates don't accidentally replay old scroll.
485
- pendingScrollRef.current = update.scroll;
528
+ // Scroll belongs to the navigation transaction: only an update that
529
+ // carries one's decision sets the pending action, and the layout effect
530
+ // above consumes it once, after the commit that follows. An update with
531
+ // no decision (server action, error, prefetch: false fill) leaves a
532
+ // pending one alone. React can hold a navigation's commit, and whatever
533
+ // lands meanwhile must not cost the navigation its scroll. Nothing
534
+ // stale is replayed: every update commits, and that commit consumes.
535
+ if (update.scroll) pendingScrollRef.current = update.scroll;
486
536
 
487
537
  // Committed before the document finished hydrating: end the window
488
538
  // first, so the page this update mounts reads the live store. A
@@ -494,10 +544,7 @@ export function NavigationProvider({
494
544
  eventController.releaseHydrationLock();
495
545
  }
496
546
 
497
- setPayload({
498
- root: update.root,
499
- metadata: update.metadata,
500
- });
547
+ setPayload({ root: update.root, metadata: update.metadata });
501
548
  // The state of the entry this payload renders, as its commit recorded
502
549
  // it: one update, so one lane, so one React commit with the tree.
503
550
  setLocationState(eventController.getLocationState());
@@ -509,31 +556,23 @@ export function NavigationProvider({
509
556
  eventController.setParams(update.metadata.params);
510
557
  }
511
558
 
559
+ // A page rendered from a history-cache entry (back/forward, or a link
560
+ // to it) starts from the entry's pushes, and its response's handles
561
+ // merge onto them: the page on screen's pushes are not its segments'.
562
+ const { matched, cachedHandleData } = update.metadata;
563
+ if (matched && cachedHandleData) {
564
+ eventController.setHandleData(cachedHandleData, matched);
565
+ }
512
566
  // Update handle data progressively as it streams in
513
567
  if (update.metadata.handles) {
514
- // Capture historyKey now - by the time async processing completes,
515
- // the user might have navigated elsewhere
516
- const historyKey = store.getHistoryKey();
517
-
518
- processHandles(update.metadata.handles, {
519
- eventController,
520
- store,
521
- matched: update.metadata.matched,
522
- isPartial: update.metadata.isPartial,
523
- resolvedIds: update.metadata.resolvedIds,
524
- historyKey,
525
- }).catch((err) =>
526
- console.error("[NavigationProvider] Error consuming handles:", err),
527
- );
528
- } else if (update.metadata.matched) {
529
- // cachedHandleData present -> full restore (back/forward); absent ->
530
- // partial cleanup of segments no longer matched.
531
- const cached = update.metadata.cachedHandleData;
568
+ consumeHandles(update.metadata, eventController, store);
569
+ } else if (matched && !cachedHandleData) {
570
+ // Partial cleanup of segments no longer matched.
532
571
  eventController.setHandleData(
533
- cached ?? {},
534
- update.metadata.matched,
535
- cached === undefined,
536
- cached === undefined ? update.metadata.resolvedIds : undefined,
572
+ {},
573
+ matched,
574
+ true,
575
+ update.metadata.resolvedIds,
537
576
  );
538
577
  }
539
578
 
@@ -91,8 +91,9 @@ export const LocationStateContext: Context<LocationStateSnapshot> =
91
91
  * Read by no component. NavigationProvider changes its value in a transition so
92
92
  * that React hydrates every boundary still dehydrated beneath it before that
93
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.
94
+ * changes, and nothing re-renders for a value nobody reads. It must stay
95
+ * unread and alone in its lane (docs/internal/execution-model.md, "The reveal
96
+ * barrier is alone in its lane"). See NavigationProviderProps.hydration.
96
97
  */
97
98
  export const HydrationBarrierContext: Context<number> =
98
99
  createContext<number>(0);
@@ -1,3 +1,14 @@
1
+ /**
2
+ * A loader sub-id carries a `D${index}.` marker ("M0L0D1.user"). Non-global,
3
+ * so `.test` and `.replace` are stateless.
4
+ */
5
+ export const LOADER_SEGMENT_ID: RegExp = /D\d+\./;
6
+
7
+ /** True for a loader sub-id ("M0L0D1.user"), false for a layout/route/slot id. */
8
+ export function isLoaderSegmentId(id: string): boolean {
9
+ return LOADER_SEGMENT_ID.test(id);
10
+ }
11
+
1
12
  /**
2
13
  * Build the handle-collection segment order from a raw `matched` list.
3
14
  *
@@ -24,7 +35,7 @@ export function filterSegmentOrder(matched: string[]): string[] {
24
35
  const nonSlotSet = new Set<string>();
25
36
 
26
37
  for (const id of matched) {
27
- if (/D\d+\./.test(id)) continue;
38
+ if (isLoaderSegmentId(id)) continue;
28
39
  const slotIdx = id.indexOf(".@");
29
40
  if (slotIdx >= 0) {
30
41
  const parent = id.slice(0, slotIdx);
@@ -66,5 +77,5 @@ export function filterSegmentOrder(matched: string[]): string[] {
66
77
  * and React reports a hydration mismatch.
67
78
  */
68
79
  export function filterRouteSegmentIds(matched: string[]): string[] {
69
- return matched.filter((id) => !id.includes(".@") && !/D\d+\./.test(id));
80
+ return matched.filter((id) => !id.includes(".@") && !isLoaderSegmentId(id));
70
81
  }
@@ -5,6 +5,7 @@ import { NavigationStoreContext } from "./context.js";
5
5
  import { useMount } from "./use-mount.js";
6
6
  import { prefetchDirect } from "../prefetch/loader.js";
7
7
  import { getAppVersion } from "../app-version.js";
8
+ import { prefixBasename } from "../../router/basename.js";
8
9
  import type { RouterInstance, RouterNavigateOptions } from "../types.js";
9
10
 
10
11
  /**
@@ -51,10 +52,7 @@ export function useRouter(): RouterInstance {
51
52
  return useMemo<RouterInstance>(() => {
52
53
  /** Prefix a root-relative path with basename if not already prefixed. */
53
54
  function withBasename(url: string): string {
54
- const bn = ctx!.basename;
55
- if (!bn || !url.startsWith("/") || url.startsWith(bn + "/") || url === bn)
56
- return url;
57
- return url === "/" ? bn : bn + url;
55
+ return prefixBasename(url, ctx!.basename);
58
56
  }
59
57
 
60
58
  /**
@@ -26,6 +26,26 @@ export function extractRscHeaderUrl(
26
26
  return url ? { url } : "blocked";
27
27
  }
28
28
 
29
+ /**
30
+ * Run `request` with a signal that `signal` aborts until the request settles,
31
+ * and only until then. A response that has arrived streams to its end:
32
+ * aborting a Flight body mid-read rejects every chunk a mounted tree may be
33
+ * reading. Aborted, the request rejects with `signal.reason`.
34
+ */
35
+ export function abortUntilResponse<T>(
36
+ signal: AbortSignal | undefined,
37
+ request: (signal: AbortSignal | undefined) => Promise<T>,
38
+ ): Promise<T> {
39
+ if (!signal) return request(undefined);
40
+ const controller = new AbortController();
41
+ const cancel = (): void => controller.abort(signal.reason);
42
+ if (signal.aborted) cancel();
43
+ else signal.addEventListener("abort", cancel, { once: true });
44
+ return request(controller.signal).finally(() =>
45
+ signal.removeEventListener("abort", cancel),
46
+ );
47
+ }
48
+
29
49
  /**
30
50
  * Empty 200 response that won't choke Flight parsing.
31
51
  * Used when a header URL is blocked by origin validation.
@@ -11,6 +11,7 @@ import {
11
11
  import { createEventController } from "./event-controller.js";
12
12
  import { resolveDeferredHandleValues } from "../handles/deferred-resolution.js";
13
13
  import { validateRedirectOrigin } from "./validate-redirect-origin.js";
14
+ import { awaitDocumentRevealed } from "./document-revealed.js";
14
15
  import { createNavigationClient } from "./navigation-client.js";
15
16
  import { createServerActionBridge } from "./server-action-bridge.js";
16
17
  import { createNavigationBridge } from "./navigation-bridge.js";
@@ -331,14 +332,23 @@ export async function initBrowserApp(
331
332
  // deferred values here exactly like the nav lane's processHandles
332
333
  // does before applying.
333
334
  const lateData = await resolveDeferredHandleValues(rawLateData);
334
- bootLog("handles: late push applied", {
335
+ // Live store only while the document's page is on screen: a full
336
+ // replace with its `matched` would overwrite another page's handles.
337
+ const pageKeys = store.getPageHistoryKeys(initialHistoryKey);
338
+ const onScreen = pageKeys.includes(store.getHistoryKey());
339
+ bootLog("handles: late push", {
335
340
  segments: Object.keys(lateData),
341
+ onScreen,
336
342
  });
337
- eventController.setHandleData(
338
- lateData,
339
- initialPayload.metadata?.matched,
340
- );
341
- store.updateCacheHandleData(initialHistoryKey, lateData);
343
+ if (onScreen) {
344
+ eventController.setHandleData(
345
+ lateData,
346
+ initialPayload.metadata?.matched,
347
+ );
348
+ }
349
+ for (const key of pageKeys) {
350
+ store.updateCacheHandleData(key, lateData);
351
+ }
342
352
  }
343
353
  } catch (err) {
344
354
  console.error("[rsc-router] Error consuming late handles:", err);
@@ -641,28 +651,7 @@ export async function initBrowserApp(
641
651
  linkInterceptionEnabled: linkInterception,
642
652
  version,
643
653
  appShellRef,
644
- documentRevealed: new Promise<void>((resolve) => {
645
- // Parsing ends with the stream, but React reveals a streamed boundary
646
- // in batches up to 300 ms later (Fizz's $RC marks it `$~` and reveals
647
- // it from a timer), so the boundary is still pending at
648
- // DOMContentLoaded. A transition then would make React client-render
649
- // it over its server HTML.
650
- const start = (): void => {
651
- // A reveal rewrites the same comment's data, or React removes the
652
- // node; a boundary nested in revealed content joins the document
653
- // only then, so resolve only once a full walk finds none.
654
- let pending = unrevealedBoundaries();
655
- const check = (): void => {
656
- pending = pending.filter(isUnrevealed);
657
- if (pending.length === 0) pending = unrevealedBoundaries();
658
- if (pending.length > 0) requestAnimationFrame(check);
659
- else resolve();
660
- };
661
- check();
662
- };
663
- if (document.readyState !== "loading") start();
664
- else document.addEventListener("DOMContentLoaded", start, { once: true });
665
- }),
654
+ documentRevealed: awaitDocumentRevealed(),
666
655
  };
667
656
  browserAppContext = context;
668
657
 
@@ -670,23 +659,6 @@ export async function initBrowserApp(
670
659
  return context;
671
660
  }
672
661
 
673
- /**
674
- * A Suspense boundary of the document React has not revealed yet: Fizz marks
675
- * one `$?` while its content streams and `$~` once its reveal is queued.
676
- */
677
- function isUnrevealed(node: Comment): boolean {
678
- return node.isConnected && (node.data === "$?" || node.data === "$~");
679
- }
680
-
681
- function unrevealedBoundaries(): Comment[] {
682
- const found: Comment[] = [];
683
- const walker = document.createTreeWalker(document, NodeFilter.SHOW_COMMENT);
684
- for (let node = walker.nextNode(); node; node = walker.nextNode()) {
685
- if (isUnrevealed(node as Comment)) found.push(node as Comment);
686
- }
687
- return found;
688
- }
689
-
690
662
  // Once-flag so the hydration-commit boot log fires a single time (StrictMode
691
663
  // re-runs the root effect; the second flush is not a second hydration).
692
664
  let hydrationCommitLogged = false;
@@ -26,8 +26,8 @@ import {
26
26
  startBrowserTransaction,
27
27
  } from "./logging.js";
28
28
  import {
29
+ validatePayloadRedirect,
29
30
  validateRedirectOrigin,
30
- validateExternalRedirect,
31
31
  } from "./validate-redirect-origin.js";
32
32
  import {
33
33
  extractRscHeaderUrl,
@@ -40,13 +40,11 @@ import { mergeLocationState } from "./history-state.js";
40
40
  import { stampLocationState } from "./react/location-state-shared.js";
41
41
  import { classifyActionOutcome } from "./action-coordinator.js";
42
42
  import { getAppVersion } from "./app-version.js";
43
- import { collectClientRevalidationDecisions } from "../client-urls/navigation.js";
44
43
  import {
45
44
  decideCommitGatedOff,
46
45
  findActionFormData,
47
46
  type TransitionWhenActionInput,
48
47
  } from "./transition-when.js";
49
- import { CLIENT_REVALIDATION_HEADER } from "../client-urls/revalidation-protocol.js";
50
48
  import { auditTreeCause } from "../suspense-audit.js";
51
49
 
52
50
  // Polyfill Symbol.dispose/asyncDispose for Safari and older browsers
@@ -335,39 +333,12 @@ export function createServerActionBridge(
335
333
  const onHandleAbort = () => fetchAbort.abort();
336
334
  handle.signal.addEventListener("abort", onHandleAbort, { once: true });
337
335
 
338
- // Client-run per-loader revalidation for the action follow-up render:
339
- // run the held clientUrls route's revalidate() predicates with the
340
- // action context and ship their decisions. Fails soft to null (locked
341
- // server defaults).
342
- let clientRevalidation: string | null = null;
343
- try {
344
- const actionPageUrl = new URL(
345
- segmentState.currentUrl,
346
- window.location.origin,
347
- );
348
- clientRevalidation = collectClientRevalidationDecisions({
349
- currentUrl: actionPageUrl,
350
- nextUrl: actionPageUrl,
351
- // Decisions ride the action POST itself — the server evaluates it
352
- // with actionContext, so the locked default is the action default.
353
- actionRequest: true,
354
- isAction: true,
355
- actionId: id,
356
- stale: false,
357
- });
358
- } catch {
359
- clientRevalidation = null;
360
- }
361
-
362
336
  // Send action request with stream tracking
363
337
  const responsePromise = fetch(url, {
364
338
  method: "POST",
365
339
  headers: {
366
340
  "rsc-action": id,
367
341
  "X-RSC-Router-Client-Path": segmentState.currentUrl,
368
- ...(clientRevalidation && {
369
- [CLIENT_REVALIDATION_HEADER]: clientRevalidation,
370
- }),
371
342
  ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
372
343
  ...(interceptSourceUrl && {
373
344
  "X-RSC-Router-Intercept-Source": interceptSourceUrl,
@@ -518,41 +489,26 @@ export function createServerActionBridge(
518
489
  // Check handle.signal.aborted to avoid redirecting from a stale action
519
490
  // when the user has already navigated away.
520
491
  if (metadata?.redirect && !handle.signal.aborted) {
521
- // Explicit off-host redirect (redirect(url, { external: true })):
522
- // hard-navigate, but still scheme-validate (http/https only). external
523
- // waives the same-origin check, NOT scheme safety, so a forged payload
524
- // carrying a javascript:/data: URL cannot script via location.assign.
525
- if (metadata.redirect.external) {
526
- const externalUrl = validateExternalRedirect(
527
- metadata.redirect.url,
528
- window.location.origin,
492
+ // An explicit off-host redirect is a hard navigation, still
493
+ // scheme-validated: a forged payload carrying a javascript:/data: URL
494
+ // cannot script via location.assign (validatePayloadRedirect).
495
+ const { redirect } = metadata;
496
+ const target = validatePayloadRedirect(redirect);
497
+ handle.complete(returnValue?.data);
498
+ if (!target) {
499
+ log(
500
+ redirect.external
501
+ ? "blocked external action redirect payload"
502
+ : "blocked action redirect payload",
503
+ { url: redirect.url },
529
504
  );
530
- if (!externalUrl) {
531
- log("blocked external action redirect payload", {
532
- url: metadata.redirect.url,
533
- });
534
- handle.complete(returnValue?.data);
535
- return returnValue?.data;
536
- }
537
- log("external action redirect", { url: externalUrl });
538
- handle.complete(returnValue?.data);
539
- window.location.assign(externalUrl);
540
- return returnValue?.data;
505
+ } else if (redirect.external) {
506
+ log("external action redirect", { url: target });
507
+ window.location.assign(target);
508
+ } else {
509
+ log("action redirect", { url: target });
510
+ await dispatchRedirect(target, metadata.locationState);
541
511
  }
542
- const redirectUrl = validateRedirectOrigin(
543
- metadata.redirect.url,
544
- window.location.origin,
545
- );
546
- if (!redirectUrl) {
547
- log("blocked action redirect payload", {
548
- url: metadata.redirect.url,
549
- });
550
- handle.complete(returnValue?.data);
551
- return returnValue?.data;
552
- }
553
- log("action redirect", { url: redirectUrl });
554
- handle.complete(returnValue?.data);
555
- await dispatchRedirect(redirectUrl, metadata.locationState);
556
512
  return returnValue?.data;
557
513
  }
558
514
 
@@ -950,6 +906,12 @@ export function createServerActionBridge(
950
906
 
951
907
  handle.complete(returnData);
952
908
  return returnData;
909
+ } catch (error) {
910
+ // What the call rejects with (a revalidation render that threw, a
911
+ // failed decode) is the action's error, for useAction as for the
912
+ // caller. A no-op for an aborted or already-settled action.
913
+ handle.fail(error);
914
+ throw error;
953
915
  } finally {
954
916
  // The single deferred invalidation + fence release for this action. Runs
955
917
  // for every terminal that settles (normal, navigated-away, error, abort,