@rangojs/router 0.28.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 (79) hide show
  1. package/dist/bin/rango.js +1 -1
  2. package/dist/types/browser/action-fence.d.ts +2 -1
  3. package/dist/types/browser/event-controller.d.ts +6 -1
  4. package/dist/types/browser/partial-update.d.ts +11 -0
  5. package/dist/types/browser/react/use-action.d.ts +7 -2
  6. package/dist/types/browser/react/use-handle.d.ts +19 -16
  7. package/dist/types/browser/react/use-link-status.d.ts +5 -2
  8. package/dist/types/browser/segment-reconciler.d.ts +26 -0
  9. package/dist/types/browser/types.d.ts +22 -4
  10. package/dist/types/browser/wire-protocol.d.ts +1 -0
  11. package/dist/types/cache/tag-invalidation.d.ts +0 -10
  12. package/dist/types/router/error-handling.d.ts +7 -1
  13. package/dist/types/router/match-context.d.ts +2 -0
  14. package/dist/types/router/match-middleware/background-revalidation.d.ts +3 -2
  15. package/dist/types/router/navigation-snapshot.d.ts +7 -0
  16. package/dist/types/router/segment-resolution/helpers.d.ts +19 -3
  17. package/dist/types/router/segment-resolution/resolution-pass.d.ts +2 -0
  18. package/dist/types/router/segment-resolution/streamed-handler-boundary.d.ts +2 -0
  19. package/dist/types/router/types.d.ts +1 -1
  20. package/dist/types/server/request-context.d.ts +30 -1
  21. package/dist/types/testing/render-route.d.ts +43 -8
  22. package/dist/types/testing/serve-shell-request.d.ts +8 -1
  23. package/dist/types/types/error-types.d.ts +27 -8
  24. package/dist/types/wire-params.d.ts +5 -0
  25. package/dist/vite/index.js +15 -19
  26. package/package.json +1 -1
  27. package/skills/client-urls/SKILL.md +4 -2
  28. package/skills/hooks/SKILL.md +1 -1
  29. package/skills/hooks/data.md +8 -1
  30. package/skills/hooks/handle-and-actions.md +33 -2
  31. package/skills/hooks/navigation.md +121 -15
  32. package/skills/hooks/state.md +6 -4
  33. package/skills/hooks/urls.md +16 -5
  34. package/skills/intercept/SKILL.md +6 -0
  35. package/skills/observability/SKILL.md +30 -0
  36. package/skills/server-actions/SKILL.md +8 -1
  37. package/skills/testing/client-components.md +25 -18
  38. package/skills/testing/serve-shell-request.md +7 -7
  39. package/skills/view-transitions/SKILL.md +1 -1
  40. package/src/browser/action-fence.ts +2 -1
  41. package/src/browser/event-controller.ts +32 -10
  42. package/src/browser/navigation-bridge.ts +45 -13
  43. package/src/browser/navigation-client.ts +2 -0
  44. package/src/browser/network-error-handler.ts +4 -0
  45. package/src/browser/partial-update.ts +33 -6
  46. package/src/browser/react/use-action.ts +24 -48
  47. package/src/browser/react/use-handle.ts +22 -18
  48. package/src/browser/react/use-link-status.ts +48 -60
  49. package/src/browser/react/use-navigation.ts +92 -41
  50. package/src/browser/segment-reconciler.ts +43 -0
  51. package/src/browser/server-action-bridge.ts +35 -8
  52. package/src/browser/types.ts +23 -4
  53. package/src/browser/wire-protocol.ts +2 -0
  54. package/src/cache/cache-runtime.ts +28 -31
  55. package/src/cache/document-cache.ts +34 -37
  56. package/src/cache/read-through-swr.ts +20 -18
  57. package/src/cache/tag-invalidation.ts +3 -18
  58. package/src/router/error-handling.ts +18 -6
  59. package/src/router/intercept-resolution.ts +9 -4
  60. package/src/router/match-api.ts +1 -0
  61. package/src/router/match-context.ts +3 -0
  62. package/src/router/match-middleware/background-revalidation.ts +5 -4
  63. package/src/router/match-middleware/cache-lookup.ts +5 -3
  64. package/src/router/navigation-snapshot.ts +10 -0
  65. package/src/router/segment-resolution/helpers.ts +49 -13
  66. package/src/router/segment-resolution/loader-cache.ts +4 -3
  67. package/src/router/segment-resolution/resolution-pass.ts +2 -0
  68. package/src/router/segment-resolution/revalidation.ts +57 -28
  69. package/src/router/segment-resolution/streamed-handler-boundary.ts +3 -0
  70. package/src/router/types.ts +1 -1
  71. package/src/router.ts +7 -4
  72. package/src/rsc/request-stages.ts +4 -2
  73. package/src/rsc/shell-capture.ts +63 -58
  74. package/src/server/request-context.ts +49 -0
  75. package/src/testing/dispatch.ts +3 -0
  76. package/src/testing/render-route.tsx +199 -13
  77. package/src/testing/serve-shell-request.ts +19 -10
  78. package/src/types/error-types.ts +27 -8
  79. package/src/wire-params.ts +5 -0
@@ -66,9 +66,13 @@ type RenderRouteResult = RenderResult & {
66
66
  url: string,
67
67
  options?: {
68
68
  loaders?: ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>;
69
+ handles?: ReadonlyArray<readonly [Handle<any, any>, unknown[]]>;
69
70
  state?: HistoryState;
70
71
  replace?: boolean;
71
72
  transition?: boolean;
73
+ // the response's stream: opens a task after the call, before the
74
+ // commit, and stays open until the promise settles (isStreaming)
75
+ streamUntil?: Promise<unknown>;
72
76
  },
73
77
  ): Promise<void>;
74
78
  // re-render the current location, as router.refresh() does:
@@ -203,13 +207,14 @@ it("lists no product twice while the next page loads", async () => {
203
207
  });
204
208
  ```
205
209
 
206
- | To bring state to a reader | Do |
207
- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
208
- | a push / replace, the component's own | click its `<Link state>` or the control that calls `router.push(url, { state })`, then `waitFor` |
209
- | a push / replace whose loader the test holds | `router.navigate(url, { state, replace?, loaders: [[Loader, pendingPromise]] })` on a `transition` spec |
210
- | back/forward onto an entry | `history.replaceState(entry, "")`, then `window.dispatchEvent(new Event("popstate"))` inside `act`. The tree has one location, so no page is restored: readers take the entry as history holds it (also how a `Def.write()` becomes visible) |
211
- | a reader that mounts while a navigation is pending | hold the navigation as in the row above, then mount the reader inside `await act(async () => { ... })`: it reads the entry on screen |
212
- | a server action's state, a redirect's, an intercept | e2e |
210
+ | To bring state to a reader | Do |
211
+ | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
212
+ | a push / replace, the component's own | click its `<Link state>` or the control that calls `router.push(url, { state })`, then `waitFor` |
213
+ | a push / replace whose loader the test holds | `router.navigate(url, { state, replace?, loaders: [[Loader, pendingPromise]] })` on a `transition` spec |
214
+ | back/forward onto the mounted entry or one a navigation wrote | `window.history.back()` inside `await act(async () => { ... })`: the entry's page is restored with its state and handle values, as production's history-cache restore is |
215
+ | back/forward onto an entry the test wrote | `history.replaceState(entry, "")`, then `window.dispatchEvent(new Event("popstate"))` inside `act`: readers take the entry as history holds it (also how a `Def.write()` becomes visible) |
216
+ | a reader that mounts while a navigation is pending | hold the navigation as in the row above, then mount the reader inside `await act(async () => { ... })`: it reads the entry on screen |
217
+ | a server action's state, a redirect's, an intercept | e2e |
213
218
 
214
219
  ### Definitions with `clearOnReload`
215
220
 
@@ -390,17 +395,19 @@ it("dims the held price while the next product streams", async () => {
390
395
  });
391
396
  ```
392
397
 
393
- | Pending state | Under `renderRoute` |
394
- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
395
- | `useLoader().isLoading` on the reader a `transition` navigate() holds, pending seed | Modeled: `true` on the OLD data until the promise settles, then `false` with the new data in the same commit. |
396
- | `transition` navigate(), settled seed (a plain value) | `false`; the data swaps in the commit. |
397
- | navigate() without `transition`, pending seed | Urgent commit (production's cold-navigation lane): the read suspends to the nearest `<Suspense>` fallback (with none, React keeps the previous tree). No flag. |
398
- | `transition: { when }` returning false, or `navigate(url, { transition: false })` | Same as without `transition`: urgent, with production's decision (`kind: "push"`; `refresh()` is `"revalidate"`). `transition: false` calls no predicate. |
399
- | Production's other hold lanes (same-structure search/filter nav, fully-prefetched, optimistic) | NOT modeled — without `transition` navigate() stays urgent. Assert those at e2e. |
400
- | `useNavigation().state`, `useLinkStatus().pending` | Modeled: navigate() starts the navigation as a click does. `loading` from the call, `idle` in the commit, held with it; `pending` true for the destination's link until the commit is handed to React. |
401
- | `useAction().state` | Stays `idle`: navigate() runs no action. e2e. |
402
- | `useHandle()` on a held navigation, `navigate(url, { handles, loaders })` | Modeled: `handles` arrive on the response's handle stream, so readers keep the page being left's values while React holds the page and take the new ones with its commit. |
403
- | A back/forward restored from the history cache (its view transition, `pop`) | NOT modeled: the tree has one location and navigate() caches no entries, so a `popstate` restores nothing. Assert it at e2e. |
398
+ | Pending state | Under `renderRoute` |
399
+ | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
400
+ | `useLoader().isLoading` on the reader a `transition` navigate() holds, pending seed | Modeled: `true` on the OLD data until the promise settles, then `false` with the new data in the same commit. |
401
+ | `transition` navigate(), settled seed (a plain value) | `false`; the data swaps in the commit. |
402
+ | navigate() without `transition`, pending seed | Urgent commit (production's cold-navigation lane): the read suspends to the nearest `<Suspense>` fallback (with none, React keeps the previous tree). No flag. |
403
+ | `transition: { when }` returning false, or `navigate(url, { transition: false })` | Same as without `transition`: urgent, with production's decision (`kind: "push"`; `refresh()` is `"revalidate"`). `transition: false` calls no predicate. |
404
+ | Production's other hold lanes (same-structure search/filter nav, fully-prefetched, optimistic) | NOT modeled — without `transition` navigate() stays urgent. Assert those at e2e. |
405
+ | `useNavigation().state`, `useLinkStatus().pending` | Modeled: navigate() starts the navigation as a click does. `loading` from the call, `idle` in the commit, held with it; `pending` true for the destination's link until that commit, held with it too. |
406
+ | `useAction().state` | Stays `idle`: navigate() runs no action. e2e. |
407
+ | `useHandle()` on a held navigation, `navigate(url, { handles, loaders })` | Modeled: `handles` arrive on the response's handle stream, so readers keep the page being left's values while React holds the page and take the new ones with its commit. |
408
+ | `useNavigation().isStreaming` past the commit, `navigate(url, { streamUntil })` | Modeled: the stream opens a task after the call (`pendingUrl` clears) and ends when the promise settles or a newer navigation aborts it; settle it inside `await act(async () => ...)`. |
409
+ | A navigation started while React holds a page (a newer click, a `transition: false` commit) | Call it inside `await act(async () => { ... })`: its first update makes React retry the held page, and a synchronous `act()` drops that retry, so a later urgent commit never renders. |
410
+ | A back/forward restored from the history cache (`pop`) | Modeled for the mounted entry and one a navigate() wrote: the page and its handle values come back, decided by `transition({ when })` as kind `"pop"`. A stale entry's revalidation and a restore that fetches: e2e. |
404
411
 
405
412
  - Per-navigation seeds merge over the render-time `loaders`/`loaderData` for THAT navigation only; a later `navigate()` without `loaders` falls back to the render-time data.
406
413
  - Pass settled data as a plain value, not `Promise.resolve(value)`: React has not observed a plain promise yet, so it counts as pending and renders one `stale` frame. (A Flight chunk that has already settled carries its status and is skipped, which a plain value mirrors.)
@@ -20,13 +20,13 @@ A `Prerender` route is served as in production: from the artifact `router.matchF
20
20
 
21
21
  ### Options — `ServeShellRequestOptions`
22
22
 
23
- | Field | Type | Meaning |
24
- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
- | `cacheStore` | `SegmentCacheStore` | The store for this request, in place of the store your `createRouter({ cache })` config returns. The rest of that config (`enabled`, `searchParams`) still applies. Omit it to use the router's own store. |
26
- | `env` | `TEnv` | Bindings, as `router.fetch(request, { env })` passes them. A function `cache` config receives them. |
27
- | `headers` | `HeadersInit` | Request headers (e.g. `cookie`). A document request defaults `accept` to `text/html`. |
28
- | `partial` | `true \| { from?: string; rewriteTarget?: string; segments?: string[]; prefetch?: boolean; fill?: boolean; interceptSource?: string; stale?: boolean }` | Serve the navigation request the browser sends instead of a document GET: from `from` (default the site root) with `segments` mounted (default none). `prefetch: true` sends it as a `<Link>` prefetch (`X-Rango-Prefetch`), so `prefetch: false` work is deferred. `fill: true` sends the follow-up request (`_rsc_fill=1`) the browser makes after adopting a prefetch; pass the prefetch's delivered ids, without the deferred ones, as `segments`, and the same URL as `from`. `interceptSource` sends the intercept source (`X-RSC-Router-Intercept-Source`) the browser sends with the fill of an intercept, whose `from` is that source too. `rewriteTarget` sends what the page at `from` carried as its payload's `metadata.rewriteTarget` when middleware rewrote it (`_rsc_rt`), as the browser does, so the server diffs against the route that rendered that page. `stale: true` sends a stale request (`_rsc_stale`), as the browser sends it: every `revalidate()` sees `stale: true` (see `revalidate()`'s `stale`), segments take an action's revalidation defaults, and it renders live, as an action does: neither the route cache (`cache()`) nor the document cache serves or stores it, and a `ppr` route's `replayStatus` is `{ outcome: "BYPASS", reason: "stale" }`. |
29
- | `action` | `{ ref: Function; id: string; args?: unknown[]; segments?: string[]; rewriteTarget?: string }` | Send the request a JS server action call makes from the page at `url`: a POST of `args` (encoded as the browser encodes them) with `?_rsc_action`, the `rsc-action` header, `X-RSC-Router-Client-Path` and `_rsc_segments` (the page's mounted segment ids, default none), plus the page's `rewriteTarget` as for `partial`. Registers `ref` under `id` (`<module id>#<export name>`). Not with `partial` or `form`. |
23
+ | Field | Type | Meaning |
24
+ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
25
+ | `cacheStore` | `SegmentCacheStore` | The store for this request, in place of the store your `createRouter({ cache })` config returns. The rest of that config (`enabled`, `searchParams`) still applies. Omit it to use the router's own store. |
26
+ | `env` | `TEnv` | Bindings, as `router.fetch(request, { env })` passes them. A function `cache` config receives them. |
27
+ | `headers` | `HeadersInit` | Request headers (e.g. `cookie`). A document request defaults `accept` to `text/html`. |
28
+ | `partial` | `true \| { from?: string; rewriteTarget?: string; segments?: string[]; prefetch?: boolean; fill?: boolean; interceptSource?: string; stale?: boolean; boundary?: string }` | Serve the navigation request the browser sends instead of a document GET: from `from` (default the site root) with `segments` mounted (default none). `prefetch: true` sends it as a `<Link>` prefetch (`X-Rango-Prefetch`), so `prefetch: false` work is deferred. `fill: true` sends the follow-up request (`_rsc_fill=1`) the browser makes after adopting a prefetch; pass the prefetch's delivered ids, without the deferred ones, as `segments`, and the same URL as `from`. `interceptSource` sends the intercept source (`X-RSC-Router-Intercept-Source`) the browser sends with the fill of an intercept, whose `from` is that source too. `rewriteTarget` sends what the page at `from` carried as its payload's `metadata.rewriteTarget` when middleware rewrote it (`_rsc_rt`), as the browser does, so the server diffs against the route that rendered that page. `stale: true` sends a stale request (`_rsc_stale`), as the browser sends it: every `revalidate()` sees `stale: true` (see `revalidate()`'s `stale`), segments take an action's revalidation defaults, and it renders live, as an action does: neither the route cache (`cache()`) nor the document cache serves or stores it, and a `ppr` route's `replayStatus` is `{ outcome: "BYPASS", reason: "stale" }`. `boundary` names the error segment of a thrown action's boundary the page at `from` shows (`_rsc_boundary`), as the background revalidation an earlier action owes that page sends it, with `stale: true` and no `segments`: the response renders every segment above the boundary and nothing at or below it, claims the boundary in `matched` and sends no segment for it. The server reads it only beside `stale`. |
29
+ | `action` | `{ ref: Function; id: string; args?: unknown[]; segments?: string[]; rewriteTarget?: string }` | Send the request a JS server action call makes from the page at `url`: a POST of `args` (encoded as the browser encodes them) with `?_rsc_action`, the `rsc-action` header, `X-RSC-Router-Client-Path` and `_rsc_segments` (the page's mounted segment ids, default none), plus the page's `rewriteTarget` as for `partial`. Registers `ref` under `id` (`<module id>#<export name>`). Not with `partial` or `form`. |
30
30
 
31
31
  ### Returns — `ServeShellRequestResult`
32
32
 
@@ -154,7 +154,7 @@ Every commit the router runs in a transition adds its `transition({ when })` kin
154
154
  - Tagging alone animates nothing: React starts a view transition only where a `<ViewTransition>` resolves a class other than `"none"` for the commit's types.
155
155
  - A commit outside a transition carries no type: a cold click that mounts new segments, a gated-off commit (`when` returned `false`), and a back/forward restored from the history cache on a route without `transition()` (it commits inside the `popstate` event). Under `transition()` that restore carries `"pop"` and `"navigation-back"`.
156
156
  - Under `transition()`, a stale entry's revalidation carries `"revalidate"` and `"action"`. An `action`-keyed map that must skip it also keys `revalidate: "none"` (`"none"` wins over any other match).
157
- - The types reach the commit that brings the page. React keeps one queue of transition types per root, and the first transition commit takes the whole queue. No router hook update takes them first: `useHandle()` and `useSegments()` commit with the page, and a `useNavigation()` value that moves while neither the state nor the URL does (`pendingUrl` as the response starts streaming, `isStreaming` as it ends) commits with the page while the reader has a held value, urgently otherwise, so it starts no view transition of its own. A transition your own code starts while React holds a navigation's page can still take them. Handle values on a held page land in the commit right after the page, by design; see [hooks](../hooks/handle-and-actions.md#when-handle-values-change-by-design). Use a kind map to choose an animation, never for correctness.
157
+ - The types reach the commit that brings the page. React keeps one queue of transition types per root, and the first transition commit takes the whole queue. No router hook update takes them first: `useSegments()` commits with the page and `useHandle()` right after it, and a `useNavigation()` value that moves while neither the state nor the URL does (`pendingUrl` as the response starts streaming, `isStreaming` as it ends) commits with the page while the reader has a held value, urgently otherwise, so it starts no view transition of its own. `useAction()` values commit urgently. `useLinkStatus()` commits `pending` urgently and its end with the page, and a newer navigation or an abort ends it urgently, outside a held commit; neither starts a transition. A transition your own code starts while React holds a navigation's page can still take them. Handle values on a held page land in the commit right after the page, by design; see [hooks](../hooks/handle-and-actions.md#when-handle-values-change-by-design). Use a kind map to choose an animation, never for correctness.
158
158
 
159
159
  ### Back and Forward
160
160
 
@@ -20,7 +20,8 @@
20
20
  *
21
21
  * The drop runs the batch's follow-up: when an action of the batch did not
22
22
  * land on the page on screen, the action bridge refetches that page once,
23
- * whole, after the navigation the user started, unless an intercept is on
23
+ * whole (or whole above an action's error boundary on screen, which it
24
+ * holds), after the navigation the user started, unless an intercept is on
24
25
  * screen (server-action-bridge.ts followUp; the rules are R1-R7 in
25
26
  * docs/internal/execution-model.md).
26
27
  *
@@ -92,6 +92,11 @@ export interface ActionEntry {
92
92
  startedAt: number;
93
93
  /** Whether action processing is complete (may still be streaming) */
94
94
  completed?: boolean;
95
+ /**
96
+ * Set when a later call started (see startAction): "id" hides this call
97
+ * from every reader, "name" from readers that match by export name.
98
+ */
99
+ supersededBy?: "id" | "name";
95
100
  }
96
101
 
97
102
  /**
@@ -104,7 +109,7 @@ export interface DerivedNavigationState {
104
109
  isStreaming: boolean;
105
110
  /** Whether a navigation is active (fetching or streaming, before commit) */
106
111
  isNavigating: boolean;
107
- /** Current committed location */
112
+ /** The location of the last page flushed to React (useNavigation keeps the page on screen) */
108
113
  location: NavigationLocation;
109
114
  /** URL being navigated to (null if idle) */
110
115
  pendingUrl: string | null;
@@ -712,15 +717,18 @@ export function createEventController(
712
717
  // idle snapshot (the same reference use-action falls back to).
713
718
  if (inflightActions.size === 0) return DEFAULT_ACTION_STATE;
714
719
 
715
- const entry = [...inflightActions.values()]
716
- .filter((a) => matchesActionId(actionId, a.actionId))
717
- .reduce<ActionEntry | undefined>((best, a) => {
718
- if (!best) return a;
719
- const aActive = a.phase !== "settling";
720
- const bActive = best.phase !== "settling";
721
- if (aActive !== bActive) return aActive ? a : best;
722
- return a.startedAt > best.startedAt ? a : best;
723
- }, undefined);
720
+ // The latest call this reader matches, or none once a later call left the
721
+ // map: an earlier call still running is never reported again.
722
+ const byName = !actionId.includes("#");
723
+ let entry: ActionEntry | undefined;
724
+ for (const a of inflightActions.values()) {
725
+ if (
726
+ matchesActionId(actionId, a.actionId) &&
727
+ (byName ? !a.supersededBy : a.supersededBy !== "id")
728
+ ) {
729
+ entry = a;
730
+ }
731
+ }
724
732
 
725
733
  if (!entry) {
726
734
  return { ...DEFAULT_ACTION_STATE };
@@ -913,6 +921,20 @@ export function createEventController(
913
921
  hadAnyConcurrentActions = true;
914
922
  }
915
923
 
924
+ // useAction reports the latest call. Every earlier call that a reader of
925
+ // this one also matches is hidden from that reader for good, so it never
926
+ // shows again after this call settles and leaves the map: a call of the
927
+ // same action from every reader, a call of another action with the same
928
+ // export name from readers that match by name (matchesActionId).
929
+ const hash = actionId.lastIndexOf("#");
930
+ const name = hash === -1 ? null : actionId.slice(hash + 1);
931
+ for (const a of inflightActions.values()) {
932
+ if (a.actionId === actionId) a.supersededBy = "id";
933
+ else if (name !== null && matchesActionId(name, a.actionId)) {
934
+ a.supersededBy ??= "name";
935
+ }
936
+ }
937
+
916
938
  const entry: ActionEntry = {
917
939
  id,
918
940
  actionId,
@@ -45,6 +45,7 @@ import {
45
45
  } from "./navigation-store.js";
46
46
  import type { EventController } from "./event-controller.js";
47
47
  import { isInterceptOnlyCache } from "./intercept-utils.js";
48
+ import { actionBoundaryAt, idsAboveBoundary } from "./segment-reconciler.js";
48
49
  import {
49
50
  toNetworkError,
50
51
  emitNetworkError,
@@ -206,7 +207,10 @@ export function createNavigationBridge(
206
207
  error: ServerRedirect,
207
208
  url: string,
208
209
  hops: number,
209
- options: { replace?: boolean; transition?: boolean },
210
+ options: Pick<
211
+ NavigateOptionsInternal,
212
+ "replace" | "transition" | "_redirectFrom"
213
+ >,
210
214
  ): Promise<void> {
211
215
  const redirectUrl = validateRedirectOrigin(
212
216
  error.url,
@@ -228,6 +232,7 @@ export function createNavigationBridge(
228
232
  ...options,
229
233
  _skipCache: true,
230
234
  _redirectHops: hops + 1,
235
+ _redirectFrom: options._redirectFrom ?? url,
231
236
  });
232
237
  }
233
238
 
@@ -414,8 +419,11 @@ export function createNavigationBridge(
414
419
  // 3. when leaving intercept - we need fresh non-intercept segments from server
415
420
  // 4. redirect-with-state - force re-render so hooks read fresh state
416
421
  // 5. stale cache - server action invalidated it, need fresh data with loading state
422
+ // 6. an error boundary in the list - a link renders the page again; its
423
+ // ids would tell the server the client holds what the error replaced
417
424
  const hasUsableCache =
418
425
  isRestorable(cachedSegments) &&
426
+ !cachedSegments.some((s) => s.type === "error") &&
419
427
  !isInterceptOnlyCache(cachedSegments) &&
420
428
  !isLeavingIntercept &&
421
429
  !cached?.stale &&
@@ -538,7 +546,11 @@ export function createNavigationBridge(
538
546
  error,
539
547
  url,
540
548
  options?._redirectHops ?? 0,
541
- { replace: options?.replace, transition: options?.transition },
549
+ {
550
+ replace: options?.replace,
551
+ transition: options?.transition,
552
+ _redirectFrom: options?._redirectFrom,
553
+ },
542
554
  );
543
555
  }
544
556
 
@@ -569,9 +581,19 @@ export function createNavigationBridge(
569
581
  },
570
582
 
571
583
  /**
572
- * Refresh current route
584
+ * Refresh current route. A cross-tab refresh holds the action error
585
+ * boundary the page shows (actionBoundaryAt), as the follow-up does: it
586
+ * is this tab's own outcome, not a read another tab's write changed. A
587
+ * refresh the app asks for renders it again.
573
588
  */
574
- async refresh(): Promise<void> {
589
+ async refresh(crossTab?: boolean): Promise<void> {
590
+ const shown =
591
+ (crossTab &&
592
+ !store.getInterceptSourceUrl() &&
593
+ store.getCachedSegments(store.getHistoryKey())?.segments) ||
594
+ [];
595
+ const at = actionBoundaryAt(shown);
596
+ if (at === 0) return;
575
597
  eventController.abortNavigation();
576
598
 
577
599
  const tx = createNavigationTransaction(
@@ -589,7 +611,7 @@ export function createNavigationBridge(
589
611
  false,
590
612
  tx.handle.signal,
591
613
  tx.with({ url: window.location.href, replace: true, scroll: false }),
592
- { type: "navigate", refresh: true },
614
+ { type: "navigate", refresh: true, boundary: shown[at]?.id },
593
615
  );
594
616
  } catch (error) {
595
617
  // Aborted or superseded: bail without rendering a boundary (see navigate()).
@@ -717,9 +739,20 @@ export function createNavigationBridge(
717
739
  }),
718
740
  });
719
741
 
742
+ // An action error boundary the entry shows (actionBoundaryAt) is
743
+ // held: the page holds the ids above it, so a link renders it again,
744
+ // and the revalidation below renders above it. Never under an
745
+ // intercept, as the follow-up (server-action-bridge.ts).
746
+ const boundaryAt = isIntercept ? -1 : actionBoundaryAt(cachedSegments);
747
+ const boundary = cachedSegments[boundaryAt]?.id;
748
+ const heldIds = idsAboveBoundary(
749
+ cachedSegments.map((s) => s.id),
750
+ boundary,
751
+ );
752
+
720
753
  // Update store to point to this history entry
721
754
  store.setHistoryKey(historyKey);
722
- store.setSegmentIds(cachedSegments.map((s) => s.id));
755
+ store.setSegmentIds(heldIds);
723
756
  store.setCurrentUrl(url);
724
757
  store.setPath(new URL(url).pathname);
725
758
 
@@ -798,8 +831,9 @@ export function createNavigationBridge(
798
831
  onUpdate({ ...popstateUpdate, inTransition: true });
799
832
  });
800
833
  }
801
- // SWR: If stale, trigger background revalidation
802
- if (isStale) {
834
+ // SWR: If stale, trigger background revalidation. Nothing is
835
+ // above a boundary at the page's first segment.
836
+ if (isStale && boundaryAt !== 0) {
803
837
  debugLog("[Browser] Cache is stale, background revalidating...");
804
838
  // Background revalidation - don't await, just fire and forget.
805
839
  // When the entry's handles are incomplete (a deferred Meta was still
@@ -811,9 +845,7 @@ export function createNavigationBridge(
811
845
  // would stay the pre-resolution carry. handlesPending is set only for
812
846
  // the deferred-Meta-aborted case, so action/cross-tab SWR keeps the
813
847
  // cheap diff path.
814
- const segmentIds = cached?.handlesPending
815
- ? []
816
- : cachedSegments.map((s) => s.id);
848
+ const segmentIds = cached?.handlesPending ? [] : heldIds;
817
849
 
818
850
  const tx = createNavigationTransaction(
819
851
  store,
@@ -835,7 +867,7 @@ export function createNavigationBridge(
835
867
  interceptSourceUrl,
836
868
  cacheOnly: true,
837
869
  }),
838
- { type: "stale-revalidation", interceptSourceUrl },
870
+ { type: "stale-revalidation", interceptSourceUrl, boundary },
839
871
  )
840
872
  .catch((error) => {
841
873
  if (isBackgroundSuppressible(error)) return;
@@ -990,7 +1022,7 @@ export function createNavigationBridge(
990
1022
 
991
1023
  // Register cross-tab refresh callback with the store
992
1024
  store.setCrossTabRefreshCallback(() => {
993
- this.refresh();
1025
+ this.refresh(true);
994
1026
  });
995
1027
 
996
1028
  window.addEventListener("popstate", handlePopstate);
@@ -82,6 +82,7 @@ export function createNavigationClient(
82
82
  rewriteTarget,
83
83
  signal,
84
84
  staleRevalidation,
85
+ boundary,
85
86
  interceptSourceUrl,
86
87
  version,
87
88
  routerId,
@@ -114,6 +115,7 @@ export function createNavigationClient(
114
115
  segmentIds,
115
116
  emptySegments: "send",
116
117
  stale: staleRevalidation,
118
+ boundary,
117
119
  version,
118
120
  routerId,
119
121
  rewriteTarget,
@@ -28,6 +28,9 @@ export function toNetworkError(
28
28
  * Render an error into the segment tree via the onUpdate subscriber so the
29
29
  * nearest error boundary catches it. Wrapped in startTransition; RenderErrorThrower
30
30
  * throws during render (async rejections do not reach boundaries on their own).
31
+ * Flagged inTransition like every tree handed over in a transition, so route
32
+ * state that arrives after it (the failed navigation's idle) waits for its
33
+ * commit (NavigationProvider's "anchor").
31
34
  */
32
35
  function emitErrorToBoundary(
33
36
  onUpdate: UpdateSubscriber,
@@ -43,6 +46,7 @@ function emitErrorToBoundary(
43
46
  segments: [],
44
47
  isError: true,
45
48
  },
49
+ inTransition: true,
46
50
  });
47
51
  });
48
52
  }
@@ -14,7 +14,7 @@ export const addTransitionType: ((type: string) => void) | undefined =
14
14
  "addTransitionType" in React ? (React as any).addTransitionType : undefined;
15
15
 
16
16
  import type { RenderSegmentsOptions } from "../segment-system.js";
17
- import { reconcileSegments } from "./segment-reconciler.js";
17
+ import { idsAboveBoundary, reconcileSegments } from "./segment-reconciler.js";
18
18
  import type { ReconcileActor } from "./segment-reconciler.js";
19
19
  import { Defer } from "./defer.js";
20
20
  import {
@@ -236,15 +236,28 @@ export type UpdateMode =
236
236
  * transition({ when }) sees kind "revalidate", not "replace".
237
237
  */
238
238
  refresh?: boolean;
239
+ /** The cross-tab refresh's held boundary (see `action`). */
240
+ boundary?: string;
241
+ }
242
+ | { type: "leave-intercept"; interceptSourceUrl?: string; boundary?: never }
243
+ | {
244
+ type: "stale-revalidation";
245
+ interceptSourceUrl?: string;
246
+ /** Back/Forward's held boundary (see `action`). */
247
+ boundary?: string;
239
248
  }
240
- | { type: "leave-intercept"; interceptSourceUrl?: string }
241
- | { type: "stale-revalidation"; interceptSourceUrl?: string }
242
249
  | {
243
250
  type: "action";
244
251
  interceptSourceUrl?: string;
245
252
  actionId?: string;
246
253
  /** The action's FormData and result, for transition({ when }) `action`. */
247
254
  action?: TransitionWhenActionInput;
255
+ /**
256
+ * An action's error boundary the page shows (actionBoundaryAt): the
257
+ * server renders above it, its cached segment stays, and the page
258
+ * holds only the ids above it.
259
+ */
260
+ boundary?: string;
248
261
  };
249
262
 
250
263
  /**
@@ -569,6 +582,7 @@ export function createPartialUpdater(
569
582
  const historyKeyAtStart = store.getHistoryKey();
570
583
 
571
584
  const interceptSourceUrl = mode.interceptSourceUrl;
585
+ const boundary = mode.boundary;
572
586
 
573
587
  let segments: string[];
574
588
  if (mode.type === "leave-intercept") {
@@ -690,6 +704,7 @@ export function createPartialUpdater(
690
704
  : store.getHistoryEntryMemory()?.rewriteTarget,
691
705
  staleRevalidation:
692
706
  mode.type === "stale-revalidation" || segments.length === 0,
707
+ boundary,
693
708
  version: getVersion(),
694
709
  routerId: store.getRouterId?.(),
695
710
  });
@@ -874,14 +889,14 @@ export function createPartialUpdater(
874
889
  // in place of each missing value, and this navigation fills them from
875
890
  // one fill request once it has committed the tree.
876
891
  let defers: Holes["defers"] | undefined;
877
- let heldIds = matchedIds;
892
+ let heldIds = idsAboveBoundary(matchedIds, boundary);
878
893
  const placeholders = serverSegments.filter(isPlaceholder);
879
894
  if (placeholders.length > 0) {
880
895
  defers = armHoles(placeholders);
881
896
  // A placeholder is not held: every later request from this page
882
897
  // leaves its id out, so the server renders it.
883
898
  const deferredIds = new Set(placeholders.map((s) => s.id));
884
- heldIds = matchedIds.filter((id) => !deferredIds.has(id));
899
+ heldIds = heldIds.filter((id) => !deferredIds.has(id));
885
900
  }
886
901
 
887
902
  const reconcile = () =>
@@ -895,7 +910,10 @@ export function createPartialUpdater(
895
910
  });
896
911
  let reconciled = reconcile();
897
912
 
898
- const missing = missingIds(matchedIds, reconciled.segments);
913
+ // Not the ids at or below a held boundary: its loaders may never have
914
+ // reached the client (the route failed on its GET), and nothing renders
915
+ // them under the error.
916
+ const missing = missingIds(heldIds, reconciled.segments);
899
917
 
900
918
  if (missing.length > 0) {
901
919
  const missingCount = missing.length;
@@ -1119,6 +1137,15 @@ export function createPartialUpdater(
1119
1137
  }),
1120
1138
  ...(serverLocationState && { serverState: serverLocationState }),
1121
1139
  };
1140
+ // A refetch that held an action's boundary commits only while the page
1141
+ // on screen still shows it: a later commit there (an action's success)
1142
+ // replaced it, and this response would put the error back.
1143
+ if (
1144
+ boundary &&
1145
+ !entryOnScreen()?.includes(cachedSegs.find((s) => s.id === boundary)!)
1146
+ ) {
1147
+ return;
1148
+ }
1122
1149
  const cachedHandleData = readCachedHandleData?.();
1123
1150
  const { scroll: navScroll } = tx.commit(
1124
1151
  heldIds,
@@ -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 };