@rangojs/router 0.0.0-experimental.259 → 0.0.0-experimental.26
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/README.md +294 -28
- package/dist/bin/rango.js +355 -47
- package/dist/vite/index.js +1658 -1239
- package/package.json +3 -3
- package/skills/cache-guide/SKILL.md +9 -5
- package/skills/caching/SKILL.md +4 -4
- package/skills/document-cache/SKILL.md +2 -2
- package/skills/hooks/SKILL.md +40 -29
- package/skills/host-router/SKILL.md +218 -0
- package/skills/intercept/SKILL.md +79 -0
- package/skills/layout/SKILL.md +62 -2
- package/skills/loader/SKILL.md +229 -15
- package/skills/middleware/SKILL.md +109 -30
- package/skills/parallel/SKILL.md +57 -2
- package/skills/prerender/SKILL.md +189 -19
- package/skills/rango/SKILL.md +1 -2
- package/skills/response-routes/SKILL.md +3 -3
- package/skills/route/SKILL.md +44 -3
- package/skills/router-setup/SKILL.md +80 -3
- package/skills/theme/SKILL.md +5 -4
- package/skills/typesafety/SKILL.md +59 -16
- package/skills/use-cache/SKILL.md +16 -2
- package/src/__internal.ts +1 -1
- package/src/bin/rango.ts +56 -19
- package/src/browser/action-coordinator.ts +97 -0
- package/src/browser/event-controller.ts +29 -48
- package/src/browser/history-state.ts +80 -0
- package/src/browser/intercept-utils.ts +1 -1
- package/src/browser/link-interceptor.ts +19 -3
- package/src/browser/merge-segment-loaders.ts +9 -2
- package/src/browser/navigation-bridge.ts +66 -443
- package/src/browser/navigation-client.ts +34 -62
- package/src/browser/navigation-store.ts +4 -33
- package/src/browser/navigation-transaction.ts +295 -0
- package/src/browser/partial-update.ts +103 -151
- package/src/browser/prefetch/cache.ts +67 -0
- package/src/browser/prefetch/fetch.ts +137 -0
- package/src/browser/prefetch/observer.ts +65 -0
- package/src/browser/prefetch/policy.ts +42 -0
- package/src/browser/prefetch/queue.ts +88 -0
- package/src/browser/rango-state.ts +112 -0
- package/src/browser/react/Link.tsx +154 -44
- package/src/browser/react/NavigationProvider.tsx +32 -0
- package/src/browser/react/context.ts +6 -0
- package/src/browser/react/filter-segment-order.ts +11 -0
- package/src/browser/react/index.ts +2 -6
- package/src/browser/react/location-state-shared.ts +29 -11
- package/src/browser/react/location-state.ts +6 -4
- package/src/browser/react/nonce-context.ts +23 -0
- package/src/browser/react/shallow-equal.ts +27 -0
- package/src/browser/react/use-action.ts +23 -45
- package/src/browser/react/use-client-cache.ts +5 -3
- package/src/browser/react/use-handle.ts +21 -64
- package/src/browser/react/use-navigation.ts +7 -32
- package/src/browser/react/use-params.ts +5 -34
- package/src/browser/react/use-pathname.ts +2 -3
- package/src/browser/react/use-router.ts +3 -6
- package/src/browser/react/use-search-params.ts +2 -1
- package/src/browser/react/use-segments.ts +75 -114
- package/src/browser/response-adapter.ts +73 -0
- package/src/browser/rsc-router.tsx +46 -22
- package/src/browser/scroll-restoration.ts +10 -7
- package/src/browser/server-action-bridge.ts +458 -405
- package/src/browser/types.ts +21 -35
- package/src/browser/validate-redirect-origin.ts +29 -0
- package/src/build/generate-manifest.ts +38 -13
- package/src/build/generate-route-types.ts +4 -0
- package/src/build/index.ts +1 -0
- package/src/build/route-trie.ts +19 -3
- package/src/build/route-types/codegen.ts +13 -4
- package/src/build/route-types/include-resolution.ts +13 -0
- package/src/build/route-types/per-module-writer.ts +15 -3
- package/src/build/route-types/router-processing.ts +170 -18
- package/src/build/runtime-discovery.ts +13 -1
- package/src/cache/background-task.ts +34 -0
- package/src/cache/cache-key-utils.ts +44 -0
- package/src/cache/cache-policy.ts +125 -0
- package/src/cache/cache-runtime.ts +136 -123
- package/src/cache/cache-scope.ts +76 -83
- package/src/cache/cf/cf-cache-store.ts +12 -7
- package/src/cache/document-cache.ts +93 -69
- package/src/cache/handle-capture.ts +81 -0
- package/src/cache/index.ts +0 -15
- package/src/cache/memory-segment-store.ts +43 -69
- package/src/cache/profile-registry.ts +43 -8
- package/src/cache/read-through-swr.ts +134 -0
- package/src/cache/segment-codec.ts +140 -117
- package/src/cache/taint.ts +30 -3
- package/src/cache/types.ts +1 -115
- package/src/client.rsc.tsx +0 -1
- package/src/client.tsx +53 -76
- package/src/errors.ts +6 -1
- package/src/handle.ts +1 -1
- package/src/handles/MetaTags.tsx +5 -2
- package/src/host/cookie-handler.ts +8 -3
- package/src/host/index.ts +0 -3
- package/src/host/router.ts +14 -1
- package/src/href-client.ts +3 -1
- package/src/index.rsc.ts +53 -10
- package/src/index.ts +73 -43
- package/src/loader.rsc.ts +12 -4
- package/src/loader.ts +8 -0
- package/src/prerender/store.ts +60 -18
- package/src/prerender.ts +76 -18
- package/src/reverse.ts +11 -7
- package/src/root-error-boundary.tsx +30 -26
- package/src/route-definition/dsl-helpers.ts +9 -6
- package/src/route-definition/index.ts +0 -3
- package/src/route-definition/redirect.ts +15 -3
- package/src/route-map-builder.ts +38 -2
- package/src/route-name.ts +53 -0
- package/src/route-types.ts +7 -0
- package/src/router/content-negotiation.ts +1 -1
- package/src/router/debug-manifest.ts +16 -3
- package/src/router/handler-context.ts +96 -17
- package/src/router/intercept-resolution.ts +6 -4
- package/src/router/lazy-includes.ts +4 -0
- package/src/router/loader-resolution.ts +6 -11
- package/src/router/logging.ts +100 -3
- package/src/router/manifest.ts +32 -3
- package/src/router/match-api.ts +62 -54
- package/src/router/match-context.ts +3 -0
- package/src/router/match-handlers.ts +185 -11
- package/src/router/match-middleware/background-revalidation.ts +65 -85
- package/src/router/match-middleware/cache-lookup.ts +78 -10
- package/src/router/match-middleware/cache-store.ts +2 -0
- package/src/router/match-pipelines.ts +8 -43
- package/src/router/match-result.ts +0 -9
- package/src/router/metrics.ts +233 -13
- package/src/router/middleware-types.ts +34 -39
- package/src/router/middleware.ts +290 -130
- package/src/router/pattern-matching.ts +61 -10
- package/src/router/prerender-match.ts +36 -6
- package/src/router/preview-match.ts +7 -1
- package/src/router/revalidation.ts +61 -2
- package/src/router/router-context.ts +15 -0
- package/src/router/router-interfaces.ts +158 -40
- package/src/router/router-options.ts +223 -1
- package/src/router/router-registry.ts +5 -2
- package/src/router/segment-resolution/fresh.ts +165 -242
- package/src/router/segment-resolution/helpers.ts +263 -0
- package/src/router/segment-resolution/loader-cache.ts +102 -98
- package/src/router/segment-resolution/revalidation.ts +394 -272
- package/src/router/segment-resolution/static-store.ts +2 -2
- package/src/router/segment-resolution.ts +1 -3
- package/src/router/segment-wrappers.ts +3 -0
- package/src/router/telemetry-otel.ts +299 -0
- package/src/router/telemetry.ts +300 -0
- package/src/router/timeout.ts +148 -0
- package/src/router/trie-matching.ts +20 -2
- package/src/router/types.ts +7 -1
- package/src/router.ts +203 -18
- package/src/rsc/handler-context.ts +13 -2
- package/src/rsc/handler.ts +489 -438
- package/src/rsc/helpers.ts +125 -5
- package/src/rsc/index.ts +0 -20
- package/src/rsc/loader-fetch.ts +84 -42
- package/src/rsc/manifest-init.ts +3 -2
- package/src/rsc/origin-guard.ts +141 -0
- package/src/rsc/progressive-enhancement.ts +245 -19
- package/src/rsc/response-route-handler.ts +347 -0
- package/src/rsc/rsc-rendering.ts +47 -43
- package/src/rsc/runtime-warnings.ts +42 -0
- package/src/rsc/server-action.ts +166 -66
- package/src/rsc/ssr-setup.ts +128 -0
- package/src/rsc/types.ts +20 -2
- package/src/search-params.ts +38 -23
- package/src/server/context.ts +61 -7
- package/src/server/cookie-store.ts +190 -0
- package/src/server/fetchable-loader-store.ts +11 -6
- package/src/server/handle-store.ts +84 -12
- package/src/server/loader-registry.ts +11 -46
- package/src/server/request-context.ts +275 -49
- package/src/server.ts +6 -0
- package/src/ssr/index.tsx +67 -28
- package/src/static-handler.ts +7 -0
- package/src/theme/ThemeProvider.tsx +6 -1
- package/src/theme/index.ts +4 -18
- package/src/theme/theme-context.ts +1 -28
- package/src/theme/theme-script.ts +2 -1
- package/src/types/cache-types.ts +6 -1
- package/src/types/error-types.ts +3 -0
- package/src/types/global-namespace.ts +22 -0
- package/src/types/handler-context.ts +103 -16
- package/src/types/index.ts +1 -1
- package/src/types/loader-types.ts +9 -6
- package/src/types/route-config.ts +17 -26
- package/src/types/route-entry.ts +28 -0
- package/src/types/segments.ts +0 -5
- package/src/urls/include-helper.ts +49 -8
- package/src/urls/index.ts +1 -0
- package/src/urls/path-helper-types.ts +30 -12
- package/src/urls/path-helper.ts +17 -2
- package/src/urls/pattern-types.ts +21 -1
- package/src/urls/response-types.ts +29 -7
- package/src/urls/type-extraction.ts +23 -15
- package/src/use-loader.tsx +27 -9
- package/src/vite/discovery/bundle-postprocess.ts +32 -52
- package/src/vite/discovery/discover-routers.ts +52 -26
- package/src/vite/discovery/prerender-collection.ts +58 -41
- package/src/vite/discovery/route-types-writer.ts +7 -7
- package/src/vite/discovery/state.ts +7 -7
- package/src/vite/discovery/virtual-module-codegen.ts +5 -2
- package/src/vite/index.ts +10 -51
- package/src/vite/plugins/client-ref-dedup.ts +115 -0
- package/src/vite/plugins/client-ref-hashing.ts +3 -3
- package/src/vite/plugins/expose-internal-ids.ts +4 -3
- package/src/vite/plugins/refresh-cmd.ts +65 -0
- package/src/vite/plugins/use-cache-transform.ts +91 -3
- package/src/vite/plugins/version-plugin.ts +188 -18
- package/src/vite/rango.ts +61 -36
- package/src/vite/router-discovery.ts +173 -100
- package/src/vite/utils/prerender-utils.ts +81 -0
- package/src/vite/utils/shared-utils.ts +19 -9
- package/skills/testing/SKILL.md +0 -226
- package/src/browser/lru-cache.ts +0 -61
- package/src/browser/react/prefetch.ts +0 -27
- package/src/browser/request-controller.ts +0 -164
- package/src/cache/memory-store.ts +0 -253
- package/src/href-context.ts +0 -33
- package/src/route-definition/route-function.ts +0 -119
- package/src/router.gen.ts +0 -6
- package/src/static-handler.gen.ts +0 -5
- package/src/urls.gen.ts +0 -8
- /package/{CLAUDE.md → AGENTS.md} +0 -0
|
@@ -4,40 +4,58 @@ import React, {
|
|
|
4
4
|
forwardRef,
|
|
5
5
|
useCallback,
|
|
6
6
|
useContext,
|
|
7
|
+
useEffect,
|
|
7
8
|
useRef,
|
|
8
9
|
type ForwardRefExoticComponent,
|
|
9
10
|
type RefAttributes,
|
|
10
11
|
} from "react";
|
|
11
12
|
import { NavigationStoreContext } from "./context.js";
|
|
12
13
|
import { LinkContext } from "./use-link-status.js";
|
|
13
|
-
import { prefetchUrl } from "./prefetch.js";
|
|
14
14
|
import type { NavigateOptions } from "../types.js";
|
|
15
|
+
import { isHashOnlyNavigation } from "../link-interceptor.js";
|
|
15
16
|
import {
|
|
16
|
-
type LocationStateEntry,
|
|
17
17
|
isLocationStateEntry,
|
|
18
|
+
type LocationStateEntry,
|
|
18
19
|
resolveLocationStateEntries,
|
|
19
20
|
} from "./location-state.js";
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
|
-
* State
|
|
23
|
+
* State prop type for Link component.
|
|
24
|
+
* - LocationStateEntry[]: Type-safe state entries via createLocationState()
|
|
25
|
+
* - StateOrGetter: Plain state object or click-time getter function
|
|
26
|
+
* - Record<string, unknown>: Plain state object passed to history.pushState
|
|
23
27
|
*/
|
|
24
28
|
export type StateOrGetter<T = unknown> = T | (() => T);
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
export type LinkState =
|
|
31
|
+
| LocationStateEntry[]
|
|
32
|
+
| StateOrGetter<Record<string, unknown>>;
|
|
33
|
+
|
|
34
|
+
import { prefetchDirect, prefetchQueued } from "../prefetch/fetch.js";
|
|
35
|
+
import {
|
|
36
|
+
observeForPrefetch,
|
|
37
|
+
unobserveForPrefetch,
|
|
38
|
+
} from "../prefetch/observer.js";
|
|
39
|
+
|
|
40
|
+
// Touch device detection for adaptive strategy.
|
|
41
|
+
// Checked once at module load (Link.tsx is "use client", runs only in browser).
|
|
42
|
+
const isTouchDevice =
|
|
43
|
+
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
|
|
32
44
|
|
|
33
45
|
/**
|
|
34
46
|
* Prefetch strategy for the Link component
|
|
35
|
-
* - "hover": Prefetch on mouse enter (
|
|
36
|
-
* - "viewport": Prefetch when link enters viewport (
|
|
37
|
-
* - "
|
|
47
|
+
* - "hover": Prefetch on mouse enter (direct, no queue)
|
|
48
|
+
* - "viewport": Prefetch when link enters viewport (queued, waits for idle)
|
|
49
|
+
* - "render": Prefetch on component mount regardless of visibility (queued, waits for idle)
|
|
50
|
+
* - "adaptive": Hover on pointer devices, viewport on touch devices
|
|
38
51
|
* - "none": No prefetching (default)
|
|
39
52
|
*/
|
|
40
|
-
export type PrefetchStrategy =
|
|
53
|
+
export type PrefetchStrategy =
|
|
54
|
+
| "hover"
|
|
55
|
+
| "viewport"
|
|
56
|
+
| "render"
|
|
57
|
+
| "adaptive"
|
|
58
|
+
| "none";
|
|
41
59
|
|
|
42
60
|
/**
|
|
43
61
|
* Link component props
|
|
@@ -74,16 +92,29 @@ export interface LinkProps extends Omit<
|
|
|
74
92
|
* @example
|
|
75
93
|
* ```tsx
|
|
76
94
|
* // Type-safe state with createLocationState (recommended)
|
|
77
|
-
* const ProductState = createLocationState
|
|
78
|
-
* <Link to="/product" state={[ProductState(product)]}>
|
|
95
|
+
* const ProductState = createLocationState<{ name: string; price: number }>();
|
|
96
|
+
* <Link to="/product" state={[ProductState({ name: product.name, price: product.price })]}>
|
|
97
|
+
* View
|
|
98
|
+
* </Link>
|
|
99
|
+
*
|
|
100
|
+
* // Type-safe just-in-time state (getter called at click time, not render time).
|
|
101
|
+
* // Must be in a client component -- getter can't cross the RSC boundary.
|
|
102
|
+
* <Link
|
|
103
|
+
* to="/product"
|
|
104
|
+
* state={[ProductState(() => ({ name: product.name, price: product.price }))]}
|
|
105
|
+
* >
|
|
106
|
+
* View
|
|
107
|
+
* </Link>
|
|
79
108
|
*
|
|
80
109
|
* // Multiple typed states
|
|
81
|
-
* <Link to="/checkout" state={[ProductState(p), CartState(c)]}>
|
|
110
|
+
* <Link to="/checkout" state={[ProductState({ name: p.name, price: p.price }), CartState(c)]}>
|
|
111
|
+
* Checkout
|
|
112
|
+
* </Link>
|
|
82
113
|
*
|
|
83
|
-
* //
|
|
114
|
+
* // Plain static state
|
|
84
115
|
* <Link to="/product" state={{ from: "list" }}>View</Link>
|
|
85
116
|
*
|
|
86
|
-
* //
|
|
117
|
+
* // Plain just-in-time state (called at click time, requires client component)
|
|
87
118
|
* <Link to="/product" state={() => ({ scrollY: window.scrollY })}>View</Link>
|
|
88
119
|
* ```
|
|
89
120
|
*/
|
|
@@ -150,6 +181,25 @@ export const Link: ForwardRefExoticComponent<
|
|
|
150
181
|
const ctx = useContext(NavigationStoreContext);
|
|
151
182
|
const isExternal = isExternalUrl(to);
|
|
152
183
|
|
|
184
|
+
// Resolve adaptive: viewport on touch devices, hover on pointer devices
|
|
185
|
+
const resolvedStrategy =
|
|
186
|
+
prefetch === "adaptive" ? (isTouchDevice ? "viewport" : "hover") : prefetch;
|
|
187
|
+
|
|
188
|
+
// Internal ref for viewport observation; merge with forwarded ref
|
|
189
|
+
const internalRef = useRef<HTMLAnchorElement | null>(null);
|
|
190
|
+
const setRef = useCallback(
|
|
191
|
+
(node: HTMLAnchorElement | null) => {
|
|
192
|
+
internalRef.current = node;
|
|
193
|
+
if (typeof ref === "function") {
|
|
194
|
+
ref(node);
|
|
195
|
+
} else if (ref) {
|
|
196
|
+
(ref as React.MutableRefObject<HTMLAnchorElement | null>).current =
|
|
197
|
+
node;
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
[ref],
|
|
201
|
+
);
|
|
202
|
+
|
|
153
203
|
// Use ref to always get the latest state/getter without adding to useCallback deps
|
|
154
204
|
// This enables just-in-time state resolution without causing re-renders
|
|
155
205
|
const stateRef = useRef(state);
|
|
@@ -180,49 +230,109 @@ export const Link: ForwardRefExoticComponent<
|
|
|
180
230
|
const target = (e.currentTarget as HTMLAnchorElement).target;
|
|
181
231
|
if (target && target !== "_self") return;
|
|
182
232
|
|
|
233
|
+
// Hash-only navigation: let the browser handle anchor scrolling natively.
|
|
234
|
+
if (isHashOnlyNavigation(e.currentTarget as HTMLAnchorElement)) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// No navigation context (outside provider): fall back to native navigation.
|
|
239
|
+
if (!ctx?.navigate) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
183
243
|
// Prevent default and use SPA navigation
|
|
184
244
|
e.preventDefault();
|
|
185
245
|
// Stop propagation to prevent link-interceptor from also handling this
|
|
186
246
|
e.stopPropagation();
|
|
187
247
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
let resolvedState: unknown;
|
|
191
|
-
const currentState = stateRef.current;
|
|
192
|
-
|
|
193
|
-
if (
|
|
194
|
-
Array.isArray(currentState) &&
|
|
195
|
-
currentState.length > 0 &&
|
|
196
|
-
isLocationStateEntry(currentState[0])
|
|
197
|
-
) {
|
|
198
|
-
// Type-safe LocationStateEntry[] - resolve each entry into keyed object
|
|
199
|
-
resolvedState = resolveLocationStateEntries(
|
|
200
|
-
currentState as LocationStateEntry[],
|
|
201
|
-
);
|
|
202
|
-
} else if (typeof currentState === "function") {
|
|
203
|
-
// Legacy getter function
|
|
204
|
-
resolvedState = currentState();
|
|
205
|
-
} else {
|
|
206
|
-
// Legacy static value
|
|
207
|
-
resolvedState = currentState;
|
|
208
|
-
}
|
|
248
|
+
const currentState = stateRef.current;
|
|
249
|
+
let resolvedState: unknown;
|
|
209
250
|
|
|
210
|
-
|
|
251
|
+
if (
|
|
252
|
+
Array.isArray(currentState) &&
|
|
253
|
+
currentState.length > 0 &&
|
|
254
|
+
isLocationStateEntry(currentState[0])
|
|
255
|
+
) {
|
|
256
|
+
resolvedState = resolveLocationStateEntries(
|
|
257
|
+
currentState as LocationStateEntry[],
|
|
258
|
+
);
|
|
259
|
+
} else if (typeof currentState === "function") {
|
|
260
|
+
resolvedState = currentState();
|
|
261
|
+
} else if (currentState != null) {
|
|
262
|
+
resolvedState = currentState;
|
|
211
263
|
}
|
|
264
|
+
|
|
265
|
+
ctx.navigate(to, { replace, scroll, state: resolvedState });
|
|
212
266
|
},
|
|
213
267
|
[to, isExternal, reloadDocument, replace, scroll, ctx, onClick],
|
|
214
268
|
);
|
|
215
269
|
|
|
216
270
|
const handleMouseEnter = useCallback(() => {
|
|
217
|
-
if (
|
|
271
|
+
if (resolvedStrategy === "hover" && !isExternal && ctx?.store) {
|
|
272
|
+
const segmentState = ctx.store.getSegmentState();
|
|
273
|
+
prefetchDirect(to, segmentState.currentSegmentIds, ctx.version);
|
|
274
|
+
}
|
|
275
|
+
}, [resolvedStrategy, to, isExternal, ctx]);
|
|
276
|
+
|
|
277
|
+
// Viewport/render prefetch: waits for idle before starting,
|
|
278
|
+
// uses concurrency-limited queue to avoid flooding.
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
if (isExternal || !ctx?.store) return;
|
|
281
|
+
const isViewport = resolvedStrategy === "viewport";
|
|
282
|
+
const isRender = resolvedStrategy === "render";
|
|
283
|
+
if (!isViewport && !isRender) return;
|
|
284
|
+
|
|
285
|
+
let cancelled = false;
|
|
286
|
+
let unsubIdle: (() => void) | undefined;
|
|
287
|
+
let observedElement: Element | null = null;
|
|
288
|
+
|
|
289
|
+
const triggerPrefetch = () => {
|
|
290
|
+
if (cancelled) return;
|
|
218
291
|
const segmentState = ctx.store.getSegmentState();
|
|
219
|
-
|
|
292
|
+
prefetchQueued(to, segmentState.currentSegmentIds, ctx.version);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// Schedule prefetch only when the app is idle (no navigation/streaming).
|
|
296
|
+
// This avoids competing with hydration and active navigation fetches.
|
|
297
|
+
const scheduleWhenIdle = (callback: () => void) => {
|
|
298
|
+
const state = ctx.eventController.getState();
|
|
299
|
+
if (state.state === "idle" && !state.isStreaming) {
|
|
300
|
+
callback();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const unsub = ctx.eventController.subscribe(() => {
|
|
304
|
+
const s = ctx.eventController.getState();
|
|
305
|
+
if (s.state === "idle" && !s.isStreaming) {
|
|
306
|
+
unsub();
|
|
307
|
+
callback();
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
unsubIdle = unsub;
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
if (isRender) {
|
|
314
|
+
scheduleWhenIdle(triggerPrefetch);
|
|
315
|
+
} else if (isViewport) {
|
|
316
|
+
const element = internalRef.current;
|
|
317
|
+
if (!element) return;
|
|
318
|
+
observedElement = element;
|
|
319
|
+
observeForPrefetch(element, () => {
|
|
320
|
+
scheduleWhenIdle(triggerPrefetch);
|
|
321
|
+
});
|
|
220
322
|
}
|
|
221
|
-
|
|
323
|
+
|
|
324
|
+
return () => {
|
|
325
|
+
cancelled = true;
|
|
326
|
+
unsubIdle?.();
|
|
327
|
+
if (isViewport && observedElement) {
|
|
328
|
+
unobserveForPrefetch(observedElement);
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
}, [resolvedStrategy, to, isExternal, ctx]);
|
|
222
332
|
|
|
223
333
|
return (
|
|
224
334
|
<a
|
|
225
|
-
ref={
|
|
335
|
+
ref={setRef}
|
|
226
336
|
href={to}
|
|
227
337
|
onClick={handleClick}
|
|
228
338
|
onMouseEnter={handleMouseEnter}
|
|
@@ -22,7 +22,9 @@ import type { EventController } from "../event-controller.js";
|
|
|
22
22
|
import { RootErrorBoundary } from "../../root-error-boundary.js";
|
|
23
23
|
import type { HandleData } from "../types.js";
|
|
24
24
|
import { ThemeProvider } from "../../theme/ThemeProvider.js";
|
|
25
|
+
import { NonceContext } from "./nonce-context.js";
|
|
25
26
|
import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
|
|
27
|
+
import { cancelAllPrefetches } from "../prefetch/queue.js";
|
|
26
28
|
|
|
27
29
|
/**
|
|
28
30
|
* Process handles from an async generator, updating the event controller
|
|
@@ -126,6 +128,12 @@ export interface NavigationProviderProps {
|
|
|
126
128
|
* When true, keeps TLS alive by sending HEAD requests after idle periods.
|
|
127
129
|
*/
|
|
128
130
|
warmupEnabled?: boolean;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* App version from server payload (stable, immutable).
|
|
134
|
+
* Forwarded to prefetch requests for version mismatch detection.
|
|
135
|
+
*/
|
|
136
|
+
version?: string;
|
|
129
137
|
}
|
|
130
138
|
|
|
131
139
|
/**
|
|
@@ -157,6 +165,7 @@ export function NavigationProvider({
|
|
|
157
165
|
themeConfig,
|
|
158
166
|
initialTheme,
|
|
159
167
|
warmupEnabled,
|
|
168
|
+
version,
|
|
160
169
|
}: NavigationProviderProps): ReactNode {
|
|
161
170
|
// Track current payload for rendering (this triggers re-renders)
|
|
162
171
|
const [payload, setPayload] = useState(initialPayload);
|
|
@@ -185,6 +194,7 @@ export function NavigationProvider({
|
|
|
185
194
|
eventController,
|
|
186
195
|
navigate,
|
|
187
196
|
refresh,
|
|
197
|
+
version,
|
|
188
198
|
}),
|
|
189
199
|
[],
|
|
190
200
|
);
|
|
@@ -276,6 +286,21 @@ export function NavigationProvider({
|
|
|
276
286
|
};
|
|
277
287
|
}, [warmupEnabled]);
|
|
278
288
|
|
|
289
|
+
// Cancel speculative prefetches when navigation starts.
|
|
290
|
+
// Viewport/render prefetches should not compete with navigation fetches.
|
|
291
|
+
useEffect(() => {
|
|
292
|
+
let wasIdle = true;
|
|
293
|
+
const unsub = eventController.subscribe(() => {
|
|
294
|
+
const state = eventController.getState();
|
|
295
|
+
const isIdle = state.state === "idle" && !state.isStreaming;
|
|
296
|
+
if (wasIdle && !isIdle) {
|
|
297
|
+
cancelAllPrefetches();
|
|
298
|
+
}
|
|
299
|
+
wasIdle = isIdle;
|
|
300
|
+
});
|
|
301
|
+
return unsub;
|
|
302
|
+
}, [eventController]);
|
|
303
|
+
|
|
279
304
|
// Subscribe to UI updates (for re-rendering the tree)
|
|
280
305
|
useEffect(() => {
|
|
281
306
|
const unsubscribe = store.onUpdate((update) => {
|
|
@@ -346,6 +371,13 @@ export function NavigationProvider({
|
|
|
346
371
|
);
|
|
347
372
|
}
|
|
348
373
|
|
|
374
|
+
// Match SSR tree shape: NonceContext.Provider is always present so
|
|
375
|
+
// hydration sees the same component tree. Value is undefined on the
|
|
376
|
+
// client — CSP nonces are a server-side HTML concern.
|
|
377
|
+
content = (
|
|
378
|
+
<NonceContext.Provider value={undefined}>{content}</NonceContext.Provider>
|
|
379
|
+
);
|
|
380
|
+
|
|
349
381
|
return (
|
|
350
382
|
<NavigationStoreContext.Provider value={contextValue}>
|
|
351
383
|
{content}
|
|
@@ -41,6 +41,12 @@ export interface NavigationStoreContextValue {
|
|
|
41
41
|
* @returns Promise that resolves when refresh is complete
|
|
42
42
|
*/
|
|
43
43
|
refresh: () => Promise<void>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* App version from server payload (stable, immutable).
|
|
47
|
+
* Used in prefetch requests for version mismatch detection.
|
|
48
|
+
*/
|
|
49
|
+
version: string | undefined;
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
/**
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filter segment IDs to only include routes and layouts.
|
|
3
|
+
* Excludes parallels (contain .@) and loaders (contain D followed by digit).
|
|
4
|
+
*/
|
|
5
|
+
export function filterSegmentOrder(matched: string[]): string[] {
|
|
6
|
+
return matched.filter((id) => {
|
|
7
|
+
if (id.includes(".@")) return false;
|
|
8
|
+
if (/D\d+\./.test(id)) return false;
|
|
9
|
+
return true;
|
|
10
|
+
});
|
|
11
|
+
}
|
|
@@ -15,14 +15,10 @@ export { useParams } from "./use-params.js";
|
|
|
15
15
|
export { useAction, type TrackedActionState } from "./use-action.js";
|
|
16
16
|
|
|
17
17
|
// Segments state hook
|
|
18
|
-
export {
|
|
19
|
-
useSegments,
|
|
20
|
-
initSegmentsSync,
|
|
21
|
-
type SegmentsState,
|
|
22
|
-
} from "./use-segments.js";
|
|
18
|
+
export { useSegments, type SegmentsState } from "./use-segments.js";
|
|
23
19
|
|
|
24
20
|
// Handle data hook
|
|
25
|
-
export { useHandle
|
|
21
|
+
export { useHandle } from "./use-handle.js";
|
|
26
22
|
|
|
27
23
|
// Client cache controls hook
|
|
28
24
|
export {
|
|
@@ -4,11 +4,14 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Internal entry representing a state value with its unique key
|
|
7
|
+
* Internal entry representing a state value with its unique key.
|
|
8
|
+
* When __rsc_ls_lazy is true, __rsc_ls_value holds a getter function
|
|
9
|
+
* that is called at navigation time (not at entry creation time).
|
|
8
10
|
*/
|
|
9
11
|
export interface LocationStateEntry {
|
|
10
12
|
readonly __rsc_ls_key: string;
|
|
11
13
|
readonly __rsc_ls_value: unknown;
|
|
14
|
+
readonly __rsc_ls_lazy?: boolean;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
/**
|
|
@@ -55,6 +58,13 @@ export interface LocationStateDefinition<TArgs extends unknown[], TState> {
|
|
|
55
58
|
* // Use in Link
|
|
56
59
|
* <Link to="/product/123" state={[ProductState({ name: "Widget", price: 9.99 })]}>
|
|
57
60
|
*
|
|
61
|
+
* // Just-in-time typed state (getter called at click time, not render time).
|
|
62
|
+
* // Must be in a client component — the getter function can't cross the RSC boundary.
|
|
63
|
+
* <Link
|
|
64
|
+
* to="/product/123"
|
|
65
|
+
* state={[ProductState(() => ({ name: product.name, price: product.price }))]}
|
|
66
|
+
* >
|
|
67
|
+
*
|
|
58
68
|
* // Read with hook (reactive)
|
|
59
69
|
* const product = useLocationState(ProductState);
|
|
60
70
|
*
|
|
@@ -69,7 +79,7 @@ export function createLocationState<TState>(
|
|
|
69
79
|
let _key: string | undefined;
|
|
70
80
|
|
|
71
81
|
function getKey(): string {
|
|
72
|
-
if (!_key && process.env.NODE_ENV
|
|
82
|
+
if (!_key && process.env.NODE_ENV === "development") {
|
|
73
83
|
throw new Error(
|
|
74
84
|
"[rsc-router] createLocationState key not set. " +
|
|
75
85
|
"Make sure the exposeInternalIds Vite plugin is enabled and " +
|
|
@@ -79,14 +89,20 @@ export function createLocationState<TState>(
|
|
|
79
89
|
return _key!;
|
|
80
90
|
}
|
|
81
91
|
|
|
82
|
-
const fn = (stateOrGetter: TState | (() => TState)): LocationStateEntry =>
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
:
|
|
89
|
-
|
|
92
|
+
const fn = (stateOrGetter: TState | (() => TState)): LocationStateEntry => {
|
|
93
|
+
if (typeof stateOrGetter === "function") {
|
|
94
|
+
// Store getter as-is; resolved at navigation time by resolveLocationStateEntries()
|
|
95
|
+
return {
|
|
96
|
+
__rsc_ls_key: getKey(),
|
|
97
|
+
__rsc_ls_value: stateOrGetter,
|
|
98
|
+
__rsc_ls_lazy: true,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
__rsc_ls_key: getKey(),
|
|
103
|
+
__rsc_ls_value: stateOrGetter,
|
|
104
|
+
};
|
|
105
|
+
};
|
|
90
106
|
|
|
91
107
|
// Use defineProperty for __rsc_ls_key to avoid Object.assign evaluating
|
|
92
108
|
// the getter during construction (before the Vite plugin sets the key).
|
|
@@ -138,7 +154,9 @@ export function resolveLocationStateEntries(
|
|
|
138
154
|
): Record<string, unknown> {
|
|
139
155
|
const result: Record<string, unknown> = {};
|
|
140
156
|
for (const entry of entries) {
|
|
141
|
-
result[entry.__rsc_ls_key] = entry.
|
|
157
|
+
result[entry.__rsc_ls_key] = entry.__rsc_ls_lazy
|
|
158
|
+
? (entry.__rsc_ls_value as () => unknown)()
|
|
159
|
+
: entry.__rsc_ls_value;
|
|
142
160
|
}
|
|
143
161
|
return result;
|
|
144
162
|
}
|
|
@@ -22,7 +22,7 @@ export {
|
|
|
22
22
|
*
|
|
23
23
|
* Overloaded:
|
|
24
24
|
* - With definition: Returns typed state from the specific key
|
|
25
|
-
* - With type param only: Returns
|
|
25
|
+
* - With type param only: Returns plain state from history.state.state
|
|
26
26
|
*
|
|
27
27
|
* @example
|
|
28
28
|
* ```typescript
|
|
@@ -34,8 +34,8 @@ export {
|
|
|
34
34
|
* const FlashMsg = createLocationState<{ text: string }>({ flash: true });
|
|
35
35
|
* const flash = useLocationState(FlashMsg);
|
|
36
36
|
*
|
|
37
|
-
* //
|
|
38
|
-
* const
|
|
37
|
+
* // Plain state access (reads from history.state.state)
|
|
38
|
+
* const state = useLocationState<{ from?: string }>();
|
|
39
39
|
* ```
|
|
40
40
|
*/
|
|
41
41
|
export function useLocationState<TArgs extends unknown[], TState>(
|
|
@@ -53,7 +53,7 @@ export function useLocationState<TArgs extends unknown[], TState>(
|
|
|
53
53
|
if (key) {
|
|
54
54
|
return window.history.state?.[key] as TState | undefined;
|
|
55
55
|
}
|
|
56
|
-
//
|
|
56
|
+
// Plain state: stored under history.state.state
|
|
57
57
|
return window.history.state?.state as TState | undefined;
|
|
58
58
|
});
|
|
59
59
|
|
|
@@ -80,6 +80,8 @@ export function useLocationState<TArgs extends unknown[], TState>(
|
|
|
80
80
|
} else {
|
|
81
81
|
setState(val);
|
|
82
82
|
}
|
|
83
|
+
} else {
|
|
84
|
+
setState(window.history.state?.state as TState | undefined);
|
|
83
85
|
}
|
|
84
86
|
};
|
|
85
87
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Context for CSP nonce propagation to client components during SSR.
|
|
5
|
+
*
|
|
6
|
+
* The SSR renderer wraps the tree with NonceContext.Provider so that
|
|
7
|
+
* client components (e.g. MetaTags) can apply nonces to inline scripts.
|
|
8
|
+
* On the browser side, no provider is needed — the default undefined
|
|
9
|
+
* is correct since CSP nonces are a server-side HTML concern.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createContext, useContext, type Context } from "react";
|
|
13
|
+
|
|
14
|
+
export const NonceContext: Context<string | undefined> = createContext<
|
|
15
|
+
string | undefined
|
|
16
|
+
>(undefined);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Read the CSP nonce during SSR. Returns undefined on the client.
|
|
20
|
+
*/
|
|
21
|
+
export function useNonce(): string | undefined {
|
|
22
|
+
return useContext(NonceContext);
|
|
23
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shallow equality check for selector results.
|
|
3
|
+
* Uses Object.is for value comparison (handles NaN and +-0 correctly).
|
|
4
|
+
*/
|
|
5
|
+
export function shallowEqual<T>(a: T, b: T): boolean {
|
|
6
|
+
if (Object.is(a, b)) return true;
|
|
7
|
+
if (
|
|
8
|
+
typeof a !== "object" ||
|
|
9
|
+
a === null ||
|
|
10
|
+
typeof b !== "object" ||
|
|
11
|
+
b === null
|
|
12
|
+
) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
const keysA = Object.keys(a);
|
|
16
|
+
const keysB = Object.keys(b);
|
|
17
|
+
if (keysA.length !== keysB.length) return false;
|
|
18
|
+
for (const key of keysA) {
|
|
19
|
+
if (
|
|
20
|
+
!Object.hasOwn(b, key) ||
|
|
21
|
+
!Object.is((a as any)[key], (b as any)[key])
|
|
22
|
+
) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
startTransition,
|
|
10
10
|
} from "react";
|
|
11
11
|
import { NavigationStoreContext } from "./context.js";
|
|
12
|
-
import
|
|
12
|
+
import { shallowEqual } from "./shallow-equal.js";
|
|
13
|
+
import type { TrackedActionState } from "../types.js";
|
|
13
14
|
import { invariant } from "../../errors.js";
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -126,6 +127,11 @@ export type ServerActionFunction = ((...args: any[]) => Promise<any>) & {
|
|
|
126
127
|
* const error = useAction(addToCart, state => state.error);
|
|
127
128
|
* ```
|
|
128
129
|
*
|
|
130
|
+
* @note The selector is expected to be stable for a given hook instance.
|
|
131
|
+
* This hook tracks one projection of one action. Changing selector semantics
|
|
132
|
+
* for the same action ID without a new action event is not a supported pattern;
|
|
133
|
+
* use separate useAction() subscriptions if you need different projections.
|
|
134
|
+
*
|
|
129
135
|
* @note Actions passed as props from server components lose their metadata
|
|
130
136
|
* during RSC serialization. Use a string action name or import directly.
|
|
131
137
|
*/
|
|
@@ -161,7 +167,10 @@ export function useAction<T>(
|
|
|
161
167
|
T | TrackedActionState
|
|
162
168
|
>(null!);
|
|
163
169
|
|
|
164
|
-
//
|
|
170
|
+
// Ref keeps the latest selector for subscription callbacks without
|
|
171
|
+
// re-subscribing on every render. Selector changes themselves are not
|
|
172
|
+
// treated as a reactive input; this hook expects a stable selector and
|
|
173
|
+
// represents one subscription/projection for one action.
|
|
165
174
|
const selectorRef = useRef(selector);
|
|
166
175
|
selectorRef.current = selector;
|
|
167
176
|
|
|
@@ -169,6 +178,17 @@ export function useAction<T>(
|
|
|
169
178
|
useEffect(() => {
|
|
170
179
|
if (!ctx) return;
|
|
171
180
|
|
|
181
|
+
// Sync current state for the (possibly new) actionId so that switching
|
|
182
|
+
// actions on an idle page doesn't leave stale data from the old action.
|
|
183
|
+
const currentState = ctx.eventController.getActionState(actionId);
|
|
184
|
+
const currentSelected = selectorRef.current
|
|
185
|
+
? selectorRef.current(currentState)
|
|
186
|
+
: currentState;
|
|
187
|
+
if (!shallowEqual(currentSelected, prevSelected.current)) {
|
|
188
|
+
prevSelected.current = currentSelected;
|
|
189
|
+
setBaseState(currentSelected);
|
|
190
|
+
}
|
|
191
|
+
|
|
172
192
|
// Subscribe to action-specific updates
|
|
173
193
|
const unsubscribe = ctx.eventController.subscribeToAction(
|
|
174
194
|
actionId,
|
|
@@ -177,7 +197,7 @@ export function useAction<T>(
|
|
|
177
197
|
? selectorRef.current(state)
|
|
178
198
|
: state;
|
|
179
199
|
|
|
180
|
-
if (!
|
|
200
|
+
if (!shallowEqual(selectedState, prevSelected.current)) {
|
|
181
201
|
prevSelected.current = selectedState;
|
|
182
202
|
setBaseState(selectedState);
|
|
183
203
|
startTransition(() => {
|
|
@@ -195,46 +215,4 @@ export function useAction<T>(
|
|
|
195
215
|
return (optimisticState ?? baseState) as T | TrackedActionState;
|
|
196
216
|
}
|
|
197
217
|
|
|
198
|
-
function isShallowEqual<T, U>(selectedState: T, baseState: U): boolean {
|
|
199
|
-
// If references are equal, they're shallow equal
|
|
200
|
-
//@ts-expect-error -- TS doesn't like comparing generics
|
|
201
|
-
if (selectedState === baseState) {
|
|
202
|
-
return true;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// If either is null/undefined and they're not equal, they're not shallow equal
|
|
206
|
-
if (selectedState == null || baseState == null) {
|
|
207
|
-
return false;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// If types are different, they're not shallow equal
|
|
211
|
-
if (typeof selectedState !== typeof baseState) {
|
|
212
|
-
return false;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// For primitives, === comparison is sufficient (already checked above)
|
|
216
|
-
if (typeof selectedState !== "object") {
|
|
217
|
-
return false;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// For objects, compare keys and values shallowly
|
|
221
|
-
const keysA = Object.keys(selectedState as object);
|
|
222
|
-
const keysB = Object.keys(baseState as object);
|
|
223
|
-
|
|
224
|
-
if (keysA.length !== keysB.length) {
|
|
225
|
-
return false;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
for (const key of keysA) {
|
|
229
|
-
if (
|
|
230
|
-
!Object.prototype.hasOwnProperty.call(baseState, key) ||
|
|
231
|
-
(selectedState as any)[key] !== (baseState as any)[key]
|
|
232
|
-
) {
|
|
233
|
-
return false;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
return true;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
218
|
export type { TrackedActionState };
|
|
@@ -46,10 +46,12 @@ export interface ClientCacheControls {
|
|
|
46
46
|
export function useClientCache(): ClientCacheControls {
|
|
47
47
|
const ctx = useContext(NavigationStoreContext);
|
|
48
48
|
|
|
49
|
+
if (!ctx) {
|
|
50
|
+
throw new Error("useClientCache must be used within NavigationProvider");
|
|
51
|
+
}
|
|
52
|
+
|
|
49
53
|
const clear = useCallback(() => {
|
|
50
|
-
|
|
51
|
-
ctx.store.clearHistoryCache();
|
|
52
|
-
}
|
|
54
|
+
ctx.store.clearHistoryCache();
|
|
53
55
|
}, [ctx]);
|
|
54
56
|
|
|
55
57
|
return { clear };
|