@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
|
@@ -12,26 +12,7 @@ import type { Handle } from "../../handle.js";
|
|
|
12
12
|
import { getCollectFn } from "../../handle.js";
|
|
13
13
|
import type { HandleData } from "../types.js";
|
|
14
14
|
import { NavigationStoreContext } from "./context.js";
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* SSR module-level state.
|
|
18
|
-
* Populated by initHandleDataSync before React renders.
|
|
19
|
-
* Used by useState initializer during SSR.
|
|
20
|
-
*/
|
|
21
|
-
let ssrHandleData: HandleData = {};
|
|
22
|
-
let ssrSegmentOrder: string[] = [];
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Filter segment IDs to only include routes and layouts.
|
|
26
|
-
* Excludes parallels (contain .@) and loaders (contain D followed by digit).
|
|
27
|
-
*/
|
|
28
|
-
function filterSegmentOrder(matched: string[]): string[] {
|
|
29
|
-
return matched.filter((id) => {
|
|
30
|
-
if (id.includes(".@")) return false;
|
|
31
|
-
if (/D\d+\./.test(id)) return false;
|
|
32
|
-
return true;
|
|
33
|
-
});
|
|
34
|
-
}
|
|
15
|
+
import { shallowEqual } from "./shallow-equal.js";
|
|
35
16
|
|
|
36
17
|
/**
|
|
37
18
|
* Resolve the collect function for a handle.
|
|
@@ -86,45 +67,6 @@ function collectHandle<T, A>(
|
|
|
86
67
|
return collect(segmentArrays);
|
|
87
68
|
}
|
|
88
69
|
|
|
89
|
-
/**
|
|
90
|
-
* Shallow equality check for selector results.
|
|
91
|
-
*/
|
|
92
|
-
function shallowEqual<T>(a: T, b: T): boolean {
|
|
93
|
-
if (Object.is(a, b)) return true;
|
|
94
|
-
if (
|
|
95
|
-
typeof a !== "object" ||
|
|
96
|
-
a === null ||
|
|
97
|
-
typeof b !== "object" ||
|
|
98
|
-
b === null
|
|
99
|
-
) {
|
|
100
|
-
return false;
|
|
101
|
-
}
|
|
102
|
-
const keysA = Object.keys(a);
|
|
103
|
-
const keysB = Object.keys(b);
|
|
104
|
-
if (keysA.length !== keysB.length) return false;
|
|
105
|
-
for (const key of keysA) {
|
|
106
|
-
if (
|
|
107
|
-
!Object.hasOwn(b, key) ||
|
|
108
|
-
!Object.is((a as any)[key], (b as any)[key])
|
|
109
|
-
) {
|
|
110
|
-
return false;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
return true;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Initialize handle data synchronously for SSR.
|
|
118
|
-
* Called before rendering to populate state for useState initializer.
|
|
119
|
-
*
|
|
120
|
-
* @param data - Handle data from RSC payload
|
|
121
|
-
* @param matched - Segment order for reduction
|
|
122
|
-
*/
|
|
123
|
-
export function initHandleDataSync(data: HandleData, matched?: string[]): void {
|
|
124
|
-
ssrHandleData = data;
|
|
125
|
-
ssrSegmentOrder = filterSegmentOrder(matched ?? []);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
70
|
/**
|
|
129
71
|
* Hook to access collected handle data.
|
|
130
72
|
*
|
|
@@ -154,11 +96,10 @@ export function useHandle<T, A, S>(
|
|
|
154
96
|
): A | S {
|
|
155
97
|
const ctx = useContext(NavigationStoreContext);
|
|
156
98
|
|
|
157
|
-
// Initial state from
|
|
99
|
+
// Initial state from context event controller, or empty fallback without provider.
|
|
158
100
|
const [value, setValue] = useState<A | S>(() => {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const collected = collectHandle(handle, ssrHandleData, ssrSegmentOrder);
|
|
101
|
+
if (!ctx) {
|
|
102
|
+
const collected = collectHandle(handle, {}, []);
|
|
162
103
|
return selector ? selector(collected) : collected;
|
|
163
104
|
}
|
|
164
105
|
|
|
@@ -173,7 +114,7 @@ export function useHandle<T, A, S>(
|
|
|
173
114
|
const prevValueRef = useRef(value);
|
|
174
115
|
prevValueRef.current = value;
|
|
175
116
|
|
|
176
|
-
//
|
|
117
|
+
// Ref keeps the latest selector without re-subscribing on every render.
|
|
177
118
|
const selectorRef = useRef(selector);
|
|
178
119
|
selectorRef.current = selector;
|
|
179
120
|
|
|
@@ -181,6 +122,22 @@ export function useHandle<T, A, S>(
|
|
|
181
122
|
useEffect(() => {
|
|
182
123
|
if (!ctx) return;
|
|
183
124
|
|
|
125
|
+
// Sync current state for the (possibly new) handle so that switching
|
|
126
|
+
// handles on an idle page doesn't leave stale data from the old handle.
|
|
127
|
+
const currentHandleState = ctx.eventController.getHandleState();
|
|
128
|
+
const currentCollected = collectHandle(
|
|
129
|
+
handle,
|
|
130
|
+
currentHandleState.data,
|
|
131
|
+
currentHandleState.segmentOrder,
|
|
132
|
+
);
|
|
133
|
+
const currentValue = selectorRef.current
|
|
134
|
+
? selectorRef.current(currentCollected)
|
|
135
|
+
: currentCollected;
|
|
136
|
+
if (!shallowEqual(currentValue, prevValueRef.current)) {
|
|
137
|
+
prevValueRef.current = currentValue;
|
|
138
|
+
setValue(currentValue);
|
|
139
|
+
}
|
|
140
|
+
|
|
184
141
|
return ctx.eventController.subscribeToHandles(() => {
|
|
185
142
|
const state = ctx.eventController.getHandleState();
|
|
186
143
|
const isAction =
|
|
@@ -9,36 +9,10 @@ import {
|
|
|
9
9
|
useRef,
|
|
10
10
|
} from "react";
|
|
11
11
|
import { NavigationStoreContext } from "./context.js";
|
|
12
|
+
import { shallowEqual } from "./shallow-equal.js";
|
|
12
13
|
import type { PublicNavigationState } from "../types.js";
|
|
13
14
|
import type { DerivedNavigationState } from "../event-controller.js";
|
|
14
15
|
|
|
15
|
-
/**
|
|
16
|
-
* Shallow equality check for selector results
|
|
17
|
-
*/
|
|
18
|
-
function shallowEqual<T>(a: T, b: T): boolean {
|
|
19
|
-
if (Object.is(a, b)) return true;
|
|
20
|
-
if (
|
|
21
|
-
typeof a !== "object" ||
|
|
22
|
-
a === null ||
|
|
23
|
-
typeof b !== "object" ||
|
|
24
|
-
b === null
|
|
25
|
-
) {
|
|
26
|
-
return false;
|
|
27
|
-
}
|
|
28
|
-
const keysA = Object.keys(a);
|
|
29
|
-
const keysB = Object.keys(b);
|
|
30
|
-
if (keysA.length !== keysB.length) return false;
|
|
31
|
-
for (const key of keysA) {
|
|
32
|
-
if (
|
|
33
|
-
!Object.hasOwn(b, key) ||
|
|
34
|
-
!Object.is((a as any)[key], (b as any)[key])
|
|
35
|
-
) {
|
|
36
|
-
return false;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return true;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
16
|
/**
|
|
43
17
|
* Convert derived state to public version (strips inflightActions)
|
|
44
18
|
*/
|
|
@@ -69,9 +43,7 @@ export function useNavigation<T>(
|
|
|
69
43
|
const ctx = useContext(NavigationStoreContext);
|
|
70
44
|
|
|
71
45
|
if (!ctx) {
|
|
72
|
-
throw new Error(
|
|
73
|
-
"useNavigation must be used within NavigationStoreContext.Provider",
|
|
74
|
-
);
|
|
46
|
+
throw new Error("useNavigation must be used within NavigationProvider");
|
|
75
47
|
}
|
|
76
48
|
|
|
77
49
|
// Base state for useOptimistic
|
|
@@ -84,8 +56,11 @@ export function useNavigation<T>(
|
|
|
84
56
|
// useOptimistic allows immediate updates during transitions/actions
|
|
85
57
|
const [value, setOptimisticValue] = useOptimistic(baseValue);
|
|
86
58
|
|
|
87
|
-
// Store selector in a ref
|
|
88
|
-
//
|
|
59
|
+
// Store selector in a ref so the subscription callback always uses the
|
|
60
|
+
// latest selector without re-subscribing on every render (inline functions
|
|
61
|
+
// have a new identity each render). This is event-driven by design: the
|
|
62
|
+
// value updates when the store emits, not when the selector changes.
|
|
63
|
+
// Between events there is nothing new to select from.
|
|
89
64
|
const selectorRef = useRef(selector);
|
|
90
65
|
selectorRef.current = selector;
|
|
91
66
|
|
|
@@ -2,37 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useContext, useState, useEffect, useRef } from "react";
|
|
4
4
|
import { NavigationStoreContext } from "./context.js";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Shallow equality check for selector results
|
|
9
|
-
*/
|
|
10
|
-
function shallowEqual<T>(a: T, b: T): boolean {
|
|
11
|
-
if (Object.is(a, b)) return true;
|
|
12
|
-
if (
|
|
13
|
-
typeof a !== "object" ||
|
|
14
|
-
a === null ||
|
|
15
|
-
typeof b !== "object" ||
|
|
16
|
-
b === null
|
|
17
|
-
) {
|
|
18
|
-
return false;
|
|
19
|
-
}
|
|
20
|
-
const keysA = Object.keys(a);
|
|
21
|
-
const keysB = Object.keys(b);
|
|
22
|
-
if (keysA.length !== keysB.length) return false;
|
|
23
|
-
for (const key of keysA) {
|
|
24
|
-
if (
|
|
25
|
-
!Object.hasOwn(b, key) ||
|
|
26
|
-
!Object.is(
|
|
27
|
-
(a as Record<string, unknown>)[key],
|
|
28
|
-
(b as Record<string, unknown>)[key],
|
|
29
|
-
)
|
|
30
|
-
) {
|
|
31
|
-
return false;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return true;
|
|
35
|
-
}
|
|
5
|
+
import { shallowEqual } from "./shallow-equal.js";
|
|
36
6
|
|
|
37
7
|
/**
|
|
38
8
|
* Hook to access the current route params.
|
|
@@ -60,15 +30,16 @@ export function useParams<T>(
|
|
|
60
30
|
const ctx = useContext(NavigationStoreContext);
|
|
61
31
|
|
|
62
32
|
const [value, setValue] = useState<T | Record<string, string>>(() => {
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
return selector ? selector(ssrParams) : ssrParams;
|
|
33
|
+
if (!ctx) {
|
|
34
|
+
return selector ? selector({}) : {};
|
|
66
35
|
}
|
|
67
36
|
const params = ctx.eventController.getParams();
|
|
68
37
|
return selector ? selector(params) : params;
|
|
69
38
|
});
|
|
70
39
|
|
|
71
40
|
const prevValue = useRef(value);
|
|
41
|
+
// Ref keeps the latest selector without re-subscribing. Event-driven by
|
|
42
|
+
// design: value updates on store events, not on selector identity change.
|
|
72
43
|
const selectorRef = useRef(selector);
|
|
73
44
|
selectorRef.current = selector;
|
|
74
45
|
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import { useContext, useState, useEffect, useRef } from "react";
|
|
4
4
|
import { NavigationStoreContext } from "./context.js";
|
|
5
|
-
import { getSsrPathname } from "./use-segments.js";
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* Hook to access the current pathname.
|
|
@@ -20,8 +19,8 @@ export function usePathname(): string {
|
|
|
20
19
|
const ctx = useContext(NavigationStoreContext);
|
|
21
20
|
|
|
22
21
|
const [pathname, setPathname] = useState<string>(() => {
|
|
23
|
-
if (
|
|
24
|
-
return
|
|
22
|
+
if (!ctx) {
|
|
23
|
+
return "/";
|
|
25
24
|
}
|
|
26
25
|
return (ctx.eventController.getState().location as URL).pathname;
|
|
27
26
|
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useContext, useMemo } from "react";
|
|
4
4
|
import { NavigationStoreContext } from "./context.js";
|
|
5
|
-
import {
|
|
5
|
+
import { prefetchDirect } from "../prefetch/fetch.js";
|
|
6
6
|
import type { RouterInstance, RouterNavigateOptions } from "../types.js";
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -25,9 +25,7 @@ export function useRouter(): RouterInstance {
|
|
|
25
25
|
const ctx = useContext(NavigationStoreContext);
|
|
26
26
|
|
|
27
27
|
if (!ctx) {
|
|
28
|
-
throw new Error(
|
|
29
|
-
"useRouter must be used within NavigationStoreContext.Provider",
|
|
30
|
-
);
|
|
28
|
+
throw new Error("useRouter must be used within NavigationProvider");
|
|
31
29
|
}
|
|
32
30
|
|
|
33
31
|
// Stable reference: ctx is itself stable (NavigationProvider memoizes with [])
|
|
@@ -46,10 +44,9 @@ export function useRouter(): RouterInstance {
|
|
|
46
44
|
},
|
|
47
45
|
|
|
48
46
|
prefetch(url: string): void {
|
|
49
|
-
// Guard for SSR where store is null
|
|
50
47
|
const segmentState = ctx.store?.getSegmentState();
|
|
51
48
|
if (segmentState) {
|
|
52
|
-
|
|
49
|
+
prefetchDirect(url, segmentState.currentSegmentIds, ctx.version);
|
|
53
50
|
}
|
|
54
51
|
},
|
|
55
52
|
|
|
@@ -41,7 +41,8 @@ export function useSearchParams(): ReadonlyURLSearchParams {
|
|
|
41
41
|
const nextSearch = location.searchParams.toString();
|
|
42
42
|
if (nextSearch !== prevSearch.current) {
|
|
43
43
|
prevSearch.current = nextSearch;
|
|
44
|
-
|
|
44
|
+
// Create a snapshot so callers cannot mutate the source URLSearchParams
|
|
45
|
+
setSearchParams(new URLSearchParams(nextSearch));
|
|
45
46
|
}
|
|
46
47
|
};
|
|
47
48
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useContext, useState, useEffect, useRef } from "react";
|
|
4
4
|
import { NavigationStoreContext } from "./context.js";
|
|
5
|
+
import { shallowEqual } from "./shallow-equal.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Segments state returned by useSegments hook
|
|
@@ -15,86 +16,6 @@ export interface SegmentsState {
|
|
|
15
16
|
location: URL;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
-
* SSR module-level state.
|
|
20
|
-
* Populated by initSegmentsSync before React renders.
|
|
21
|
-
* Used by useState initializer during SSR.
|
|
22
|
-
*/
|
|
23
|
-
let ssrSegmentOrder: string[] = [];
|
|
24
|
-
let ssrPathname: string = "/";
|
|
25
|
-
let ssrParams: Record<string, string> = {};
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Filter segment IDs to only include routes and layouts.
|
|
29
|
-
* Excludes parallels (contain .@) and loaders (contain D followed by digit).
|
|
30
|
-
*/
|
|
31
|
-
function filterSegmentOrder(matched: string[]): string[] {
|
|
32
|
-
return matched.filter((id) => {
|
|
33
|
-
if (id.includes(".@")) return false;
|
|
34
|
-
if (/D\d+\./.test(id)) return false;
|
|
35
|
-
return true;
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Initialize segments data synchronously for SSR.
|
|
41
|
-
* Called before rendering to populate state for useState initializer.
|
|
42
|
-
*
|
|
43
|
-
* @param matched - Segment order from RSC metadata
|
|
44
|
-
* @param pathname - Current pathname
|
|
45
|
-
* @param params - Merged route params
|
|
46
|
-
*/
|
|
47
|
-
export function initSegmentsSync(
|
|
48
|
-
matched?: string[],
|
|
49
|
-
pathname?: string,
|
|
50
|
-
params?: Record<string, string>,
|
|
51
|
-
): void {
|
|
52
|
-
ssrSegmentOrder = filterSegmentOrder(matched ?? []);
|
|
53
|
-
ssrPathname = pathname ?? "/";
|
|
54
|
-
ssrParams = params ?? {};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Get SSR params for use-params hook initialization.
|
|
59
|
-
*/
|
|
60
|
-
export function getSsrParams(): Record<string, string> {
|
|
61
|
-
return ssrParams;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Get SSR pathname for use-pathname hook initialization.
|
|
66
|
-
*/
|
|
67
|
-
export function getSsrPathname(): string {
|
|
68
|
-
return ssrPathname;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Shallow equality check for selector results
|
|
73
|
-
*/
|
|
74
|
-
function shallowEqual<T>(a: T, b: T): boolean {
|
|
75
|
-
if (Object.is(a, b)) return true;
|
|
76
|
-
if (
|
|
77
|
-
typeof a !== "object" ||
|
|
78
|
-
a === null ||
|
|
79
|
-
typeof b !== "object" ||
|
|
80
|
-
b === null
|
|
81
|
-
) {
|
|
82
|
-
return false;
|
|
83
|
-
}
|
|
84
|
-
const keysA = Object.keys(a);
|
|
85
|
-
const keysB = Object.keys(b);
|
|
86
|
-
if (keysA.length !== keysB.length) return false;
|
|
87
|
-
for (const key of keysA) {
|
|
88
|
-
if (
|
|
89
|
-
!Object.hasOwn(b, key) ||
|
|
90
|
-
!Object.is((a as any)[key], (b as any)[key])
|
|
91
|
-
) {
|
|
92
|
-
return false;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
return true;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
19
|
/**
|
|
99
20
|
* Parse pathname into path segments
|
|
100
21
|
* /shop/products/123 → ["shop", "products", "123"]
|
|
@@ -117,18 +38,6 @@ function buildSegmentsState(
|
|
|
117
38
|
};
|
|
118
39
|
}
|
|
119
40
|
|
|
120
|
-
/**
|
|
121
|
-
* Build SSR state from module-level variables
|
|
122
|
-
*/
|
|
123
|
-
function buildSsrState(): SegmentsState {
|
|
124
|
-
const location = new URL(ssrPathname, "http://localhost");
|
|
125
|
-
return {
|
|
126
|
-
path: parsePathname(ssrPathname),
|
|
127
|
-
segmentIds: ssrSegmentOrder,
|
|
128
|
-
location,
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
|
|
132
41
|
/**
|
|
133
42
|
* Hook to access current route segments with optional selector for performance
|
|
134
43
|
*
|
|
@@ -152,50 +61,99 @@ export function useSegments<T>(
|
|
|
152
61
|
): T | SegmentsState {
|
|
153
62
|
const ctx = useContext(NavigationStoreContext);
|
|
154
63
|
|
|
155
|
-
// Build initial state from
|
|
64
|
+
// Build initial state from event controller when context exists.
|
|
65
|
+
// Inlined rather than calling recompute() because the segmentsCache ref
|
|
66
|
+
// is not yet initialized during the useState initializer.
|
|
156
67
|
const [state, setState] = useState<T | SegmentsState>(() => {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
return selector ? selector(
|
|
68
|
+
if (!ctx) {
|
|
69
|
+
const fallbackLocation = new URL("/", "http://localhost");
|
|
70
|
+
const fallbackState = buildSegmentsState(fallbackLocation, []);
|
|
71
|
+
return selector ? selector(fallbackState) : fallbackState;
|
|
161
72
|
}
|
|
162
|
-
|
|
163
|
-
const navState = ctx.eventController.getState();
|
|
73
|
+
const location = ctx.eventController.getLocation();
|
|
164
74
|
const handleState = ctx.eventController.getHandleState();
|
|
165
75
|
const segmentsState = buildSegmentsState(
|
|
166
|
-
|
|
76
|
+
location as URL,
|
|
167
77
|
handleState.segmentOrder,
|
|
168
78
|
);
|
|
169
79
|
return selector ? selector(segmentsState) : segmentsState;
|
|
170
80
|
});
|
|
171
81
|
|
|
172
82
|
const prevState = useRef(state);
|
|
83
|
+
const selectorRef = useRef(selector);
|
|
84
|
+
selectorRef.current = selector;
|
|
85
|
+
|
|
86
|
+
// Track selector identity to detect when the selector function changes.
|
|
87
|
+
// Only then do we eagerly recompute during render to avoid staleness.
|
|
88
|
+
// Without this guard, no-selector mode causes infinite re-renders because
|
|
89
|
+
// buildSegmentsState creates fresh arrays that fail Object.is checks.
|
|
90
|
+
const prevSelectorIdentity = useRef(selector);
|
|
91
|
+
|
|
92
|
+
// Cache SegmentsState to stabilize nested references (path, segmentIds
|
|
93
|
+
// arrays) so selectors returning composite values don't cause spurious
|
|
94
|
+
// render-time setState calls.
|
|
95
|
+
const segmentsCache = useRef<{
|
|
96
|
+
location: URL;
|
|
97
|
+
segmentOrder: string[];
|
|
98
|
+
state: SegmentsState;
|
|
99
|
+
} | null>(null);
|
|
100
|
+
|
|
101
|
+
// Recompute selected value from current store state and apply selector.
|
|
102
|
+
// Shared by the render-time eager check and the subscription callback.
|
|
103
|
+
function recompute(
|
|
104
|
+
sel: ((state: SegmentsState) => T) | undefined,
|
|
105
|
+
): T | SegmentsState {
|
|
106
|
+
const location = ctx!.eventController.getLocation();
|
|
107
|
+
const handleState = ctx!.eventController.getHandleState();
|
|
108
|
+
|
|
109
|
+
// Reuse cached state when inputs haven't changed by reference,
|
|
110
|
+
// keeping array/object references stable for composite selectors.
|
|
111
|
+
const cache = segmentsCache.current;
|
|
112
|
+
let segmentsState: SegmentsState;
|
|
113
|
+
if (
|
|
114
|
+
cache &&
|
|
115
|
+
cache.location === location &&
|
|
116
|
+
cache.segmentOrder === handleState.segmentOrder
|
|
117
|
+
) {
|
|
118
|
+
segmentsState = cache.state;
|
|
119
|
+
} else {
|
|
120
|
+
segmentsState = buildSegmentsState(
|
|
121
|
+
location as URL,
|
|
122
|
+
handleState.segmentOrder,
|
|
123
|
+
);
|
|
124
|
+
segmentsCache.current = {
|
|
125
|
+
location: location as URL,
|
|
126
|
+
segmentOrder: handleState.segmentOrder,
|
|
127
|
+
state: segmentsState,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return sel ? sel(segmentsState) : segmentsState;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (ctx && selector !== prevSelectorIdentity.current) {
|
|
134
|
+
prevSelectorIdentity.current = selector;
|
|
135
|
+
const nextSelected = recompute(selector);
|
|
136
|
+
if (!shallowEqual(nextSelected, prevState.current)) {
|
|
137
|
+
prevState.current = nextSelected;
|
|
138
|
+
setState(nextSelected);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
173
141
|
|
|
174
|
-
// Subscribe to
|
|
142
|
+
// Subscribe to store changes. The eager block above handles selector
|
|
143
|
+
// changes and SSR drift, so no initial updateState() call is needed.
|
|
175
144
|
useEffect(() => {
|
|
176
145
|
if (!ctx) {
|
|
177
146
|
return;
|
|
178
147
|
}
|
|
179
148
|
|
|
180
149
|
const updateState = () => {
|
|
181
|
-
const
|
|
182
|
-
const handleState = ctx.eventController.getHandleState();
|
|
183
|
-
const segmentsState = buildSegmentsState(
|
|
184
|
-
navState.location as URL,
|
|
185
|
-
handleState.segmentOrder,
|
|
186
|
-
);
|
|
187
|
-
const nextSelected = selector ? selector(segmentsState) : segmentsState;
|
|
188
|
-
|
|
150
|
+
const nextSelected = recompute(selectorRef.current);
|
|
189
151
|
if (!shallowEqual(nextSelected, prevState.current)) {
|
|
190
152
|
prevState.current = nextSelected;
|
|
191
153
|
setState(nextSelected);
|
|
192
154
|
}
|
|
193
155
|
};
|
|
194
156
|
|
|
195
|
-
// Initial update in case SSR state differs from client state
|
|
196
|
-
updateState();
|
|
197
|
-
|
|
198
|
-
// Subscribe to both state sources
|
|
199
157
|
const unsubscribeNav = ctx.eventController.subscribe(updateState);
|
|
200
158
|
const unsubscribeHandles =
|
|
201
159
|
ctx.eventController.subscribeToHandles(updateState);
|
|
@@ -204,7 +162,10 @@ export function useSegments<T>(
|
|
|
204
162
|
unsubscribeNav();
|
|
205
163
|
unsubscribeHandles();
|
|
206
164
|
};
|
|
207
|
-
|
|
165
|
+
// Stable subscription: selector changes are handled via selectorRef,
|
|
166
|
+
// state comparison uses prevState ref. No re-subscribe needed.
|
|
167
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
168
|
+
}, []);
|
|
208
169
|
|
|
209
170
|
return state as T | SegmentsState;
|
|
210
171
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { validateRedirectOrigin } from "./validate-redirect-origin.js";
|
|
2
|
+
|
|
3
|
+
type HeaderResult = { url: string } | "blocked" | null;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extract and validate an RSC response header URL (X-RSC-Reload, X-RSC-Redirect).
|
|
7
|
+
* Returns { url } if valid, "blocked" if present but invalid origin, null if absent.
|
|
8
|
+
*/
|
|
9
|
+
export function extractRscHeaderUrl(
|
|
10
|
+
response: Response,
|
|
11
|
+
header: string,
|
|
12
|
+
): HeaderResult {
|
|
13
|
+
const raw = response.headers.get(header);
|
|
14
|
+
if (!raw) return null;
|
|
15
|
+
const url = validateRedirectOrigin(raw, window.location.origin);
|
|
16
|
+
return url ? { url } : "blocked";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Empty 200 response that won't choke Flight parsing.
|
|
21
|
+
* Used when a header URL is blocked by origin validation.
|
|
22
|
+
*/
|
|
23
|
+
export function emptyResponse(): Response {
|
|
24
|
+
return new Response(null, { status: 200 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Tee a response body for RSC parsing and stream completion tracking.
|
|
29
|
+
* Returns a new Response with one branch; the other is consumed to detect
|
|
30
|
+
* end-of-stream, calling onComplete when done.
|
|
31
|
+
*
|
|
32
|
+
* If the response has no body, onComplete fires synchronously.
|
|
33
|
+
* If signal is provided, an abort cancels the tracking reader.
|
|
34
|
+
*/
|
|
35
|
+
export function teeWithCompletion(
|
|
36
|
+
response: Response,
|
|
37
|
+
onComplete: () => void,
|
|
38
|
+
signal?: AbortSignal,
|
|
39
|
+
): Response {
|
|
40
|
+
if (!response.body) {
|
|
41
|
+
onComplete();
|
|
42
|
+
return response;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const [rscStream, trackingStream] = response.body.tee();
|
|
46
|
+
|
|
47
|
+
(async () => {
|
|
48
|
+
const reader = trackingStream.getReader();
|
|
49
|
+
const onAbort = signal ? reader.cancel.bind(reader) : undefined;
|
|
50
|
+
if (onAbort) signal!.addEventListener("abort", onAbort, { once: true });
|
|
51
|
+
try {
|
|
52
|
+
while (true) {
|
|
53
|
+
const { done } = await reader.read();
|
|
54
|
+
if (done) break;
|
|
55
|
+
}
|
|
56
|
+
} finally {
|
|
57
|
+
if (onAbort) signal!.removeEventListener("abort", onAbort);
|
|
58
|
+
reader.releaseLock();
|
|
59
|
+
onComplete();
|
|
60
|
+
}
|
|
61
|
+
})().catch((error) => {
|
|
62
|
+
if (!signal?.aborted) {
|
|
63
|
+
console.error("[Browser] Error reading tracking stream:", error);
|
|
64
|
+
}
|
|
65
|
+
onComplete();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return new Response(rscStream, {
|
|
69
|
+
headers: response.headers,
|
|
70
|
+
status: response.status,
|
|
71
|
+
statusText: response.statusText,
|
|
72
|
+
});
|
|
73
|
+
}
|