@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.
- package/dist/bin/rango.js +1 -1
- package/dist/types/browser/action-fence.d.ts +2 -1
- package/dist/types/browser/event-controller.d.ts +6 -1
- package/dist/types/browser/partial-update.d.ts +11 -0
- package/dist/types/browser/react/use-action.d.ts +7 -2
- package/dist/types/browser/react/use-handle.d.ts +19 -16
- package/dist/types/browser/react/use-link-status.d.ts +5 -2
- package/dist/types/browser/segment-reconciler.d.ts +26 -0
- package/dist/types/browser/types.d.ts +22 -4
- package/dist/types/browser/wire-protocol.d.ts +1 -0
- package/dist/types/cache/tag-invalidation.d.ts +0 -10
- package/dist/types/router/error-handling.d.ts +7 -1
- package/dist/types/router/match-context.d.ts +2 -0
- package/dist/types/router/match-middleware/background-revalidation.d.ts +3 -2
- package/dist/types/router/navigation-snapshot.d.ts +7 -0
- package/dist/types/router/segment-resolution/helpers.d.ts +19 -3
- package/dist/types/router/segment-resolution/resolution-pass.d.ts +2 -0
- package/dist/types/router/segment-resolution/streamed-handler-boundary.d.ts +2 -0
- package/dist/types/router/types.d.ts +1 -1
- package/dist/types/server/request-context.d.ts +30 -1
- package/dist/types/testing/render-route.d.ts +43 -8
- package/dist/types/testing/serve-shell-request.d.ts +8 -1
- package/dist/types/types/error-types.d.ts +27 -8
- package/dist/types/wire-params.d.ts +5 -0
- package/dist/vite/index.js +15 -19
- package/package.json +1 -1
- package/skills/client-urls/SKILL.md +4 -2
- package/skills/hooks/SKILL.md +1 -1
- package/skills/hooks/data.md +8 -1
- package/skills/hooks/handle-and-actions.md +33 -2
- package/skills/hooks/navigation.md +121 -15
- package/skills/hooks/state.md +6 -4
- package/skills/hooks/urls.md +16 -5
- package/skills/intercept/SKILL.md +6 -0
- package/skills/observability/SKILL.md +30 -0
- package/skills/server-actions/SKILL.md +8 -1
- package/skills/testing/client-components.md +25 -18
- package/skills/testing/serve-shell-request.md +7 -7
- package/skills/view-transitions/SKILL.md +1 -1
- package/src/browser/action-fence.ts +2 -1
- package/src/browser/event-controller.ts +32 -10
- package/src/browser/navigation-bridge.ts +45 -13
- package/src/browser/navigation-client.ts +2 -0
- package/src/browser/network-error-handler.ts +4 -0
- package/src/browser/partial-update.ts +33 -6
- package/src/browser/react/use-action.ts +24 -48
- package/src/browser/react/use-handle.ts +22 -18
- package/src/browser/react/use-link-status.ts +48 -60
- package/src/browser/react/use-navigation.ts +92 -41
- package/src/browser/segment-reconciler.ts +43 -0
- package/src/browser/server-action-bridge.ts +35 -8
- package/src/browser/types.ts +23 -4
- package/src/browser/wire-protocol.ts +2 -0
- package/src/cache/cache-runtime.ts +28 -31
- package/src/cache/document-cache.ts +34 -37
- package/src/cache/read-through-swr.ts +20 -18
- package/src/cache/tag-invalidation.ts +3 -18
- package/src/router/error-handling.ts +18 -6
- package/src/router/intercept-resolution.ts +9 -4
- package/src/router/match-api.ts +1 -0
- package/src/router/match-context.ts +3 -0
- package/src/router/match-middleware/background-revalidation.ts +5 -4
- package/src/router/match-middleware/cache-lookup.ts +5 -3
- package/src/router/navigation-snapshot.ts +10 -0
- package/src/router/segment-resolution/helpers.ts +49 -13
- package/src/router/segment-resolution/loader-cache.ts +4 -3
- package/src/router/segment-resolution/resolution-pass.ts +2 -0
- package/src/router/segment-resolution/revalidation.ts +57 -28
- package/src/router/segment-resolution/streamed-handler-boundary.ts +3 -0
- package/src/router/types.ts +1 -1
- package/src/router.ts +7 -4
- package/src/rsc/request-stages.ts +4 -2
- package/src/rsc/shell-capture.ts +63 -58
- package/src/server/request-context.ts +49 -0
- package/src/testing/dispatch.ts +3 -0
- package/src/testing/render-route.tsx +199 -13
- package/src/testing/serve-shell-request.ts +19 -10
- package/src/types/error-types.ts +27 -8
- 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
|
|
207
|
-
|
|
|
208
|
-
| a push / replace, the component's own
|
|
209
|
-
| a push / replace whose loader the test holds
|
|
210
|
-
| back/forward onto
|
|
211
|
-
|
|
|
212
|
-
| a
|
|
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
|
|
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
|
-
|
|
|
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
|
|
24
|
-
| ------------ |
|
|
25
|
-
| `cacheStore` | `SegmentCacheStore`
|
|
26
|
-
| `env` | `TEnv`
|
|
27
|
-
| `headers` | `HeadersInit`
|
|
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 }`
|
|
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: `
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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:
|
|
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
|
-
{
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
142
|
-
|
|
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(
|
|
153
|
-
prevSelected.current =
|
|
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
|
-
|
|
172
|
-
|
|
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
|
|
179
|
+
return value;
|
|
204
180
|
}
|
|
205
181
|
|
|
206
182
|
export type { TrackedActionState };
|