@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.
@@ -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) | 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. |
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 + content negotiation, plus the global + route-level middleware guard stack on RESPONSE routes — it never renders React. Use Flight primitives or e2e to exercise component rendering.
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
- // Nothing inflight — skip building/scanning the list and return the shared
717
- // idle snapshot (the same reference use-action falls back to).
718
- if (inflightActions.size === 0) return DEFAULT_ACTION_STATE;
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, or none once a later call left the
721
- // map: an earlier call still running is never reported again.
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: ActionEntry | undefined;
724
- for (const a of inflightActions.values()) {
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 (useAction reads) and, on a slow connection,
911
- // while the Flight stream drains its EOF after complete(). Counting them
912
- // latched hadAnyConcurrentActions for back-to-back sequential actions,
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 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).
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
- for (const a of inflightActions.values()) {
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
- inflightActions.delete(id);
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 inflight actions tracked by the controller.
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
- * - Captures result/error locally (React handles cleanup)
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
- const [snapshot, setSnapshot] = useState<Snapshot<T | PublicNavigationState>>(
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
- const next = (
122
- base: Snapshot<T | PublicNavigationState>,
123
- ): Snapshot<T | PublicNavigationState> => {
124
- const state = held ? hold(base.state, live, moved) : live;
125
- return { state, value: select(state) };
126
- };
127
- const target = next(latest.current);
128
- // A release is compared with the state React renders it on, not
129
- // `latest`: an urgent page can end the hold before React commits the
130
- // anchor's set. An anchor sets inside its page's transition, which
131
- // renders every set so far, so `latest` is that state. A release that
132
- // finds every set committed with its value sets nothing: the updater
133
- // would render the reader again only to bail out.
134
- if (signal === "release") {
135
- if (
136
- latest.current === committed.current &&
137
- shallowEqual(target.value, latest.current.value)
138
- )
139
- return;
140
- latest.current = target;
141
- setSnapshot((prev) =>
142
- shallowEqual(prev.value, target.value) ? prev : target,
143
- );
144
- return;
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, useState, useEffect, useRef } from "react";
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
- const [value, setValue] = useState<T | Record<string, string>>(() => {
52
- const params = ctx
53
- ? (ctx.eventController.getHydrationSnapshot()?.params ??
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, useState, useEffect, useRef } from "react";
4
- import { NavigationStoreContext } from "./context.js";
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
- // Seed from the store location on BOTH sides (mirrors usePathname): the
117
- // SSR store carries the live request's search, the browser store carries
118
- // window.location — same URL, so the hydration renders agree. No ctx
119
- // (no provider) seeds empty.
120
- const [searchParams, setSearchParamsState] =
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
@@ -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
- /** Current lifecycle state of the action */
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 (preserved after completion) */
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
- * useLoader read, where the nearest error boundary catches it.
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
  }