@rangojs/router 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/bin/rango.js +1 -1
  2. package/dist/types/browser/action-fence.d.ts +2 -1
  3. package/dist/types/browser/event-controller.d.ts +6 -1
  4. package/dist/types/browser/partial-update.d.ts +11 -0
  5. package/dist/types/browser/react/use-action.d.ts +10 -4
  6. package/dist/types/browser/react/use-handle.d.ts +20 -16
  7. package/dist/types/browser/react/use-link-status.d.ts +5 -2
  8. package/dist/types/browser/react/use-route-state.d.ts +45 -0
  9. package/dist/types/browser/segment-reconciler.d.ts +26 -0
  10. package/dist/types/browser/types.d.ts +30 -8
  11. package/dist/types/browser/wire-protocol.d.ts +1 -0
  12. package/dist/types/cache/tag-invalidation.d.ts +0 -10
  13. package/dist/types/router/error-handling.d.ts +7 -1
  14. package/dist/types/router/match-context.d.ts +2 -0
  15. package/dist/types/router/match-middleware/background-revalidation.d.ts +3 -2
  16. package/dist/types/router/navigation-snapshot.d.ts +7 -0
  17. package/dist/types/router/segment-resolution/helpers.d.ts +19 -3
  18. package/dist/types/router/segment-resolution/resolution-pass.d.ts +2 -0
  19. package/dist/types/router/segment-resolution/streamed-handler-boundary.d.ts +2 -0
  20. package/dist/types/router/types.d.ts +1 -1
  21. package/dist/types/server/request-context.d.ts +30 -1
  22. package/dist/types/testing/render-route.d.ts +43 -8
  23. package/dist/types/testing/serve-shell-request.d.ts +8 -1
  24. package/dist/types/types/error-types.d.ts +27 -8
  25. package/dist/types/wire-params.d.ts +5 -0
  26. package/dist/vite/index.js +62 -67
  27. package/package.json +1 -1
  28. package/skills/client-urls/SKILL.md +6 -8
  29. package/skills/hooks/SKILL.md +1 -1
  30. package/skills/hooks/data.md +8 -1
  31. package/skills/hooks/handle-and-actions.md +40 -2
  32. package/skills/hooks/navigation.md +121 -15
  33. package/skills/hooks/state.md +6 -4
  34. package/skills/hooks/urls.md +17 -5
  35. package/skills/intercept/SKILL.md +6 -0
  36. package/skills/observability/SKILL.md +30 -0
  37. package/skills/server-actions/SKILL.md +8 -1
  38. package/skills/testing/client-components.md +25 -18
  39. package/skills/testing/serve-shell-request.md +7 -7
  40. package/skills/view-transitions/SKILL.md +1 -1
  41. package/src/browser/action-fence.ts +2 -1
  42. package/src/browser/event-controller.ts +78 -19
  43. package/src/browser/navigation-bridge.ts +45 -13
  44. package/src/browser/navigation-client.ts +2 -0
  45. package/src/browser/network-error-handler.ts +4 -0
  46. package/src/browser/partial-update.ts +33 -6
  47. package/src/browser/react/use-action.ts +27 -50
  48. package/src/browser/react/use-handle.ts +27 -18
  49. package/src/browser/react/use-link-status.ts +48 -60
  50. package/src/browser/react/use-navigation.ts +76 -51
  51. package/src/browser/react/use-params.ts +6 -35
  52. package/src/browser/react/use-pathname.ts +3 -32
  53. package/src/browser/react/use-route-state.ts +141 -0
  54. package/src/browser/react/use-search-params.ts +7 -40
  55. package/src/browser/segment-reconciler.ts +43 -0
  56. package/src/browser/server-action-bridge.ts +35 -8
  57. package/src/browser/types.ts +31 -8
  58. package/src/browser/wire-protocol.ts +2 -0
  59. package/src/cache/cache-runtime.ts +28 -31
  60. package/src/cache/document-cache.ts +34 -37
  61. package/src/cache/read-through-swr.ts +20 -18
  62. package/src/cache/tag-invalidation.ts +3 -18
  63. package/src/router/error-handling.ts +18 -6
  64. package/src/router/intercept-resolution.ts +9 -4
  65. package/src/router/match-api.ts +1 -0
  66. package/src/router/match-context.ts +3 -0
  67. package/src/router/match-middleware/background-revalidation.ts +5 -4
  68. package/src/router/match-middleware/cache-lookup.ts +5 -3
  69. package/src/router/navigation-snapshot.ts +10 -0
  70. package/src/router/segment-resolution/helpers.ts +49 -13
  71. package/src/router/segment-resolution/loader-cache.ts +4 -3
  72. package/src/router/segment-resolution/resolution-pass.ts +2 -0
  73. package/src/router/segment-resolution/revalidation.ts +57 -28
  74. package/src/router/segment-resolution/streamed-handler-boundary.ts +3 -0
  75. package/src/router/types.ts +1 -1
  76. package/src/router.ts +7 -4
  77. package/src/rsc/request-stages.ts +4 -2
  78. package/src/rsc/shell-capture.ts +63 -58
  79. package/src/server/request-context.ts +49 -0
  80. package/src/testing/dispatch.ts +3 -0
  81. package/src/testing/render-route.tsx +204 -16
  82. package/src/testing/serve-shell-request.ts +19 -10
  83. package/src/types/error-types.ts +27 -8
  84. package/src/wire-params.ts +5 -0
@@ -5,12 +5,10 @@ import {
5
5
  useContext,
6
6
  useState,
7
7
  useEffect,
8
- useRef,
9
- useOptimistic,
10
- startTransition,
11
8
  type Context,
12
9
  } from "react";
13
10
  import { NavigationStoreContext } from "./context.js";
11
+ import type { EventController, NavigationEntry } from "../event-controller.js";
14
12
 
15
13
  /**
16
14
  * Context for Link component to provide its destination URL
@@ -43,24 +41,45 @@ function normalizeUrl(url: string, origin: string): string {
43
41
  }
44
42
 
45
43
  /**
46
- * Check if this link's destination matches the pending navigation URL
44
+ * The navigation in flight a link can be pending for. One that shows no
45
+ * loading state (a background revalidation, an action's refetch) is none: it
46
+ * goes to no link and ends none whose page React still holds.
47
+ */
48
+ function visibleNavigation(ec: EventController): NavigationEntry | null {
49
+ const navigation = ec.getCurrentNavigation();
50
+ return navigation && !navigation.options?.skipLoadingState
51
+ ? navigation
52
+ : null;
53
+ }
54
+
55
+ /**
56
+ * Whether `navigation` goes to `linkTo`: its URL or, after a server redirect,
57
+ * the URL its chain started from.
47
58
  */
48
59
  function isPendingFor(
49
60
  linkTo: string | null,
50
- pendingUrl: string | null,
61
+ navigation: NavigationEntry | null,
51
62
  origin: string,
52
63
  ): boolean {
53
- if (linkTo === null || pendingUrl === null) {
64
+ if (linkTo === null || !navigation) {
54
65
  return false;
55
66
  }
56
- return normalizeUrl(pendingUrl, origin) === normalizeUrl(linkTo, origin);
67
+ const to = normalizeUrl(linkTo, origin);
68
+ const from = navigation.options?._redirectFrom;
69
+ return (
70
+ normalizeUrl(navigation.url, origin) === to ||
71
+ (from !== undefined && normalizeUrl(from, origin) === to)
72
+ );
57
73
  }
58
74
 
59
75
  /**
60
76
  * Hook to track the pending state of a Link component
61
77
  *
62
- * Must be used inside a Link component. Returns `{ pending: true }`
63
- * when navigation to this link's destination is in progress.
78
+ * Must be used inside a Link component. Returns `{ pending: true }` while a
79
+ * navigation to the link's destination is in flight, until the page it
80
+ * brings commits: under transition() while React holds that page, and
81
+ * through a server redirect until the target page commits. A newer
82
+ * navigation ends it at once.
64
83
  *
65
84
  * Useful for showing inline loading indicators on individual links.
66
85
  *
@@ -84,63 +103,32 @@ export function useLinkStatus(): LinkStatus {
84
103
 
85
104
  const origin = ctx?.origin ?? "http://localhost";
86
105
 
87
- const [basePending, setBasePending] = useState<boolean>(() => {
88
- if (!ctx || linkTo === null) {
89
- return false;
90
- }
91
- const state =
92
- ctx.eventController.getHydrationSnapshot()?.state ??
93
- ctx.eventController.getState();
94
- return isPendingFor(linkTo, state.pendingUrl, origin);
95
- });
96
-
97
- const prevPending = useRef(basePending);
98
-
99
- // Tracks whether the most recent setOptimisticPending call pinned the value
100
- // to a non-idle (loading) state. Used to decide whether to emit a release
101
- // update when returning to idle, so the optimistic store doesn't stay pinned
102
- // to `true` if a parent transition (e.g. the <Link> click / view transition
103
- // commit) is still pending.
104
- const optimisticPinnedRef = useRef(false);
105
-
106
- const [pending, setOptimisticPending] = useOptimistic(basePending);
107
-
106
+ // The document renders no navigation in flight.
107
+ const [pending, setPending] = useState<boolean>(
108
+ () =>
109
+ !!ctx &&
110
+ !ctx.eventController.getHydrationSnapshot() &&
111
+ isPendingFor(linkTo, visibleNavigation(ctx.eventController), origin),
112
+ );
113
+
114
+ // The value commits in the lane of the notification that carries it, as
115
+ // useNavigation's does: at once from the click's task, and with the page
116
+ // when its commit clears the navigation (NavigationProvider flushes that
117
+ // notification inside the page's update). Set once per navigation: a
118
+ // repeated urgent set would commit ahead of a held page's settle. A
119
+ // background navigation (an action's refetch) is not a newer one, so a
120
+ // held link stays pending.
108
121
  useEffect(() => {
109
122
  if (!ctx || linkTo === null) {
110
123
  return;
111
124
  }
112
125
 
126
+ let last: NavigationEntry | null | undefined;
113
127
  const update = () => {
114
- const state = ctx.eventController.getState();
115
- const isPending = isPendingFor(linkTo, state.pendingUrl, origin);
116
-
117
- if (isPending !== prevPending.current) {
118
- prevPending.current = isPending;
119
-
120
- const shouldPin = isPending && state.state !== "idle";
121
-
122
- if (shouldPin) {
123
- // Pin the optimistic value so the spinner shows immediately even if
124
- // a parent transition (e.g. <Link> click) defers the urgent
125
- // setBasePending commit.
126
- startTransition(() => {
127
- setOptimisticPending(isPending);
128
- });
129
- optimisticPinnedRef.current = true;
130
- } else if (optimisticPinnedRef.current) {
131
- // Release a previously-pinned optimistic value. Without this,
132
- // useOptimistic keeps returning the stale `true` while any parent
133
- // transition is still pending, even after basePending flipped to
134
- // false at navigation completion — leaving the link spinner stuck.
135
- startTransition(() => {
136
- setOptimisticPending(isPending);
137
- });
138
- optimisticPinnedRef.current = false;
139
- }
140
-
141
- // Always update base state
142
- setBasePending(isPending);
143
- }
128
+ const navigation = visibleNavigation(ctx.eventController);
129
+ if (navigation === last) return;
130
+ last = navigation;
131
+ setPending(isPendingFor(linkTo, navigation, origin));
144
132
  };
145
133
 
146
134
  // Catch-up: re-read state synchronously on mount before subscribing, so a
@@ -1,17 +1,14 @@
1
1
  "use client";
2
2
 
3
- import {
4
- useContext,
5
- useState,
6
- useEffect,
7
- useLayoutEffect,
8
- startTransition,
9
- useRef,
10
- } from "react";
3
+ import { useContext, useEffect, useRef } from "react";
11
4
  import { NavigationStoreContext } from "./context.js";
12
5
  import { shallowEqual } from "./shallow-equal.js";
6
+ import { useRouteState } from "./use-route-state.js";
13
7
  import type { PublicNavigationState } from "../types.js";
14
- import type { DerivedNavigationState } from "../event-controller.js";
8
+ import type {
9
+ DerivedNavigationState,
10
+ RouteStateSignal,
11
+ } from "../event-controller.js";
15
12
 
16
13
  /**
17
14
  * Convert derived state to public version (strips inflightActions)
@@ -21,6 +18,32 @@ function toPublicState(state: DerivedNavigationState): PublicNavigationState {
21
18
  return publicState;
22
19
  }
23
20
 
21
+ /** The state a value was selected from, and the value. */
22
+ interface Snapshot<T> {
23
+ state: PublicNavigationState;
24
+ value: T;
25
+ }
26
+
27
+ /**
28
+ * A notification while React holds a page: work that starts shows at once
29
+ * (loading, streaming, a newer click's URL), what settles waits for the
30
+ * page's commit (rango-d152), and location moves only with the notification
31
+ * that moves it (the page on screen until the next one shows).
32
+ */
33
+ function hold(
34
+ base: PublicNavigationState,
35
+ live: PublicNavigationState,
36
+ moved: boolean,
37
+ ): PublicNavigationState {
38
+ return {
39
+ state: live.state === "loading" ? live.state : base.state,
40
+ isStreaming: live.isStreaming || base.isStreaming,
41
+ isNavigating: live.isNavigating || base.isNavigating,
42
+ pendingUrl: live.pendingUrl ?? base.pendingUrl,
43
+ location: moved ? live.location : base.location,
44
+ };
45
+ }
46
+
24
47
  /**
25
48
  * Hook to access reactive navigation state with optional selector for performance.
26
49
  *
@@ -46,19 +69,20 @@ export function useNavigation<T>(
46
69
  throw new Error("useNavigation must be used within NavigationProvider");
47
70
  }
48
71
 
49
- const [value, setValue] = useState<T | PublicNavigationState>(() => {
50
- const publicState = toPublicState(
51
- ctx.eventController.getHydrationSnapshot()?.state ??
52
- ctx.eventController.getState(),
53
- );
54
- return selector ? selector(publicState) : publicState;
55
- });
56
- const prevState = useRef(value);
57
- // The value on screen: a set value that differs has not committed yet.
58
- const committedRef = useRef(value);
59
- useLayoutEffect(() => {
60
- committedRef.current = value;
61
- });
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>>(
77
+ () => {
78
+ const state = toPublicState(
79
+ ctx.eventController.getHydrationSnapshot()?.state ??
80
+ ctx.eventController.getState(),
81
+ );
82
+ return { state, value: selector ? selector(state) : state };
83
+ },
84
+ (a, b) => shallowEqual(a.value, b.value),
85
+ );
62
86
 
63
87
  // Store selector in a ref so the subscription callback always uses the
64
88
  // latest selector without re-subscribing on every render (inline functions
@@ -74,34 +98,28 @@ export function useNavigation<T>(
74
98
  // the location hooks' values do: NavigationProvider flushes a navigation's
75
99
  // notification into the transition that commits its tree, so idle commits
76
100
  // with the destination's location (useSearchParams, usePathname,
77
- // useParams). An optimistic update renders in the sync lane, ahead of that
78
- // transition, and put idle on screen with the location being left (#1164).
101
+ // useParams). From that page's anchor to its release every other
102
+ // notification goes through hold(), urgently, so React rebases the same
103
+ // update onto the page when it commits. Never a transition of its own.
79
104
  useEffect(() => {
80
- let last: DerivedNavigationState | undefined;
81
- const update = () => {
82
- const currentState = ctx.eventController.getState();
83
- // Neither the state nor the location moved (the stream starting or
84
- // ending): while a value of this hook's is held, a transition, which
85
- // React entangles with it, so it never commits ahead of the location.
86
- // Otherwise urgent: a transition of its own would start a view
87
- // transition, and could commit ahead of a held page and take its
88
- // transition types.
89
- const quiet =
90
- currentState.state === last?.state &&
91
- currentState.location === last.location;
92
- last = currentState;
93
- const publicState = toPublicState(currentState);
94
- const nextSelected = selectorRef.current
95
- ? selectorRef.current(publicState)
96
- : publicState;
97
-
98
- // Check if selected value has changed
99
- if (!shallowEqual(nextSelected, prevState.current)) {
100
- const held = prevState.current !== committedRef.current;
101
- prevState.current = nextSelected;
102
- if (quiet && held) startTransition(() => setValue(nextSelected));
103
- else setValue(nextSelected);
104
- }
105
+ let pageHeld = false;
106
+ let location: PublicNavigationState["location"] | undefined;
107
+ const select = (state: PublicNavigationState) =>
108
+ selectorRef.current ? selectorRef.current(state) : state;
109
+ const update = (signal?: RouteStateSignal) => {
110
+ if (signal) pageHeld = signal === "anchor";
111
+ const live = toPublicState(ctx.eventController.getState());
112
+ const moved = live.location !== location;
113
+ location = live.location;
114
+ const held = pageHeld && !signal;
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
+ );
105
123
  };
106
124
 
107
125
  // Catch-up: re-read state synchronously on mount before subscribing, so a
@@ -110,8 +128,15 @@ export function useNavigation<T>(
110
128
  // useSearchParams.
111
129
  update();
112
130
 
113
- return ctx.eventController.subscribe(update);
131
+ const unsubscribe = ctx.eventController.subscribe(update);
132
+ // The anchor and release signals ride the handle channel. One without a
133
+ // signal re-reads the same state, as the state channel's own would.
134
+ const unsubscribeSignals = ctx.eventController.subscribeToHandles(update);
135
+ return () => {
136
+ unsubscribe();
137
+ unsubscribeSignals();
138
+ };
114
139
  }, []);
115
140
 
116
- return value as T | PublicNavigationState;
141
+ return snapshot.value as T | PublicNavigationState;
117
142
  }
@@ -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
@@ -260,3 +260,46 @@ export function reconcileErrorSegments(
260
260
  interceptSegments: intercept,
261
261
  };
262
262
  }
263
+
264
+ /**
265
+ * The ids a page holds once an action's error boundary `boundary` shows: the
266
+ * ones above it. Nothing at or below the boundary ran after the action's
267
+ * write (its response and the follow-up cut there), so the next request must
268
+ * not hold it. ShortCodes nest, so those ids start with the boundary's: the
269
+ * boundary, its loaders (`${id}D…`), its slots and every entry under it. A
270
+ * sibling with a longer index (`…R10` beside `…R1`) is kept.
271
+ */
272
+ export function idsAboveBoundary(
273
+ ids: string[],
274
+ boundary: string | undefined,
275
+ ): string[] {
276
+ return boundary
277
+ ? ids.filter(
278
+ (id) =>
279
+ !id.startsWith(boundary) || /\d/.test(id.charAt(boundary.length)),
280
+ )
281
+ : ids;
282
+ }
283
+
284
+ /**
285
+ * The error segment of every action error boundary committed, by identity
286
+ * (server-action-bridge.ts adds and deletes). An entry shows one while its
287
+ * cached list still holds that object: a later commit on the entry replaces
288
+ * the list, and a refetch that holds the boundary keeps it. Weak, so a
289
+ * replaced boundary is not kept alive. Two routers never share a segment
290
+ * object. No hook reads it.
291
+ */
292
+ export const actionBoundaries: WeakSet<ResolvedSegment> = new WeakSet();
293
+
294
+ /**
295
+ * The index of the deepest action error boundary in `segments`, -1 for none.
296
+ * It is that action's outcome, which no GET reproduces: a background refetch
297
+ * of the page (the follow-up, Back/Forward's stale revalidation, the
298
+ * cross-tab refresh) holds it and renders what is above it. At 0 nothing is
299
+ * above it.
300
+ */
301
+ export function actionBoundaryAt(segments: readonly ResolvedSegment[]): number {
302
+ let at = segments.length;
303
+ while (at-- && !actionBoundaries.has(segments[at]));
304
+ return at;
305
+ }