@rangojs/router 0.29.0 → 0.30.1
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/react/use-action.d.ts +3 -2
- package/dist/types/browser/react/use-handle.d.ts +2 -1
- package/dist/types/browser/react/use-route-state.d.ts +45 -0
- package/dist/types/browser/types.d.ts +8 -4
- package/dist/types/suspense-audit.d.ts +19 -1
- package/dist/types/testing/render-route.d.ts +7 -7
- package/dist/types/vite/discovery/build-versions.d.ts +17 -13
- package/dist/types/vite/plugins/stable-rsc-output.d.ts +1 -1
- package/dist/vite/index.js +87 -68
- package/package.json +1 -1
- package/skills/client-urls/SKILL.md +2 -6
- package/skills/hooks/handle-and-actions.md +9 -2
- package/skills/hooks/navigation.md +3 -3
- package/skills/hooks/urls.md +1 -0
- package/skills/testing/client-components.md +1 -1
- package/skills/testing/response-routes.md +3 -2
- package/src/browser/event-controller.ts +54 -17
- package/src/browser/react/use-action.ts +3 -2
- package/src/browser/react/use-handle.ts +6 -1
- package/src/browser/react/use-navigation.ts +16 -42
- package/src/browser/react/use-params.ts +6 -35
- package/src/browser/react/use-pathname.ts +3 -32
- package/src/browser/react/use-route-state.ts +141 -0
- package/src/browser/react/use-search-params.ts +7 -40
- package/src/browser/types.ts +8 -4
- package/src/suspense-audit.ts +58 -11
- package/src/testing/render-route.tsx +20 -14
- package/src/vite/discovery/build-versions.ts +67 -26
- package/src/vite/plugins/stable-rsc-output.ts +1 -1
|
@@ -406,7 +406,7 @@ it("dims the held price while the next product streams", async () => {
|
|
|
406
406
|
| `useAction().state` | Stays `idle`: navigate() runs no action. e2e. |
|
|
407
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
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) |
|
|
409
|
+
| A navigation started while React holds a page (a newer click, a `transition: false` commit) | `await router.navigate(...)` shows its page, with or without `act()`. A `<Link>` click goes inside `await act(async () => { ... })`: `fireEvent`'s synchronous `act()` drops React's retry of the held page. |
|
|
410
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. |
|
|
411
411
|
|
|
412
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.
|
|
@@ -82,14 +82,15 @@ describe("api routes via dispatch", () => {
|
|
|
82
82
|
|
|
83
83
|
- trailing-slash redirects from the trie — a route with `trailingSlash: "never"` or `"always"` answers the other form with a 308 and the `Location` (query preserved). A route with no `trailingSlash` serves its URL with and without a trailing slash, with no redirect, as production does;
|
|
84
84
|
- the open-redirect guard on browser-followed redirects (a cross-origin `Location` is rewritten to the basename root unless `redirect(url, { external: true })`);
|
|
85
|
-
- content-negotiated routes (`Vary: Accept` appended);
|
|
85
|
+
- content-negotiated routes whose winning route is a response route (`Vary: Accept` appended);
|
|
86
86
|
- the reloads production answers inside the global middleware chain: a request whose `_rsc_v` names another deploy's document version (`setBuildVersions`, `./serve-shell-request.md`), and a `_rsc_partial` request whose `_rsc_rid` names another router (a cross-app navigation), get an `X-RSC-Reload` at the visitor's URL — on any route, a component route or an unmatched path included;
|
|
87
87
|
- cached response routes (`cache({ ... })` around a `path.json` etc.): hit, SWR, and tag writes. The cache write is scheduled via `waitUntil` (a microtask in tests), so flush microtasks between the seeding `dispatch` and the one that asserts the hit;
|
|
88
88
|
- `createRouter({ telemetry })` `request.start` / `request.end` / `request.error` events, all with one `requestId`, which a response route's `request.timeout` carries too (a rewritten pass's included), and `createRouter({ onError })` for cache-read/write failures.
|
|
89
89
|
|
|
90
90
|
## Caveats
|
|
91
91
|
|
|
92
|
-
- Hitting a COMPONENT (RSC) route throws a clear directive error: `dispatch` is for response routes + redirects + 404 +
|
|
92
|
+
- Hitting a COMPONENT (RSC) route throws a clear directive error: `dispatch` is for response routes + redirects + 404 + negotiation won by a response route, plus the global + route-level middleware guard stack on RESPONSE routes — it never renders React. Use Flight primitives or e2e to exercise component rendering.
|
|
93
|
+
- On a content-negotiated URL, any `Accept` the page route wins throws the same error. Serve that request with `serveShellRequest(router, url, { headers: { accept } })` (`./serve-shell-request.md`): it runs the production handler and answers the HTML document or the Flight stream, with `Vary: Accept`.
|
|
93
94
|
- A COMPONENT route's guard stack cannot run here. Assert it at e2e, or extract the middleware fn and unit-test it with `runMiddleware` (see `./middleware.md`).
|
|
94
95
|
- JSON serialization is bare, applied in `response-route-handler.ts`: a `path.json` handler that returns a value is serialized verbatim (`JSON.stringify(value)`, status 200, `application/json`) — no envelope. Returning or throwing a `Response` (e.g. `Response.json(x)`) uses the same control-flow path. Any other thrown error takes the error's status (`RouterError.status`, else 500, or a non-200 status already set upstream in the request pipeline). On a `path.json` route the body is an RFC 9457 problem+json object `{ title, status, detail, code }` (`application/problem+json;charset=utf-8`), where `code` is the `RouterError.code`, else `"INTERNAL"`, and the `type` member is omitted. On text/html/xml/md routes the body is `text/plain`: the `RouterError` message, the error message in dev, or `"Internal Server Error"` in production. Assert the shape matching what your handler returns.
|
|
95
96
|
- Setup: needs the preset (alias + virtual stubs) or a Vite-RSC env (see `./setup.md`); a bare router import throws on Vite virtuals.
|
|
@@ -542,6 +542,28 @@ export function createEventController(
|
|
|
542
542
|
let unsettledNavigation: NavigationEntry | null = null;
|
|
543
543
|
|
|
544
544
|
const inflightActions = new Map<string, ActionEntry>();
|
|
545
|
+
// Each action id's last settled call, which useAction reports until the
|
|
546
|
+
// action's next call starts (startAction deletes it). A call moves here
|
|
547
|
+
// when doSettle's cleanup takes it out of inflightActions. A trimmed
|
|
548
|
+
// snapshot, so no abort controller outlives the call. abortAllActions leaves
|
|
549
|
+
// settled calls: they persist through navigations until the action's next
|
|
550
|
+
// call.
|
|
551
|
+
type TrackedCall = Pick<
|
|
552
|
+
ActionEntry,
|
|
553
|
+
| "actionId"
|
|
554
|
+
| "phase"
|
|
555
|
+
| "payload"
|
|
556
|
+
| "result"
|
|
557
|
+
| "error"
|
|
558
|
+
| "revalidationError"
|
|
559
|
+
| "supersededBy"
|
|
560
|
+
>;
|
|
561
|
+
const settledActions = new Map<string, TrackedCall>();
|
|
562
|
+
|
|
563
|
+
function* trackedCalls(): Generator<TrackedCall> {
|
|
564
|
+
yield* inflightActions.values();
|
|
565
|
+
yield* settledActions.values();
|
|
566
|
+
}
|
|
545
567
|
|
|
546
568
|
let location: NavigationLocation =
|
|
547
569
|
config?.initialLocation ??
|
|
@@ -713,15 +735,17 @@ export function createEventController(
|
|
|
713
735
|
}
|
|
714
736
|
|
|
715
737
|
function getActionState(actionId: string): TrackedActionState {
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
if (inflightActions.size === 0)
|
|
738
|
+
// No call tracked: the shared idle snapshot (the same reference use-action
|
|
739
|
+
// falls back to).
|
|
740
|
+
if (inflightActions.size === 0 && settledActions.size === 0) {
|
|
741
|
+
return DEFAULT_ACTION_STATE;
|
|
742
|
+
}
|
|
719
743
|
|
|
720
|
-
// The latest call this reader matches,
|
|
721
|
-
//
|
|
744
|
+
// The latest call this reader matches, in flight or settled, or none once
|
|
745
|
+
// a later call was aborted: an earlier call is never reported again.
|
|
722
746
|
const byName = !actionId.includes("#");
|
|
723
|
-
let entry:
|
|
724
|
-
for (const a of
|
|
747
|
+
let entry: TrackedCall | undefined;
|
|
748
|
+
for (const a of trackedCalls()) {
|
|
725
749
|
if (
|
|
726
750
|
matchesActionId(actionId, a.actionId) &&
|
|
727
751
|
(byName ? !a.supersededBy : a.supersededBy !== "id")
|
|
@@ -907,9 +931,9 @@ export function createEventController(
|
|
|
907
931
|
// after that point is strictly ordered behind the prior action's server
|
|
908
932
|
// execution, and there is no skipped render or order uncertainty for
|
|
909
933
|
// consolidation to repair. Completed entries still linger in the map for
|
|
910
|
-
// the 100ms doSettle window
|
|
911
|
-
//
|
|
912
|
-
//
|
|
934
|
+
// the 100ms doSettle window and, on a slow connection, while the Flight
|
|
935
|
+
// stream drains its EOF after complete(). Counting them latched
|
|
936
|
+
// hadAnyConcurrentActions for back-to-back sequential actions,
|
|
913
937
|
// which made the LAST action classify as consolidation-needed; the
|
|
914
938
|
// consolidation refetch omits every concurrently-revalidated segment id
|
|
915
939
|
// from _rsc_segments, so the server re-ran gated loaders as "new-segment"
|
|
@@ -922,13 +946,15 @@ export function createEventController(
|
|
|
922
946
|
}
|
|
923
947
|
|
|
924
948
|
// useAction reports the latest call. Every earlier call that a reader of
|
|
925
|
-
// this one also matches is hidden from that reader
|
|
926
|
-
// shows again
|
|
927
|
-
// same action from every reader, a call of another action
|
|
928
|
-
// export name from readers that match by name
|
|
949
|
+
// this one also matches, in flight or settled, is hidden from that reader
|
|
950
|
+
// for good, so it never shows again, also once this call is aborted: a
|
|
951
|
+
// call of the same action from every reader, a call of another action
|
|
952
|
+
// with the same export name from readers that match by name
|
|
953
|
+
// (matchesActionId). So at most one call matches a reader (getActionState).
|
|
929
954
|
const hash = actionId.lastIndexOf("#");
|
|
930
955
|
const name = hash === -1 ? null : actionId.slice(hash + 1);
|
|
931
|
-
|
|
956
|
+
settledActions.delete(actionId);
|
|
957
|
+
for (const a of trackedCalls()) {
|
|
932
958
|
if (a.actionId === actionId) a.supersededBy = "id";
|
|
933
959
|
else if (name !== null && matchesActionId(name, a.actionId)) {
|
|
934
960
|
a.supersededBy ??= "name";
|
|
@@ -978,9 +1004,20 @@ export function createEventController(
|
|
|
978
1004
|
if (settled) return;
|
|
979
1005
|
settled = true;
|
|
980
1006
|
|
|
981
|
-
// Cleanup after brief delay (allow useAction to read result)
|
|
982
1007
|
setTimeout(() => {
|
|
983
|
-
|
|
1008
|
+
// The action's last settled call until its next call starts, unless
|
|
1009
|
+
// that call already started.
|
|
1010
|
+
if (inflightActions.delete(id) && entry.supersededBy !== "id") {
|
|
1011
|
+
settledActions.set(actionId, {
|
|
1012
|
+
actionId: entry.actionId,
|
|
1013
|
+
phase: entry.phase,
|
|
1014
|
+
payload: entry.payload,
|
|
1015
|
+
result: entry.result,
|
|
1016
|
+
error: entry.error,
|
|
1017
|
+
revalidationError: entry.revalidationError,
|
|
1018
|
+
supersededBy: entry.supersededBy,
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
984
1021
|
// Free this cohort's arbitration once its last action has settled, so
|
|
985
1022
|
// a long-running action elsewhere cannot pin keys from a drained entry.
|
|
986
1023
|
releaseCohort();
|
|
@@ -71,11 +71,12 @@ export type ServerActionFunction = ((...args: any[]) => Promise<any>) & {
|
|
|
71
71
|
* tracks the state of individual server action invocations.
|
|
72
72
|
*
|
|
73
73
|
* Uses the event controller for reactive state management.
|
|
74
|
-
* State is derived from the
|
|
74
|
+
* State is derived from the calls the controller tracks: those in flight,
|
|
75
|
+
* and each action's last settled call.
|
|
75
76
|
*
|
|
76
77
|
* Features:
|
|
77
78
|
* - Tracks action lifecycle: idle → loading → streaming → idle
|
|
78
|
-
* -
|
|
79
|
+
* - Keeps the settled call's state until the action is called again.
|
|
79
80
|
* - If the same action fires several times, reports the latest call to
|
|
80
81
|
* start. An earlier call that settles later changes nothing it reports;
|
|
81
82
|
* a latest call that was aborted reports idle with nothing. One call at a
|
|
@@ -6,6 +6,7 @@ import { collectHandleData } from "../../handle.js";
|
|
|
6
6
|
import type { HandleData } from "../types.js";
|
|
7
7
|
import { NavigationStoreContext } from "./context.js";
|
|
8
8
|
import { shallowEqual } from "./shallow-equal.js";
|
|
9
|
+
import { useCommitted } from "./use-route-state.js";
|
|
9
10
|
import type { RouteStateSignal } from "../event-controller.js";
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -86,7 +87,8 @@ export function useHandle<T, A, S>(
|
|
|
86
87
|
* location commit with their page.
|
|
87
88
|
*
|
|
88
89
|
* A value shallow-equal to the last one sets nothing, so a reader renders only
|
|
89
|
-
* when its value moves.
|
|
90
|
+
* when its value moves. A release is compared with what React committed, as in
|
|
91
|
+
* useRouteState.
|
|
90
92
|
*/
|
|
91
93
|
export function useCommitAnchoredState<T>(
|
|
92
94
|
init: () => T,
|
|
@@ -99,12 +101,15 @@ export function useCommitAnchoredState<T>(
|
|
|
99
101
|
const ref = useRef<{ last: T; held?: boolean; waiting?: boolean }>({
|
|
100
102
|
last: value,
|
|
101
103
|
});
|
|
104
|
+
const committed = useCommitted(value);
|
|
102
105
|
const [set] = useState(() => (next: T, signal?: RouteStateSignal): void => {
|
|
103
106
|
const r = ref.current;
|
|
104
107
|
if (signal) r.waiting = signal === "anchor";
|
|
105
108
|
if (!shallowEqual(next, r.last)) {
|
|
106
109
|
r.last = next;
|
|
107
110
|
r.held = true;
|
|
111
|
+
} else if (signal === "release" && r.last !== committed.current) {
|
|
112
|
+
r.held = true;
|
|
108
113
|
}
|
|
109
114
|
if (!r.held || (r.waiting && (waitForRelease || signal !== "anchor")))
|
|
110
115
|
return;
|
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
useContext,
|
|
5
|
-
useState,
|
|
6
|
-
useEffect,
|
|
7
|
-
useLayoutEffect,
|
|
8
|
-
useRef,
|
|
9
|
-
} from "react";
|
|
3
|
+
import { useContext, useEffect, useRef } from "react";
|
|
10
4
|
import { NavigationStoreContext } from "./context.js";
|
|
11
5
|
import { shallowEqual } from "./shallow-equal.js";
|
|
6
|
+
import { useRouteState } from "./use-route-state.js";
|
|
12
7
|
import type { PublicNavigationState } from "../types.js";
|
|
13
8
|
import type {
|
|
14
9
|
DerivedNavigationState,
|
|
@@ -74,7 +69,11 @@ export function useNavigation<T>(
|
|
|
74
69
|
throw new Error("useNavigation must be used within NavigationProvider");
|
|
75
70
|
}
|
|
76
71
|
|
|
77
|
-
|
|
72
|
+
// A release is compared with the state React renders it on
|
|
73
|
+
// (useRouteState). The anchor is compared with the last set, as an
|
|
74
|
+
// ordinary notification is: it sets inside its page's transition, which
|
|
75
|
+
// renders every set so far.
|
|
76
|
+
const [snapshot, set] = useRouteState<Snapshot<T | PublicNavigationState>>(
|
|
78
77
|
() => {
|
|
79
78
|
const state = toPublicState(
|
|
80
79
|
ctx.eventController.getHydrationSnapshot()?.state ??
|
|
@@ -82,13 +81,8 @@ export function useNavigation<T>(
|
|
|
82
81
|
);
|
|
83
82
|
return { state, value: selector ? selector(state) : state };
|
|
84
83
|
},
|
|
84
|
+
(a, b) => shallowEqual(a.value, b.value),
|
|
85
85
|
);
|
|
86
|
-
// What React ends on once every set so far commits, and what it committed.
|
|
87
|
-
const latest = useRef(snapshot);
|
|
88
|
-
const committed = useRef(snapshot);
|
|
89
|
-
useLayoutEffect(() => {
|
|
90
|
-
committed.current = snapshot;
|
|
91
|
-
});
|
|
92
86
|
|
|
93
87
|
// Store selector in a ref so the subscription callback always uses the
|
|
94
88
|
// latest selector without re-subscribing on every render (inline functions
|
|
@@ -118,34 +112,14 @@ export function useNavigation<T>(
|
|
|
118
112
|
const moved = live.location !== location;
|
|
119
113
|
location = live.location;
|
|
120
114
|
const held = pageHeld && !signal;
|
|
121
|
-
|
|
122
|
-
base
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
// `latest`: an urgent page can end the hold before React commits the
|
|
130
|
-
// anchor's set. An anchor sets inside its page's transition, which
|
|
131
|
-
// renders every set so far, so `latest` is that state. A release that
|
|
132
|
-
// finds every set committed with its value sets nothing: the updater
|
|
133
|
-
// would render the reader again only to bail out.
|
|
134
|
-
if (signal === "release") {
|
|
135
|
-
if (
|
|
136
|
-
latest.current === committed.current &&
|
|
137
|
-
shallowEqual(target.value, latest.current.value)
|
|
138
|
-
)
|
|
139
|
-
return;
|
|
140
|
-
latest.current = target;
|
|
141
|
-
setSnapshot((prev) =>
|
|
142
|
-
shallowEqual(prev.value, target.value) ? prev : target,
|
|
143
|
-
);
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
if (shallowEqual(target.value, latest.current.value)) return;
|
|
147
|
-
latest.current = target;
|
|
148
|
-
setSnapshot(held ? next : target);
|
|
115
|
+
set(
|
|
116
|
+
(base) => {
|
|
117
|
+
const state = held ? hold(base.state, live, moved) : live;
|
|
118
|
+
return { state, value: select(state) };
|
|
119
|
+
},
|
|
120
|
+
signal,
|
|
121
|
+
held,
|
|
122
|
+
);
|
|
149
123
|
};
|
|
150
124
|
|
|
151
125
|
// Catch-up: re-read state synchronously on mount before subscribing, so a
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useContext
|
|
4
|
-
import { NavigationStoreContext } from "./context.js";
|
|
3
|
+
import { useContext } from "react";
|
|
5
4
|
import { shallowEqual } from "./shallow-equal.js";
|
|
5
|
+
import { useUrlValue } from "./use-route-state.js";
|
|
6
6
|
import { OptimisticLocationContext } from "../../client-urls/optimistic-location.js";
|
|
7
7
|
|
|
8
|
-
const EMPTY_PARAMS: Record<string, string> = Object.freeze({});
|
|
9
|
-
|
|
10
8
|
/**
|
|
11
9
|
* Hook to access the current route params.
|
|
12
10
|
*
|
|
@@ -45,38 +43,11 @@ export function useParams<T>(
|
|
|
45
43
|
export function useParams<T>(
|
|
46
44
|
selector?: (params: Record<string, string | undefined>) => T,
|
|
47
45
|
): T | Record<string, string | undefined> {
|
|
48
|
-
const ctx = useContext(NavigationStoreContext);
|
|
49
46
|
const optimistic = useContext(OptimisticLocationContext);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
ctx.eventController.getParams())
|
|
55
|
-
: EMPTY_PARAMS;
|
|
56
|
-
return selector ? selector(params) : params;
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
const prevValue = useRef(value);
|
|
60
|
-
const selectorRef = useRef(selector);
|
|
61
|
-
selectorRef.current = selector;
|
|
62
|
-
|
|
63
|
-
useEffect(() => {
|
|
64
|
-
if (!ctx) return;
|
|
65
|
-
|
|
66
|
-
const update = () => {
|
|
67
|
-
const params = ctx.eventController.getParams();
|
|
68
|
-
const next = selectorRef.current ? selectorRef.current(params) : params;
|
|
69
|
-
|
|
70
|
-
if (!shallowEqual(next, prevValue.current)) {
|
|
71
|
-
prevValue.current = next;
|
|
72
|
-
setValue(next);
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
update();
|
|
77
|
-
|
|
78
|
-
return ctx.eventController.subscribe(update);
|
|
79
|
-
}, []);
|
|
47
|
+
const value = useUrlValue<T | Record<string, string>>(
|
|
48
|
+
({ params }) => (selector ? selector(params) : params),
|
|
49
|
+
shallowEqual,
|
|
50
|
+
);
|
|
80
51
|
|
|
81
52
|
if (optimistic) {
|
|
82
53
|
return selector ? selector(optimistic.params) : optimistic.params;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useContext
|
|
4
|
-
import {
|
|
3
|
+
import { useContext } from "react";
|
|
4
|
+
import { useUrlValue } from "./use-route-state.js";
|
|
5
5
|
import { OptimisticLocationContext } from "../../client-urls/optimistic-location.js";
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -19,36 +19,7 @@ import { OptimisticLocationContext } from "../../client-urls/optimistic-location
|
|
|
19
19
|
* ```
|
|
20
20
|
*/
|
|
21
21
|
export function usePathname(): string {
|
|
22
|
-
const ctx = useContext(NavigationStoreContext);
|
|
23
22
|
const optimistic = useContext(OptimisticLocationContext);
|
|
24
|
-
|
|
25
|
-
const [pathname, setPathname] = useState<string>(() => {
|
|
26
|
-
if (!ctx) {
|
|
27
|
-
return "/";
|
|
28
|
-
}
|
|
29
|
-
const state =
|
|
30
|
-
ctx.eventController.getHydrationSnapshot()?.state ??
|
|
31
|
-
ctx.eventController.getState();
|
|
32
|
-
return (state.location as URL).pathname;
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
const prevPathname = useRef(pathname);
|
|
36
|
-
|
|
37
|
-
useEffect(() => {
|
|
38
|
-
if (!ctx) return;
|
|
39
|
-
|
|
40
|
-
const update = () => {
|
|
41
|
-
const next = (ctx.eventController.getState().location as URL).pathname;
|
|
42
|
-
if (next !== prevPathname.current) {
|
|
43
|
-
prevPathname.current = next;
|
|
44
|
-
setPathname(next);
|
|
45
|
-
}
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
update();
|
|
49
|
-
|
|
50
|
-
return ctx.eventController.subscribe(update);
|
|
51
|
-
}, []);
|
|
52
|
-
|
|
23
|
+
const pathname = useUrlValue((route) => route.location.pathname, Object.is);
|
|
53
24
|
return optimistic ? optimistic.pathname : pathname;
|
|
54
25
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { NavigationStoreContext } from "./context.js";
|
|
11
|
+
import type { EventController, RouteStateSignal } from "../event-controller.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The value a reader rendered in React's last commit of it, written by its
|
|
15
|
+
* layout effect. A page's release runs in the provider's layout effect, after
|
|
16
|
+
* every reader's (children first), so it reads the commit that brings the
|
|
17
|
+
* page.
|
|
18
|
+
*/
|
|
19
|
+
export function useCommitted<T>(value: T): { readonly current: T } {
|
|
20
|
+
const committed = useRef(value);
|
|
21
|
+
useLayoutEffect(() => {
|
|
22
|
+
committed.current = value;
|
|
23
|
+
}, [value]);
|
|
24
|
+
return committed;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Sets a route-state reader's value. `next` maps the value React ends on once
|
|
29
|
+
* every set so far commits (`latest`) to the reader's value; `rebase` sets
|
|
30
|
+
* `next` itself, so React recomputes it on whatever base it renders. The value
|
|
31
|
+
* must not be a function.
|
|
32
|
+
*/
|
|
33
|
+
export type RouteStateSetter<T> = (
|
|
34
|
+
next: (base: T) => T,
|
|
35
|
+
signal?: RouteStateSignal,
|
|
36
|
+
rebase?: boolean,
|
|
37
|
+
) => void;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Route state a reader sets as the router store notifies it, in the lane of
|
|
41
|
+
* the notification that carries it. A notification equal to `latest` sets
|
|
42
|
+
* nothing, so a reader renders only when its value moves.
|
|
43
|
+
*
|
|
44
|
+
* A release is compared with what React committed, not with `latest`: while
|
|
45
|
+
* React holds a page, its set is not on screen, and an urgent page that ends
|
|
46
|
+
* the hold brings an equal value. A release that finds every set committed
|
|
47
|
+
* with its value sets nothing.
|
|
48
|
+
*/
|
|
49
|
+
export function useRouteState<T>(
|
|
50
|
+
init: () => T,
|
|
51
|
+
same: (a: T, b: T) => boolean,
|
|
52
|
+
): [T, RouteStateSetter<T>] {
|
|
53
|
+
const [value, setValue] = useState(init);
|
|
54
|
+
const latest = useRef(value);
|
|
55
|
+
const committed = useCommitted(value);
|
|
56
|
+
const [set] = useState(
|
|
57
|
+
(): RouteStateSetter<T> => (next, signal, rebase) => {
|
|
58
|
+
const target = next(latest.current);
|
|
59
|
+
if (signal === "release") {
|
|
60
|
+
if (
|
|
61
|
+
latest.current === committed.current &&
|
|
62
|
+
same(target, latest.current)
|
|
63
|
+
)
|
|
64
|
+
return;
|
|
65
|
+
latest.current = target;
|
|
66
|
+
setValue((prev) => (same(prev, target) ? prev : target));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (same(target, latest.current)) return;
|
|
70
|
+
latest.current = target;
|
|
71
|
+
setValue(rebase ? next : target);
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
return [value, set];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The route a URL hook reads. */
|
|
78
|
+
export interface UrlRoute {
|
|
79
|
+
location: URL;
|
|
80
|
+
params: Record<string, string>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const NO_ROUTE: UrlRoute = {
|
|
84
|
+
location: new URL("/", "http://localhost"),
|
|
85
|
+
params: Object.freeze({}) as Record<string, string>,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const routeOf = (controller: EventController): UrlRoute => ({
|
|
89
|
+
location: controller.getState().location,
|
|
90
|
+
params: controller.getParams(),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A URL hook's value (usePathname, useParams, useSearchParams): `read` of the
|
|
95
|
+
* route, seeded from the hydration snapshot while the document hydrates, then
|
|
96
|
+
* set as the store notifies it and at a page's release (useRouteState). The
|
|
97
|
+
* release rides the handle channel. `read` applies when the store changes,
|
|
98
|
+
* never in a later render: a new one takes effect from the next change.
|
|
99
|
+
*
|
|
100
|
+
* A read equal to the value it is set over keeps that value, so an object
|
|
101
|
+
* reader changes identity once per value: a release that ends a hold sets the
|
|
102
|
+
* held page's object, which React's render of the held lane keeps.
|
|
103
|
+
*/
|
|
104
|
+
export function useUrlValue<T>(
|
|
105
|
+
read: (route: UrlRoute) => T,
|
|
106
|
+
same: (a: T, b: T) => boolean,
|
|
107
|
+
): T {
|
|
108
|
+
const ctx = useContext(NavigationStoreContext);
|
|
109
|
+
const [value, set] = useRouteState(() => {
|
|
110
|
+
if (!ctx) return read(NO_ROUTE);
|
|
111
|
+
const hydration = ctx.eventController.getHydrationSnapshot();
|
|
112
|
+
return read(
|
|
113
|
+
hydration
|
|
114
|
+
? { location: hydration.state.location, params: hydration.params }
|
|
115
|
+
: routeOf(ctx.eventController),
|
|
116
|
+
);
|
|
117
|
+
}, same);
|
|
118
|
+
const readRef = useRef(read);
|
|
119
|
+
readRef.current = read;
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (!ctx) return;
|
|
123
|
+
const controller = ctx.eventController;
|
|
124
|
+
const update = (signal?: RouteStateSignal): void => {
|
|
125
|
+
const next = readRef.current(routeOf(controller));
|
|
126
|
+
set((base) => (same(next, base) ? base : next), signal);
|
|
127
|
+
};
|
|
128
|
+
// Catch up with a change between the seeding render and this effect.
|
|
129
|
+
update();
|
|
130
|
+
const unsubscribe = controller.subscribe(update);
|
|
131
|
+
const unsubscribeRelease = controller.subscribeToHandles((signal) => {
|
|
132
|
+
if (signal === "release") update(signal);
|
|
133
|
+
});
|
|
134
|
+
return () => {
|
|
135
|
+
unsubscribe();
|
|
136
|
+
unsubscribeRelease();
|
|
137
|
+
};
|
|
138
|
+
}, []);
|
|
139
|
+
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
useCallback,
|
|
5
|
-
useContext,
|
|
6
|
-
useEffect,
|
|
7
|
-
useMemo,
|
|
8
|
-
useRef,
|
|
9
|
-
useState,
|
|
10
|
-
} from "react";
|
|
3
|
+
import { useCallback, useContext, useMemo } from "react";
|
|
11
4
|
import { NavigationStoreContext } from "./context.js";
|
|
5
|
+
import { useUrlValue } from "./use-route-state.js";
|
|
12
6
|
import type { ReadonlyURLSearchParams } from "../types.js";
|
|
13
7
|
import { OptimisticLocationContext } from "../../client-urls/optimistic-location.js";
|
|
14
8
|
|
|
@@ -113,38 +107,11 @@ export function useSearchParams(): [ReadonlyURLSearchParams, SetSearchParams] {
|
|
|
113
107
|
const ctx = useContext(NavigationStoreContext);
|
|
114
108
|
const optimistic = useContext(OptimisticLocationContext);
|
|
115
109
|
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
useState<ReadonlyURLSearchParams>(() => {
|
|
122
|
-
const state = ctx
|
|
123
|
-
? (ctx.eventController.getHydrationSnapshot()?.state ??
|
|
124
|
-
ctx.eventController.getState())
|
|
125
|
-
: undefined;
|
|
126
|
-
const location = state?.location as URL | undefined;
|
|
127
|
-
return new URLSearchParams(location?.searchParams);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
const prevSearch = useRef(searchParams.toString());
|
|
131
|
-
|
|
132
|
-
useEffect(() => {
|
|
133
|
-
if (!ctx) return;
|
|
134
|
-
|
|
135
|
-
const update = () => {
|
|
136
|
-
const location = ctx.eventController.getState().location as URL;
|
|
137
|
-
const nextSearch = location.searchParams.toString();
|
|
138
|
-
if (nextSearch !== prevSearch.current) {
|
|
139
|
-
prevSearch.current = nextSearch;
|
|
140
|
-
setSearchParamsState(new URLSearchParams(nextSearch));
|
|
141
|
-
}
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
update();
|
|
145
|
-
|
|
146
|
-
return ctx.eventController.subscribe(update);
|
|
147
|
-
}, []);
|
|
110
|
+
// A new object only when the string changes.
|
|
111
|
+
const searchParams = useUrlValue<ReadonlyURLSearchParams>(
|
|
112
|
+
(route) => new URLSearchParams(route.location.searchParams),
|
|
113
|
+
(a, b) => a.toString() === b.toString(),
|
|
114
|
+
);
|
|
148
115
|
|
|
149
116
|
// Reads the committed location at CALL time (not the render closure), so a
|
|
150
117
|
// functional init always merges against fresh params even when the caller's
|
package/src/browser/types.ts
CHANGED
|
@@ -253,7 +253,11 @@ export type ActionLifecycleState = "idle" | "loading" | "streaming";
|
|
|
253
253
|
* @internal This type is an implementation detail and may change without notice.
|
|
254
254
|
*/
|
|
255
255
|
export interface TrackedActionState {
|
|
256
|
-
/**
|
|
256
|
+
/**
|
|
257
|
+
* Current lifecycle state of the action. Once the call settles it is
|
|
258
|
+
* "idle" and the rest of the call's state is kept until the action's next
|
|
259
|
+
* call.
|
|
260
|
+
*/
|
|
257
261
|
state: ActionLifecycleState;
|
|
258
262
|
|
|
259
263
|
/** Server action function ID (e.g., "addToCart") */
|
|
@@ -265,13 +269,13 @@ export interface TrackedActionState {
|
|
|
265
269
|
/** Error if action failed */
|
|
266
270
|
error: unknown | null;
|
|
267
271
|
|
|
268
|
-
/** Result data from the action
|
|
272
|
+
/** Result data from the action */
|
|
269
273
|
result: unknown | null;
|
|
270
274
|
|
|
271
275
|
/**
|
|
272
276
|
* The error of a loader that threw while it re-ran after this successful
|
|
273
|
-
* action (the first, when several did). The loader also throws at its
|
|
274
|
-
*
|
|
277
|
+
* action (the first, when several did). The loader also throws at its useLoader read, where the nearest
|
|
278
|
+
* error boundary catches it.
|
|
275
279
|
*/
|
|
276
280
|
revalidationError: unknown | null;
|
|
277
281
|
}
|