@rangojs/router 0.29.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.
package/dist/bin/rango.js CHANGED
@@ -1644,7 +1644,7 @@ var init_package = __esm({
1644
1644
  "package.json"() {
1645
1645
  package_default = {
1646
1646
  name: "@rangojs/router",
1647
- version: "0.29.0",
1647
+ version: "0.30.0",
1648
1648
  description: "Django-inspired RSC router with composable URL patterns",
1649
1649
  keywords: [
1650
1650
  "react",
@@ -13,11 +13,12 @@ export type ServerActionFunction = ((...args: any[]) => Promise<any>) & {
13
13
  * tracks the state of individual server action invocations.
14
14
  *
15
15
  * Uses the event controller for reactive state management.
16
- * State is derived from the inflight actions tracked by the controller.
16
+ * State is derived from the calls the controller tracks: those in flight,
17
+ * and each action's last settled call.
17
18
  *
18
19
  * Features:
19
20
  * - Tracks action lifecycle: idle → loading → streaming → idle
20
- * - Captures result/error locally (React handles cleanup)
21
+ * - Keeps the settled call's state until the action is called again.
21
22
  * - If the same action fires several times, reports the latest call to
22
23
  * start. An earlier call that settles later changes nothing it reports;
23
24
  * a latest call that was aborted reports idle with nothing. One call at a
@@ -39,6 +39,7 @@ export declare function useHandle<T, A, S>(handle: Handle<T, A>, selector: (data
39
39
  * location commit with their page.
40
40
  *
41
41
  * A value shallow-equal to the last one sets nothing, so a reader renders only
42
- * when its value moves.
42
+ * when its value moves. A release is compared with what React committed, as in
43
+ * useRouteState.
43
44
  */
44
45
  export declare function useCommitAnchoredState<T>(init: () => T, waitForRelease?: boolean): [T, (value: T, signal?: RouteStateSignal) => void];
@@ -0,0 +1,45 @@
1
+ import type { RouteStateSignal } from "../event-controller.js";
2
+ /**
3
+ * The value a reader rendered in React's last commit of it, written by its
4
+ * layout effect. A page's release runs in the provider's layout effect, after
5
+ * every reader's (children first), so it reads the commit that brings the
6
+ * page.
7
+ */
8
+ export declare function useCommitted<T>(value: T): {
9
+ readonly current: T;
10
+ };
11
+ /**
12
+ * Sets a route-state reader's value. `next` maps the value React ends on once
13
+ * every set so far commits (`latest`) to the reader's value; `rebase` sets
14
+ * `next` itself, so React recomputes it on whatever base it renders. The value
15
+ * must not be a function.
16
+ */
17
+ export type RouteStateSetter<T> = (next: (base: T) => T, signal?: RouteStateSignal, rebase?: boolean) => void;
18
+ /**
19
+ * Route state a reader sets as the router store notifies it, in the lane of
20
+ * the notification that carries it. A notification equal to `latest` sets
21
+ * nothing, so a reader renders only when its value moves.
22
+ *
23
+ * A release is compared with what React committed, not with `latest`: while
24
+ * React holds a page, its set is not on screen, and an urgent page that ends
25
+ * the hold brings an equal value. A release that finds every set committed
26
+ * with its value sets nothing.
27
+ */
28
+ export declare function useRouteState<T>(init: () => T, same: (a: T, b: T) => boolean): [T, RouteStateSetter<T>];
29
+ /** The route a URL hook reads. */
30
+ export interface UrlRoute {
31
+ location: URL;
32
+ params: Record<string, string>;
33
+ }
34
+ /**
35
+ * A URL hook's value (usePathname, useParams, useSearchParams): `read` of the
36
+ * route, seeded from the hydration snapshot while the document hydrates, then
37
+ * set as the store notifies it and at a page's release (useRouteState). The
38
+ * release rides the handle channel. `read` applies when the store changes,
39
+ * never in a later render: a new one takes effect from the next change.
40
+ *
41
+ * A read equal to the value it is set over keeps that value, so an object
42
+ * reader changes identity once per value: a release that ends a hold sets the
43
+ * held page's object, which React's render of the held lane keeps.
44
+ */
45
+ export declare function useUrlValue<T>(read: (route: UrlRoute) => T, same: (a: T, b: T) => boolean): T;
@@ -231,7 +231,11 @@ export type ActionLifecycleState = "idle" | "loading" | "streaming";
231
231
  * @internal This type is an implementation detail and may change without notice.
232
232
  */
233
233
  export interface TrackedActionState {
234
- /** Current lifecycle state of the action */
234
+ /**
235
+ * Current lifecycle state of the action. Once the call settles it is
236
+ * "idle" and the rest of the call's state is kept until the action's next
237
+ * call.
238
+ */
235
239
  state: ActionLifecycleState;
236
240
  /** Server action function ID (e.g., "addToCart") */
237
241
  actionId: string | null;
@@ -239,12 +243,12 @@ export interface TrackedActionState {
239
243
  payload: unknown[] | FormData | null;
240
244
  /** Error if action failed */
241
245
  error: unknown | null;
242
- /** Result data from the action (preserved after completion) */
246
+ /** Result data from the action */
243
247
  result: unknown | null;
244
248
  /**
245
249
  * The error of a loader that threw while it re-ran after this successful
246
- * action (the first, when several did). The loader also throws at its
247
- * useLoader read, where the nearest error boundary catches it.
250
+ * action (the first, when several did). The loader also throws at its useLoader read, where the nearest
251
+ * error boundary catches it.
248
252
  */
249
253
  revalidationError: unknown | null;
250
254
  }
@@ -385,15 +385,15 @@ export interface TestRouterHandle {
385
385
  * await act(async () => tail.resolve());
386
386
  * // useNavigation(): idle, isStreaming false
387
387
  *
388
- * A navigation started while React holds a page goes inside
389
- * `await act(async () => ...)`. Its first update makes React retry the held
390
- * page, which suspends again; a synchronous act() scope drops that retry
391
- * and React renders no later update of the same priority.
388
+ * A navigation started while React holds a page shows its page once the
389
+ * call resolves, with or without act(). A `<Link>` click over a held page
390
+ * still goes inside `await act(async () => ...)` (docs/testing.md).
392
391
  *
393
392
  * @example
394
- * await act(async () => {
395
- * await router.navigate("/products/3", { transition: false });
396
- * });
393
+ * await router.navigate("/products/2", { loaders: [[ProductLoader, next]] });
394
+ * // React holds /products/2
395
+ * await router.navigate("/products/3", { transition: false });
396
+ * // /products/3 is on screen
397
397
  */
398
398
  navigate(url: string, options?: Pick<RenderRouteOptions, "loaders" | "handles"> & Pick<NavigateOptions, "state" | "replace" | "transition"> & {
399
399
  streamUntil?: Promise<unknown>;
@@ -4491,13 +4491,13 @@ import {
4491
4491
  Component as Component3,
4492
4492
  createElement as createElement4,
4493
4493
  isValidElement as isValidElement2,
4494
- useContext as useContext17,
4494
+ useContext as useContext18,
4495
4495
  useMemo as useMemo9,
4496
4496
  Suspense as Suspense2
4497
4497
  } from "react";
4498
4498
 
4499
4499
  // src/browser/react/use-handle.ts
4500
- import { useContext, useState, useEffect, useRef } from "react";
4500
+ import { useContext as useContext2, useState as useState2, useEffect as useEffect2, useRef as useRef2 } from "react";
4501
4501
 
4502
4502
  // src/missing-id-error.ts
4503
4503
  var SELF_FILES = /* @__PURE__ */ new Set([
@@ -4575,6 +4575,19 @@ var NavigationStoreContext = createContext(null);
4575
4575
  var LocationStateContext = createContext(void 0);
4576
4576
  var HydrationBarrierContext = createContext(0);
4577
4577
 
4578
+ // src/browser/react/use-route-state.ts
4579
+ import {
4580
+ useContext,
4581
+ useEffect,
4582
+ useLayoutEffect,
4583
+ useRef,
4584
+ useState
4585
+ } from "react";
4586
+ var NO_ROUTE = {
4587
+ location: new URL("/", "http://localhost"),
4588
+ params: Object.freeze({})
4589
+ };
4590
+
4578
4591
  // src/handles/meta.ts
4579
4592
  function isUnsetDescriptor(descriptor) {
4580
4593
  return typeof descriptor === "object" && descriptor !== null && "unset" in descriptor && typeof descriptor.unset === "string";
@@ -4704,18 +4717,18 @@ function collectMeta(segments) {
4704
4717
  var Meta = createHandle(collectMeta, "__rsc_router_meta__");
4705
4718
 
4706
4719
  // src/theme/theme-context.ts
4707
- import { createContext as createContext2, useContext as useContext2 } from "react";
4720
+ import { createContext as createContext2, useContext as useContext3 } from "react";
4708
4721
  var ThemeContext = createContext2(null);
4709
4722
 
4710
4723
  // src/browser/react/nonce-context.ts
4711
- import { createContext as createContext3, useContext as useContext3 } from "react";
4724
+ import { createContext as createContext3, useContext as useContext4 } from "react";
4712
4725
  var NonceContext = createContext3(void 0);
4713
4726
 
4714
4727
  // src/handles/MetaTags.tsx
4715
4728
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4716
4729
 
4717
4730
  // src/handles/Scripts.tsx
4718
- import { useState as useState2 } from "react";
4731
+ import { useState as useState3 } from "react";
4719
4732
 
4720
4733
  // src/handles/script.ts
4721
4734
  function validateConfigDev(config) {
@@ -4769,7 +4782,7 @@ var Script = createHandle(collectScripts, "__rsc_router_script__");
4769
4782
  import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
4770
4783
 
4771
4784
  // src/browser/react/ScrollRestoration.tsx
4772
- import { useEffect as useEffect2 } from "react";
4785
+ import { useEffect as useEffect3 } from "react";
4773
4786
 
4774
4787
  // src/browser/scroll-restoration.ts
4775
4788
  init_logging();
@@ -4783,12 +4796,12 @@ import { Component, Suspense, use as use2, useMemo as useMemo3 } from "react";
4783
4796
 
4784
4797
  // src/outlet-provider.tsx
4785
4798
  import {
4786
- useContext as useContext4,
4799
+ useContext as useContext5,
4787
4800
  useMemo
4788
4801
  } from "react";
4789
4802
 
4790
4803
  // src/suspense-audit-react.tsx
4791
- import { useEffect as useEffect3, useLayoutEffect, useRef as useRef2 } from "react";
4804
+ import { useEffect as useEffect4, useLayoutEffect as useLayoutEffect2, useRef as useRef3 } from "react";
4792
4805
 
4793
4806
  // src/suspense-audit.ts
4794
4807
  init_internal_debug();
@@ -4803,13 +4816,13 @@ import { jsx as jsx3 } from "react/jsx-runtime";
4803
4816
  import { createElement as createElement2 } from "react";
4804
4817
 
4805
4818
  // src/loader-redirect.tsx
4806
- import { useEffect as useEffect4 } from "react";
4819
+ import { useEffect as useEffect5 } from "react";
4807
4820
 
4808
4821
  // src/browser/react/use-router.ts
4809
- import { useContext as useContext6, useMemo as useMemo2 } from "react";
4822
+ import { useContext as useContext7, useMemo as useMemo2 } from "react";
4810
4823
 
4811
4824
  // src/browser/react/use-mount.ts
4812
- import { useContext as useContext5 } from "react";
4825
+ import { useContext as useContext6 } from "react";
4813
4826
 
4814
4827
  // src/browser/react/mount-context.ts
4815
4828
  import {
@@ -5002,12 +5015,12 @@ import {
5002
5015
  startTransition,
5003
5016
  use as use3,
5004
5017
  useCallback,
5005
- useContext as useContext7,
5006
- useEffect as useEffect5,
5018
+ useContext as useContext8,
5019
+ useEffect as useEffect6,
5007
5020
  useMemo as useMemo4,
5008
5021
  useOptimistic,
5009
- useRef as useRef3,
5010
- useState as useState3
5022
+ useRef as useRef4,
5023
+ useState as useState4
5011
5024
  } from "react";
5012
5025
 
5013
5026
  // src/loader-store.ts
@@ -5169,37 +5182,23 @@ function insertSegments(node, segments, index, leafBase, paramNames) {
5169
5182
  }
5170
5183
 
5171
5184
  // src/browser/react/use-navigation.ts
5172
- import {
5173
- useContext as useContext8,
5174
- useState as useState4,
5175
- useEffect as useEffect6,
5176
- useLayoutEffect as useLayoutEffect2,
5177
- useRef as useRef4
5178
- } from "react";
5185
+ import { useContext as useContext9, useEffect as useEffect7, useRef as useRef5 } from "react";
5179
5186
 
5180
5187
  // src/browser/react/use-pathname.ts
5181
- import { useContext as useContext9, useState as useState5, useEffect as useEffect7, useRef as useRef5 } from "react";
5188
+ import { useContext as useContext10 } from "react";
5182
5189
 
5183
5190
  // src/client-urls/optimistic-location.ts
5184
5191
  import { createContext as createContext6 } from "react";
5185
5192
  var OptimisticLocationContext = createContext6(null);
5186
5193
 
5187
5194
  // src/browser/react/use-search-params.ts
5188
- import {
5189
- useCallback as useCallback2,
5190
- useContext as useContext10,
5191
- useEffect as useEffect8,
5192
- useMemo as useMemo5,
5193
- useRef as useRef6,
5194
- useState as useState6
5195
- } from "react";
5195
+ import { useCallback as useCallback2, useContext as useContext11, useMemo as useMemo5 } from "react";
5196
5196
 
5197
5197
  // src/browser/react/use-params.ts
5198
- import { useContext as useContext11, useState as useState7, useEffect as useEffect9, useRef as useRef7 } from "react";
5199
- var EMPTY_PARAMS = Object.freeze({});
5198
+ import { useContext as useContext12 } from "react";
5200
5199
 
5201
5200
  // src/browser/react/use-action.ts
5202
- import { useContext as useContext12, useState as useState8, useEffect as useEffect10, useRef as useRef8 } from "react";
5201
+ import { useContext as useContext13, useState as useState5, useEffect as useEffect8, useRef as useRef6 } from "react";
5203
5202
 
5204
5203
  // src/browser/event-controller.ts
5205
5204
  init_notify_listeners();
@@ -5262,31 +5261,31 @@ function subscribeToLocationChange(eventController, listener) {
5262
5261
  }
5263
5262
 
5264
5263
  // src/browser/react/use-segments.ts
5265
- import { useContext as useContext13, useEffect as useEffect11, useRef as useRef9 } from "react";
5264
+ import { useContext as useContext14, useEffect as useEffect9, useRef as useRef7 } from "react";
5266
5265
 
5267
5266
  // src/browser/react/NavigationProvider.tsx
5268
5267
  import {
5269
- useState as useState11,
5270
- useEffect as useEffect13,
5268
+ useState as useState8,
5269
+ useEffect as useEffect11,
5271
5270
  useLayoutEffect as useLayoutEffect4,
5272
5271
  useCallback as useCallback4,
5273
5272
  useMemo as useMemo7,
5274
- useRef as useRef11,
5273
+ useRef as useRef9,
5275
5274
  use as use4,
5276
5275
  startTransition as startTransition2
5277
5276
  } from "react";
5278
5277
 
5279
5278
  // src/root-error-boundary.tsx
5280
- import { Component as Component2, useState as useState9 } from "react";
5279
+ import { Component as Component2, useState as useState6 } from "react";
5281
5280
  import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
5282
5281
 
5283
5282
  // src/theme/ThemeProvider.tsx
5284
5283
  import {
5285
5284
  useCallback as useCallback3,
5286
- useEffect as useEffect12,
5285
+ useEffect as useEffect10,
5287
5286
  useLayoutEffect as useLayoutEffect3,
5288
5287
  useMemo as useMemo6,
5289
- useState as useState10
5288
+ useState as useState7
5290
5289
  } from "react";
5291
5290
 
5292
5291
  // src/theme/constants.ts
@@ -5320,18 +5319,18 @@ import { jsx as jsx7 } from "react/jsx-runtime";
5320
5319
  import {
5321
5320
  forwardRef,
5322
5321
  useCallback as useCallback5,
5323
- useContext as useContext15,
5324
- useEffect as useEffect15,
5322
+ useContext as useContext16,
5323
+ useEffect as useEffect13,
5325
5324
  useMemo as useMemo8,
5326
- useRef as useRef12
5325
+ useRef as useRef10
5327
5326
  } from "react";
5328
5327
 
5329
5328
  // src/browser/react/use-link-status.ts
5330
5329
  import {
5331
5330
  createContext as createContext7,
5332
- useContext as useContext14,
5333
- useState as useState12,
5334
- useEffect as useEffect14
5331
+ useContext as useContext15,
5332
+ useState as useState9,
5333
+ useEffect as useEffect12
5335
5334
  } from "react";
5336
5335
  var LinkContext = createContext7(
5337
5336
  null
@@ -5532,14 +5531,14 @@ var Link = forwardRef(function Link2({
5532
5531
  onClick,
5533
5532
  ...props
5534
5533
  }, ref) {
5535
- const ctx = useContext15(NavigationStoreContext);
5534
+ const ctx = useContext16(NavigationStoreContext);
5536
5535
  const isExternal = isExternalUrl(to, ctx?.origin);
5537
5536
  const resolvedTo = useMemo8(
5538
5537
  () => isExternal ? to : prefixBasename(to, ctx?.basename),
5539
5538
  [to, isExternal, ctx?.basename]
5540
5539
  );
5541
5540
  const configuredStrategy = prefetch ?? ctx?.defaultPrefetch ?? ENVIRONMENT_DEFAULT_PREFETCH;
5542
- const internalRef = useRef12(null);
5541
+ const internalRef = useRef10(null);
5543
5542
  const setRef = useCallback5(
5544
5543
  (node) => {
5545
5544
  internalRef.current = node;
@@ -5551,7 +5550,7 @@ var Link = forwardRef(function Link2({
5551
5550
  },
5552
5551
  [ref]
5553
5552
  );
5554
- const stateRef = useRef12(state);
5553
+ const stateRef = useRef10(state);
5555
5554
  stateRef.current = state;
5556
5555
  const handleClick = useCallback5(
5557
5556
  (e) => {
@@ -5608,7 +5607,7 @@ var Link = forwardRef(function Link2({
5608
5607
  );
5609
5608
  }
5610
5609
  }, [configuredStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
5611
- useEffect15(() => {
5610
+ useEffect13(() => {
5612
5611
  if (isExternal || !ctx?.store) return;
5613
5612
  if (configuredStrategy !== "viewport" && configuredStrategy !== "render" && configuredStrategy !== "adaptive") {
5614
5613
  return;
@@ -5728,7 +5727,7 @@ var Breadcrumbs = createHandle(
5728
5727
  );
5729
5728
 
5730
5729
  // src/browser/react/location-state.ts
5731
- import { useContext as useContext16, useEffect as useEffect16 } from "react";
5730
+ import { useContext as useContext17, useEffect as useEffect14 } from "react";
5732
5731
 
5733
5732
  // src/browser/react/use-href.tsx
5734
5733
  import { useCallback as useCallback6 } from "react";
@@ -5944,7 +5943,7 @@ import {
5944
5943
  useDeferredValue,
5945
5944
  useLayoutEffect as useLayoutEffect5,
5946
5945
  useMemo as useMemo10,
5947
- useState as useState13
5946
+ useState as useState10
5948
5947
  } from "react";
5949
5948
 
5950
5949
  // src/client-urls/navigation.ts
@@ -7142,7 +7141,7 @@ function resolveVersionsFrom(table, routerId2) {
7142
7141
  // package.json
7143
7142
  var package_default = {
7144
7143
  name: "@rangojs/router",
7145
- version: "0.29.0",
7144
+ version: "0.30.0",
7146
7145
  description: "Django-inspired RSC router with composable URL patterns",
7147
7146
  keywords: [
7148
7147
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -450,16 +450,12 @@ function StockPanel() {
450
450
  }
451
451
 
452
452
  function SaveStock({ sku }: { sku: string }) {
453
+ // Kept until the next save starts.
453
454
  const { revalidationError } = useAction(saveStock);
454
- const [stale, setStale] = useState(false);
455
- // useAction resets to idle once the action has settled: keep the notice.
456
- useEffect(() => {
457
- if (revalidationError) setStale(true);
458
- }, [revalidationError]);
459
455
  return (
460
456
  <form action={saveStock.bind(null, sku)}>
461
457
  <button>Save</button>
462
- {stale && (
458
+ {revalidationError != null && (
463
459
  <p role="status">Saved. The stock count could not be refreshed.</p>
464
460
  )}
465
461
  </form>
@@ -196,6 +196,14 @@ Track the lifecycle of a server action, wherever on the page it was invoked.
196
196
  If the same action fires several times, the hook reports the latest call: the
197
197
  last one to start.
198
198
 
199
+ - Once the call settles, `state` is `idle` and the rest of its state
200
+ (`result`, `error`, `revalidationError`, `payload`, `actionId`) stays until
201
+ the same action is called again: through navigations and Back/Forward, and
202
+ in a component that mounts after the call settled. Every reader of the
203
+ same action (a function or a full id) reads the same call; a string reader
204
+ follows the latest call of any action with that export name. The next
205
+ call reads `loading` with none of them, then its own. A document load
206
+ starts with nothing.
199
207
  - When the latest call returns before an earlier one, the hook shows its
200
208
  result. The earlier call settling afterwards changes nothing the hook
201
209
  reports, a result or an error alike. That call still runs to its end: its
@@ -203,8 +211,7 @@ last one to start.
203
211
  page, and an error-boundary response replaces it.
204
212
  - A latest call that was aborted reports nothing: `idle`, with no result or
205
213
  error. The hook does not fall back to an earlier call. The router aborts
206
- calls in flight when another call's response is an error boundary, and on
207
- Back/Forward into or out of an intercept.
214
+ the other calls in flight when a call's response is an error boundary.
208
215
  - `useNavigation()` stays `loading` while any call runs.
209
216
 
210
217
  Which calls count as the same action depends on what you pass:
@@ -90,9 +90,9 @@ reads (a selector, the full state): work that starts shows at once
90
90
  when the response stream ended earlier, and agrees with every other
91
91
  `useNavigation()` reader on the screen. A page that ends the hold urgently (a
92
92
  Back to a page without `transition()`, a link with `transition={false}`)
93
- shows with its own values in every `useNavigation()` reader. `usePathname()`
94
- can read the path of the page being left on that page's first screen, until
95
- React finishes the render of the page it held.
93
+ shows with its own values in every `useNavigation()` reader, and with its URL
94
+ in `usePathname()`, `useParams()`, `useSearchParams()` and `useSegments()`,
95
+ also when it is the URL React was holding.
96
96
 
97
97
  The rule holds for readers mounted before the page was handed to React. A
98
98
  reader that mounts while the page is held (a menu opened during the wait)
@@ -123,6 +123,7 @@ click or `router.push()`.
123
123
  | Back/Forward, entry fetched again | when the fetched page commits; until then the page being left is on screen |
124
124
  | a Back/Forward whose fetch fails | with the error boundary: they report the entry the browser is on |
125
125
  | `router.push(url, { revalidate: false })` | at once: nothing is fetched and the page stays |
126
+ | `transition={false}` over a held page | with that page's first screen, also on the URL React was holding |
126
127
  | inside an optimistic `clientUrls()` destination | at the click, for that branch only (see `/client-urls`) |
127
128
 
128
129
  **A newer click that fails while a page is held.** Click a link, and while
@@ -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.
@@ -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
  }
@@ -475,15 +475,15 @@ export interface TestRouterHandle {
475
475
  * await act(async () => tail.resolve());
476
476
  * // useNavigation(): idle, isStreaming false
477
477
  *
478
- * A navigation started while React holds a page goes inside
479
- * `await act(async () => ...)`. Its first update makes React retry the held
480
- * page, which suspends again; a synchronous act() scope drops that retry
481
- * and React renders no later update of the same priority.
478
+ * A navigation started while React holds a page shows its page once the
479
+ * call resolves, with or without act(). A `<Link>` click over a held page
480
+ * still goes inside `await act(async () => ...)` (docs/testing.md).
482
481
  *
483
482
  * @example
484
- * await act(async () => {
485
- * await router.navigate("/products/3", { transition: false });
486
- * });
483
+ * await router.navigate("/products/2", { loaders: [[ProductLoader, next]] });
484
+ * // React holds /products/2
485
+ * await router.navigate("/products/3", { transition: false });
486
+ * // /products/3 is on screen
487
487
  */
488
488
  navigate(
489
489
  url: string,
@@ -914,9 +914,10 @@ export async function renderRoute(
914
914
  const handle = eventController.startNavigation(
915
915
  new URL(target, TEST_ORIGIN).href,
916
916
  );
917
- // Synchronous, in the caller's task: a <Link> click's own act() scope
918
- // takes it, and no act() of the caller's can overlap it.
919
- act(() => eventController.flushRouteState());
917
+ // Runs in the caller's task, so a <Link> click's own act() takes it.
918
+ // Awaited: React retries a held page with this update, and an unawaited
919
+ // act() drops that suspended retry for good.
920
+ await act(() => eventController.flushRouteState());
920
921
  try {
921
922
  const streamUntil = navOptions?.streamUntil;
922
923
  if (streamUntil) {
@@ -927,18 +928,19 @@ export async function renderRoute(
927
928
  });
928
929
  if (handle.signal.aborted) return;
929
930
  const stream = handle.startStreaming();
930
- act(() => eventController.flushRouteState());
931
+ await act(() => eventController.flushRouteState());
931
932
  // A navigation that is left stops counting as streaming at the abort
932
933
  // (partial-update.ts endStream); the abort's notification goes out
933
934
  // with the newer navigation's.
934
935
  handle.signal.addEventListener("abort", () => stream.end(), {
935
936
  once: true,
936
937
  });
937
- const end = (): void =>
938
- act(() => {
938
+ const end = async (): Promise<void> => {
939
+ await act(() => {
939
940
  stream.end();
940
941
  eventController.flushRouteState();
941
942
  });
943
+ };
942
944
  void streamUntil.then(end, end);
943
945
  }
944
946
  await commitNavigation(handle, target, navOptions);