@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 +1 -1
- package/dist/types/browser/react/use-action.d.ts +3 -2
- package/dist/types/browser/react/use-handle.d.ts +2 -1
- package/dist/types/browser/react/use-route-state.d.ts +45 -0
- package/dist/types/browser/types.d.ts +8 -4
- package/dist/types/testing/render-route.d.ts +7 -7
- package/dist/vite/index.js +53 -54
- package/package.json +1 -1
- package/skills/client-urls/SKILL.md +2 -6
- package/skills/hooks/handle-and-actions.md +9 -2
- package/skills/hooks/navigation.md +3 -3
- package/skills/hooks/urls.md +1 -0
- package/skills/testing/client-components.md +1 -1
- package/src/browser/event-controller.ts +54 -17
- package/src/browser/react/use-action.ts +3 -2
- package/src/browser/react/use-handle.ts +6 -1
- package/src/browser/react/use-navigation.ts +16 -42
- package/src/browser/react/use-params.ts +6 -35
- package/src/browser/react/use-pathname.ts +3 -32
- package/src/browser/react/use-route-state.ts +141 -0
- package/src/browser/react/use-search-params.ts +7 -40
- package/src/browser/types.ts +8 -4
- package/src/testing/render-route.tsx +15 -13
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.
|
|
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
|
|
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
|
-
* -
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
*
|
|
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
|
|
389
|
-
*
|
|
390
|
-
*
|
|
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
|
|
395
|
-
*
|
|
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>;
|
package/dist/vite/index.js
CHANGED
|
@@ -4491,13 +4491,13 @@ import {
|
|
|
4491
4491
|
Component as Component3,
|
|
4492
4492
|
createElement as createElement4,
|
|
4493
4493
|
isValidElement as isValidElement2,
|
|
4494
|
-
useContext as
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
4799
|
+
useContext as useContext5,
|
|
4787
4800
|
useMemo
|
|
4788
4801
|
} from "react";
|
|
4789
4802
|
|
|
4790
4803
|
// src/suspense-audit-react.tsx
|
|
4791
|
-
import { useEffect as
|
|
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
|
|
4819
|
+
import { useEffect as useEffect5 } from "react";
|
|
4807
4820
|
|
|
4808
4821
|
// src/browser/react/use-router.ts
|
|
4809
|
-
import { useContext as
|
|
4822
|
+
import { useContext as useContext7, useMemo as useMemo2 } from "react";
|
|
4810
4823
|
|
|
4811
4824
|
// src/browser/react/use-mount.ts
|
|
4812
|
-
import { useContext as
|
|
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
|
|
5006
|
-
useEffect as
|
|
5018
|
+
useContext as useContext8,
|
|
5019
|
+
useEffect as useEffect6,
|
|
5007
5020
|
useMemo as useMemo4,
|
|
5008
5021
|
useOptimistic,
|
|
5009
|
-
useRef as
|
|
5010
|
-
useState as
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
5270
|
-
useEffect as
|
|
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
|
|
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
|
|
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
|
|
5285
|
+
useEffect as useEffect10,
|
|
5287
5286
|
useLayoutEffect as useLayoutEffect3,
|
|
5288
5287
|
useMemo as useMemo6,
|
|
5289
|
-
useState as
|
|
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
|
|
5324
|
-
useEffect as
|
|
5322
|
+
useContext as useContext16,
|
|
5323
|
+
useEffect as useEffect13,
|
|
5325
5324
|
useMemo as useMemo8,
|
|
5326
|
-
useRef as
|
|
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
|
|
5333
|
-
useState as
|
|
5334
|
-
useEffect as
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
@@ -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
|
-
{
|
|
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
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
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)
|
package/skills/hooks/urls.md
CHANGED
|
@@ -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) |
|
|
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
|
-
//
|
|
717
|
-
//
|
|
718
|
-
if (inflightActions.size === 0)
|
|
738
|
+
// No call tracked: the shared idle snapshot (the same reference use-action
|
|
739
|
+
// falls back to).
|
|
740
|
+
if (inflightActions.size === 0 && settledActions.size === 0) {
|
|
741
|
+
return DEFAULT_ACTION_STATE;
|
|
742
|
+
}
|
|
719
743
|
|
|
720
|
-
// The latest call this reader matches,
|
|
721
|
-
//
|
|
744
|
+
// The latest call this reader matches, in flight or settled, or none once
|
|
745
|
+
// a later call was aborted: an earlier call is never reported again.
|
|
722
746
|
const byName = !actionId.includes("#");
|
|
723
|
-
let entry:
|
|
724
|
-
for (const a of
|
|
747
|
+
let entry: TrackedCall | undefined;
|
|
748
|
+
for (const a of trackedCalls()) {
|
|
725
749
|
if (
|
|
726
750
|
matchesActionId(actionId, a.actionId) &&
|
|
727
751
|
(byName ? !a.supersededBy : a.supersededBy !== "id")
|
|
@@ -907,9 +931,9 @@ export function createEventController(
|
|
|
907
931
|
// after that point is strictly ordered behind the prior action's server
|
|
908
932
|
// execution, and there is no skipped render or order uncertainty for
|
|
909
933
|
// consolidation to repair. Completed entries still linger in the map for
|
|
910
|
-
// the 100ms doSettle window
|
|
911
|
-
//
|
|
912
|
-
//
|
|
934
|
+
// the 100ms doSettle window and, on a slow connection, while the Flight
|
|
935
|
+
// stream drains its EOF after complete(). Counting them latched
|
|
936
|
+
// hadAnyConcurrentActions for back-to-back sequential actions,
|
|
913
937
|
// which made the LAST action classify as consolidation-needed; the
|
|
914
938
|
// consolidation refetch omits every concurrently-revalidated segment id
|
|
915
939
|
// from _rsc_segments, so the server re-ran gated loaders as "new-segment"
|
|
@@ -922,13 +946,15 @@ export function createEventController(
|
|
|
922
946
|
}
|
|
923
947
|
|
|
924
948
|
// useAction reports the latest call. Every earlier call that a reader of
|
|
925
|
-
// this one also matches is hidden from that reader
|
|
926
|
-
// shows again
|
|
927
|
-
// same action from every reader, a call of another action
|
|
928
|
-
// export name from readers that match by name
|
|
949
|
+
// this one also matches, in flight or settled, is hidden from that reader
|
|
950
|
+
// for good, so it never shows again, also once this call is aborted: a
|
|
951
|
+
// call of the same action from every reader, a call of another action
|
|
952
|
+
// with the same export name from readers that match by name
|
|
953
|
+
// (matchesActionId). So at most one call matches a reader (getActionState).
|
|
929
954
|
const hash = actionId.lastIndexOf("#");
|
|
930
955
|
const name = hash === -1 ? null : actionId.slice(hash + 1);
|
|
931
|
-
|
|
956
|
+
settledActions.delete(actionId);
|
|
957
|
+
for (const a of trackedCalls()) {
|
|
932
958
|
if (a.actionId === actionId) a.supersededBy = "id";
|
|
933
959
|
else if (name !== null && matchesActionId(name, a.actionId)) {
|
|
934
960
|
a.supersededBy ??= "name";
|
|
@@ -978,9 +1004,20 @@ export function createEventController(
|
|
|
978
1004
|
if (settled) return;
|
|
979
1005
|
settled = true;
|
|
980
1006
|
|
|
981
|
-
// Cleanup after brief delay (allow useAction to read result)
|
|
982
1007
|
setTimeout(() => {
|
|
983
|
-
|
|
1008
|
+
// The action's last settled call until its next call starts, unless
|
|
1009
|
+
// that call already started.
|
|
1010
|
+
if (inflightActions.delete(id) && entry.supersededBy !== "id") {
|
|
1011
|
+
settledActions.set(actionId, {
|
|
1012
|
+
actionId: entry.actionId,
|
|
1013
|
+
phase: entry.phase,
|
|
1014
|
+
payload: entry.payload,
|
|
1015
|
+
result: entry.result,
|
|
1016
|
+
error: entry.error,
|
|
1017
|
+
revalidationError: entry.revalidationError,
|
|
1018
|
+
supersededBy: entry.supersededBy,
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
984
1021
|
// Free this cohort's arbitration once its last action has settled, so
|
|
985
1022
|
// a long-running action elsewhere cannot pin keys from a drained entry.
|
|
986
1023
|
releaseCohort();
|
|
@@ -71,11 +71,12 @@ export type ServerActionFunction = ((...args: any[]) => Promise<any>) & {
|
|
|
71
71
|
* tracks the state of individual server action invocations.
|
|
72
72
|
*
|
|
73
73
|
* Uses the event controller for reactive state management.
|
|
74
|
-
* State is derived from the
|
|
74
|
+
* State is derived from the calls the controller tracks: those in flight,
|
|
75
|
+
* and each action's last settled call.
|
|
75
76
|
*
|
|
76
77
|
* Features:
|
|
77
78
|
* - Tracks action lifecycle: idle → loading → streaming → idle
|
|
78
|
-
* -
|
|
79
|
+
* - Keeps the settled call's state until the action is called again.
|
|
79
80
|
* - If the same action fires several times, reports the latest call to
|
|
80
81
|
* start. An earlier call that settles later changes nothing it reports;
|
|
81
82
|
* a latest call that was aborted reports idle with nothing. One call at a
|
|
@@ -6,6 +6,7 @@ import { collectHandleData } from "../../handle.js";
|
|
|
6
6
|
import type { HandleData } from "../types.js";
|
|
7
7
|
import { NavigationStoreContext } from "./context.js";
|
|
8
8
|
import { shallowEqual } from "./shallow-equal.js";
|
|
9
|
+
import { useCommitted } from "./use-route-state.js";
|
|
9
10
|
import type { RouteStateSignal } from "../event-controller.js";
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -86,7 +87,8 @@ export function useHandle<T, A, S>(
|
|
|
86
87
|
* location commit with their page.
|
|
87
88
|
*
|
|
88
89
|
* A value shallow-equal to the last one sets nothing, so a reader renders only
|
|
89
|
-
* when its value moves.
|
|
90
|
+
* when its value moves. A release is compared with what React committed, as in
|
|
91
|
+
* useRouteState.
|
|
90
92
|
*/
|
|
91
93
|
export function useCommitAnchoredState<T>(
|
|
92
94
|
init: () => T,
|
|
@@ -99,12 +101,15 @@ export function useCommitAnchoredState<T>(
|
|
|
99
101
|
const ref = useRef<{ last: T; held?: boolean; waiting?: boolean }>({
|
|
100
102
|
last: value,
|
|
101
103
|
});
|
|
104
|
+
const committed = useCommitted(value);
|
|
102
105
|
const [set] = useState(() => (next: T, signal?: RouteStateSignal): void => {
|
|
103
106
|
const r = ref.current;
|
|
104
107
|
if (signal) r.waiting = signal === "anchor";
|
|
105
108
|
if (!shallowEqual(next, r.last)) {
|
|
106
109
|
r.last = next;
|
|
107
110
|
r.held = true;
|
|
111
|
+
} else if (signal === "release" && r.last !== committed.current) {
|
|
112
|
+
r.held = true;
|
|
108
113
|
}
|
|
109
114
|
if (!r.held || (r.waiting && (waitForRelease || signal !== "anchor")))
|
|
110
115
|
return;
|
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
useContext,
|
|
5
|
-
useState,
|
|
6
|
-
useEffect,
|
|
7
|
-
useLayoutEffect,
|
|
8
|
-
useRef,
|
|
9
|
-
} from "react";
|
|
3
|
+
import { useContext, useEffect, useRef } from "react";
|
|
10
4
|
import { NavigationStoreContext } from "./context.js";
|
|
11
5
|
import { shallowEqual } from "./shallow-equal.js";
|
|
6
|
+
import { useRouteState } from "./use-route-state.js";
|
|
12
7
|
import type { PublicNavigationState } from "../types.js";
|
|
13
8
|
import type {
|
|
14
9
|
DerivedNavigationState,
|
|
@@ -74,7 +69,11 @@ export function useNavigation<T>(
|
|
|
74
69
|
throw new Error("useNavigation must be used within NavigationProvider");
|
|
75
70
|
}
|
|
76
71
|
|
|
77
|
-
|
|
72
|
+
// A release is compared with the state React renders it on
|
|
73
|
+
// (useRouteState). The anchor is compared with the last set, as an
|
|
74
|
+
// ordinary notification is: it sets inside its page's transition, which
|
|
75
|
+
// renders every set so far.
|
|
76
|
+
const [snapshot, set] = useRouteState<Snapshot<T | PublicNavigationState>>(
|
|
78
77
|
() => {
|
|
79
78
|
const state = toPublicState(
|
|
80
79
|
ctx.eventController.getHydrationSnapshot()?.state ??
|
|
@@ -82,13 +81,8 @@ export function useNavigation<T>(
|
|
|
82
81
|
);
|
|
83
82
|
return { state, value: selector ? selector(state) : state };
|
|
84
83
|
},
|
|
84
|
+
(a, b) => shallowEqual(a.value, b.value),
|
|
85
85
|
);
|
|
86
|
-
// What React ends on once every set so far commits, and what it committed.
|
|
87
|
-
const latest = useRef(snapshot);
|
|
88
|
-
const committed = useRef(snapshot);
|
|
89
|
-
useLayoutEffect(() => {
|
|
90
|
-
committed.current = snapshot;
|
|
91
|
-
});
|
|
92
86
|
|
|
93
87
|
// Store selector in a ref so the subscription callback always uses the
|
|
94
88
|
// latest selector without re-subscribing on every render (inline functions
|
|
@@ -118,34 +112,14 @@ export function useNavigation<T>(
|
|
|
118
112
|
const moved = live.location !== location;
|
|
119
113
|
location = live.location;
|
|
120
114
|
const held = pageHeld && !signal;
|
|
121
|
-
|
|
122
|
-
base
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
// `latest`: an urgent page can end the hold before React commits the
|
|
130
|
-
// anchor's set. An anchor sets inside its page's transition, which
|
|
131
|
-
// renders every set so far, so `latest` is that state. A release that
|
|
132
|
-
// finds every set committed with its value sets nothing: the updater
|
|
133
|
-
// would render the reader again only to bail out.
|
|
134
|
-
if (signal === "release") {
|
|
135
|
-
if (
|
|
136
|
-
latest.current === committed.current &&
|
|
137
|
-
shallowEqual(target.value, latest.current.value)
|
|
138
|
-
)
|
|
139
|
-
return;
|
|
140
|
-
latest.current = target;
|
|
141
|
-
setSnapshot((prev) =>
|
|
142
|
-
shallowEqual(prev.value, target.value) ? prev : target,
|
|
143
|
-
);
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
if (shallowEqual(target.value, latest.current.value)) return;
|
|
147
|
-
latest.current = target;
|
|
148
|
-
setSnapshot(held ? next : target);
|
|
115
|
+
set(
|
|
116
|
+
(base) => {
|
|
117
|
+
const state = held ? hold(base.state, live, moved) : live;
|
|
118
|
+
return { state, value: select(state) };
|
|
119
|
+
},
|
|
120
|
+
signal,
|
|
121
|
+
held,
|
|
122
|
+
);
|
|
149
123
|
};
|
|
150
124
|
|
|
151
125
|
// Catch-up: re-read state synchronously on mount before subscribing, so a
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useContext
|
|
4
|
-
import { NavigationStoreContext } from "./context.js";
|
|
3
|
+
import { useContext } from "react";
|
|
5
4
|
import { shallowEqual } from "./shallow-equal.js";
|
|
5
|
+
import { useUrlValue } from "./use-route-state.js";
|
|
6
6
|
import { OptimisticLocationContext } from "../../client-urls/optimistic-location.js";
|
|
7
7
|
|
|
8
|
-
const EMPTY_PARAMS: Record<string, string> = Object.freeze({});
|
|
9
|
-
|
|
10
8
|
/**
|
|
11
9
|
* Hook to access the current route params.
|
|
12
10
|
*
|
|
@@ -45,38 +43,11 @@ export function useParams<T>(
|
|
|
45
43
|
export function useParams<T>(
|
|
46
44
|
selector?: (params: Record<string, string | undefined>) => T,
|
|
47
45
|
): T | Record<string, string | undefined> {
|
|
48
|
-
const ctx = useContext(NavigationStoreContext);
|
|
49
46
|
const optimistic = useContext(OptimisticLocationContext);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
ctx.eventController.getParams())
|
|
55
|
-
: EMPTY_PARAMS;
|
|
56
|
-
return selector ? selector(params) : params;
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
const prevValue = useRef(value);
|
|
60
|
-
const selectorRef = useRef(selector);
|
|
61
|
-
selectorRef.current = selector;
|
|
62
|
-
|
|
63
|
-
useEffect(() => {
|
|
64
|
-
if (!ctx) return;
|
|
65
|
-
|
|
66
|
-
const update = () => {
|
|
67
|
-
const params = ctx.eventController.getParams();
|
|
68
|
-
const next = selectorRef.current ? selectorRef.current(params) : params;
|
|
69
|
-
|
|
70
|
-
if (!shallowEqual(next, prevValue.current)) {
|
|
71
|
-
prevValue.current = next;
|
|
72
|
-
setValue(next);
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
update();
|
|
77
|
-
|
|
78
|
-
return ctx.eventController.subscribe(update);
|
|
79
|
-
}, []);
|
|
47
|
+
const value = useUrlValue<T | Record<string, string>>(
|
|
48
|
+
({ params }) => (selector ? selector(params) : params),
|
|
49
|
+
shallowEqual,
|
|
50
|
+
);
|
|
80
51
|
|
|
81
52
|
if (optimistic) {
|
|
82
53
|
return selector ? selector(optimistic.params) : optimistic.params;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useContext
|
|
4
|
-
import {
|
|
3
|
+
import { useContext } from "react";
|
|
4
|
+
import { useUrlValue } from "./use-route-state.js";
|
|
5
5
|
import { OptimisticLocationContext } from "../../client-urls/optimistic-location.js";
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -19,36 +19,7 @@ import { OptimisticLocationContext } from "../../client-urls/optimistic-location
|
|
|
19
19
|
* ```
|
|
20
20
|
*/
|
|
21
21
|
export function usePathname(): string {
|
|
22
|
-
const ctx = useContext(NavigationStoreContext);
|
|
23
22
|
const optimistic = useContext(OptimisticLocationContext);
|
|
24
|
-
|
|
25
|
-
const [pathname, setPathname] = useState<string>(() => {
|
|
26
|
-
if (!ctx) {
|
|
27
|
-
return "/";
|
|
28
|
-
}
|
|
29
|
-
const state =
|
|
30
|
-
ctx.eventController.getHydrationSnapshot()?.state ??
|
|
31
|
-
ctx.eventController.getState();
|
|
32
|
-
return (state.location as URL).pathname;
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
const prevPathname = useRef(pathname);
|
|
36
|
-
|
|
37
|
-
useEffect(() => {
|
|
38
|
-
if (!ctx) return;
|
|
39
|
-
|
|
40
|
-
const update = () => {
|
|
41
|
-
const next = (ctx.eventController.getState().location as URL).pathname;
|
|
42
|
-
if (next !== prevPathname.current) {
|
|
43
|
-
prevPathname.current = next;
|
|
44
|
-
setPathname(next);
|
|
45
|
-
}
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
update();
|
|
49
|
-
|
|
50
|
-
return ctx.eventController.subscribe(update);
|
|
51
|
-
}, []);
|
|
52
|
-
|
|
23
|
+
const pathname = useUrlValue((route) => route.location.pathname, Object.is);
|
|
53
24
|
return optimistic ? optimistic.pathname : pathname;
|
|
54
25
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { NavigationStoreContext } from "./context.js";
|
|
11
|
+
import type { EventController, RouteStateSignal } from "../event-controller.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The value a reader rendered in React's last commit of it, written by its
|
|
15
|
+
* layout effect. A page's release runs in the provider's layout effect, after
|
|
16
|
+
* every reader's (children first), so it reads the commit that brings the
|
|
17
|
+
* page.
|
|
18
|
+
*/
|
|
19
|
+
export function useCommitted<T>(value: T): { readonly current: T } {
|
|
20
|
+
const committed = useRef(value);
|
|
21
|
+
useLayoutEffect(() => {
|
|
22
|
+
committed.current = value;
|
|
23
|
+
}, [value]);
|
|
24
|
+
return committed;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Sets a route-state reader's value. `next` maps the value React ends on once
|
|
29
|
+
* every set so far commits (`latest`) to the reader's value; `rebase` sets
|
|
30
|
+
* `next` itself, so React recomputes it on whatever base it renders. The value
|
|
31
|
+
* must not be a function.
|
|
32
|
+
*/
|
|
33
|
+
export type RouteStateSetter<T> = (
|
|
34
|
+
next: (base: T) => T,
|
|
35
|
+
signal?: RouteStateSignal,
|
|
36
|
+
rebase?: boolean,
|
|
37
|
+
) => void;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Route state a reader sets as the router store notifies it, in the lane of
|
|
41
|
+
* the notification that carries it. A notification equal to `latest` sets
|
|
42
|
+
* nothing, so a reader renders only when its value moves.
|
|
43
|
+
*
|
|
44
|
+
* A release is compared with what React committed, not with `latest`: while
|
|
45
|
+
* React holds a page, its set is not on screen, and an urgent page that ends
|
|
46
|
+
* the hold brings an equal value. A release that finds every set committed
|
|
47
|
+
* with its value sets nothing.
|
|
48
|
+
*/
|
|
49
|
+
export function useRouteState<T>(
|
|
50
|
+
init: () => T,
|
|
51
|
+
same: (a: T, b: T) => boolean,
|
|
52
|
+
): [T, RouteStateSetter<T>] {
|
|
53
|
+
const [value, setValue] = useState(init);
|
|
54
|
+
const latest = useRef(value);
|
|
55
|
+
const committed = useCommitted(value);
|
|
56
|
+
const [set] = useState(
|
|
57
|
+
(): RouteStateSetter<T> => (next, signal, rebase) => {
|
|
58
|
+
const target = next(latest.current);
|
|
59
|
+
if (signal === "release") {
|
|
60
|
+
if (
|
|
61
|
+
latest.current === committed.current &&
|
|
62
|
+
same(target, latest.current)
|
|
63
|
+
)
|
|
64
|
+
return;
|
|
65
|
+
latest.current = target;
|
|
66
|
+
setValue((prev) => (same(prev, target) ? prev : target));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (same(target, latest.current)) return;
|
|
70
|
+
latest.current = target;
|
|
71
|
+
setValue(rebase ? next : target);
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
return [value, set];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The route a URL hook reads. */
|
|
78
|
+
export interface UrlRoute {
|
|
79
|
+
location: URL;
|
|
80
|
+
params: Record<string, string>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const NO_ROUTE: UrlRoute = {
|
|
84
|
+
location: new URL("/", "http://localhost"),
|
|
85
|
+
params: Object.freeze({}) as Record<string, string>,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const routeOf = (controller: EventController): UrlRoute => ({
|
|
89
|
+
location: controller.getState().location,
|
|
90
|
+
params: controller.getParams(),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A URL hook's value (usePathname, useParams, useSearchParams): `read` of the
|
|
95
|
+
* route, seeded from the hydration snapshot while the document hydrates, then
|
|
96
|
+
* set as the store notifies it and at a page's release (useRouteState). The
|
|
97
|
+
* release rides the handle channel. `read` applies when the store changes,
|
|
98
|
+
* never in a later render: a new one takes effect from the next change.
|
|
99
|
+
*
|
|
100
|
+
* A read equal to the value it is set over keeps that value, so an object
|
|
101
|
+
* reader changes identity once per value: a release that ends a hold sets the
|
|
102
|
+
* held page's object, which React's render of the held lane keeps.
|
|
103
|
+
*/
|
|
104
|
+
export function useUrlValue<T>(
|
|
105
|
+
read: (route: UrlRoute) => T,
|
|
106
|
+
same: (a: T, b: T) => boolean,
|
|
107
|
+
): T {
|
|
108
|
+
const ctx = useContext(NavigationStoreContext);
|
|
109
|
+
const [value, set] = useRouteState(() => {
|
|
110
|
+
if (!ctx) return read(NO_ROUTE);
|
|
111
|
+
const hydration = ctx.eventController.getHydrationSnapshot();
|
|
112
|
+
return read(
|
|
113
|
+
hydration
|
|
114
|
+
? { location: hydration.state.location, params: hydration.params }
|
|
115
|
+
: routeOf(ctx.eventController),
|
|
116
|
+
);
|
|
117
|
+
}, same);
|
|
118
|
+
const readRef = useRef(read);
|
|
119
|
+
readRef.current = read;
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (!ctx) return;
|
|
123
|
+
const controller = ctx.eventController;
|
|
124
|
+
const update = (signal?: RouteStateSignal): void => {
|
|
125
|
+
const next = readRef.current(routeOf(controller));
|
|
126
|
+
set((base) => (same(next, base) ? base : next), signal);
|
|
127
|
+
};
|
|
128
|
+
// Catch up with a change between the seeding render and this effect.
|
|
129
|
+
update();
|
|
130
|
+
const unsubscribe = controller.subscribe(update);
|
|
131
|
+
const unsubscribeRelease = controller.subscribeToHandles((signal) => {
|
|
132
|
+
if (signal === "release") update(signal);
|
|
133
|
+
});
|
|
134
|
+
return () => {
|
|
135
|
+
unsubscribe();
|
|
136
|
+
unsubscribeRelease();
|
|
137
|
+
};
|
|
138
|
+
}, []);
|
|
139
|
+
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
useCallback,
|
|
5
|
-
useContext,
|
|
6
|
-
useEffect,
|
|
7
|
-
useMemo,
|
|
8
|
-
useRef,
|
|
9
|
-
useState,
|
|
10
|
-
} from "react";
|
|
3
|
+
import { useCallback, useContext, useMemo } from "react";
|
|
11
4
|
import { NavigationStoreContext } from "./context.js";
|
|
5
|
+
import { useUrlValue } from "./use-route-state.js";
|
|
12
6
|
import type { ReadonlyURLSearchParams } from "../types.js";
|
|
13
7
|
import { OptimisticLocationContext } from "../../client-urls/optimistic-location.js";
|
|
14
8
|
|
|
@@ -113,38 +107,11 @@ export function useSearchParams(): [ReadonlyURLSearchParams, SetSearchParams] {
|
|
|
113
107
|
const ctx = useContext(NavigationStoreContext);
|
|
114
108
|
const optimistic = useContext(OptimisticLocationContext);
|
|
115
109
|
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
useState<ReadonlyURLSearchParams>(() => {
|
|
122
|
-
const state = ctx
|
|
123
|
-
? (ctx.eventController.getHydrationSnapshot()?.state ??
|
|
124
|
-
ctx.eventController.getState())
|
|
125
|
-
: undefined;
|
|
126
|
-
const location = state?.location as URL | undefined;
|
|
127
|
-
return new URLSearchParams(location?.searchParams);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
const prevSearch = useRef(searchParams.toString());
|
|
131
|
-
|
|
132
|
-
useEffect(() => {
|
|
133
|
-
if (!ctx) return;
|
|
134
|
-
|
|
135
|
-
const update = () => {
|
|
136
|
-
const location = ctx.eventController.getState().location as URL;
|
|
137
|
-
const nextSearch = location.searchParams.toString();
|
|
138
|
-
if (nextSearch !== prevSearch.current) {
|
|
139
|
-
prevSearch.current = nextSearch;
|
|
140
|
-
setSearchParamsState(new URLSearchParams(nextSearch));
|
|
141
|
-
}
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
update();
|
|
145
|
-
|
|
146
|
-
return ctx.eventController.subscribe(update);
|
|
147
|
-
}, []);
|
|
110
|
+
// A new object only when the string changes.
|
|
111
|
+
const searchParams = useUrlValue<ReadonlyURLSearchParams>(
|
|
112
|
+
(route) => new URLSearchParams(route.location.searchParams),
|
|
113
|
+
(a, b) => a.toString() === b.toString(),
|
|
114
|
+
);
|
|
148
115
|
|
|
149
116
|
// Reads the committed location at CALL time (not the render closure), so a
|
|
150
117
|
// functional init always merges against fresh params even when the caller's
|
package/src/browser/types.ts
CHANGED
|
@@ -253,7 +253,11 @@ export type ActionLifecycleState = "idle" | "loading" | "streaming";
|
|
|
253
253
|
* @internal This type is an implementation detail and may change without notice.
|
|
254
254
|
*/
|
|
255
255
|
export interface TrackedActionState {
|
|
256
|
-
/**
|
|
256
|
+
/**
|
|
257
|
+
* Current lifecycle state of the action. Once the call settles it is
|
|
258
|
+
* "idle" and the rest of the call's state is kept until the action's next
|
|
259
|
+
* call.
|
|
260
|
+
*/
|
|
257
261
|
state: ActionLifecycleState;
|
|
258
262
|
|
|
259
263
|
/** Server action function ID (e.g., "addToCart") */
|
|
@@ -265,13 +269,13 @@ export interface TrackedActionState {
|
|
|
265
269
|
/** Error if action failed */
|
|
266
270
|
error: unknown | null;
|
|
267
271
|
|
|
268
|
-
/** Result data from the action
|
|
272
|
+
/** Result data from the action */
|
|
269
273
|
result: unknown | null;
|
|
270
274
|
|
|
271
275
|
/**
|
|
272
276
|
* The error of a loader that threw while it re-ran after this successful
|
|
273
|
-
* action (the first, when several did). The loader also throws at its
|
|
274
|
-
*
|
|
277
|
+
* action (the first, when several did). The loader also throws at its useLoader read, where the nearest
|
|
278
|
+
* error boundary catches it.
|
|
275
279
|
*/
|
|
276
280
|
revalidationError: unknown | null;
|
|
277
281
|
}
|
|
@@ -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
|
|
479
|
-
*
|
|
480
|
-
*
|
|
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
|
|
485
|
-
*
|
|
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
|
-
//
|
|
918
|
-
//
|
|
919
|
-
act(
|
|
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);
|