@rangojs/router 0.0.0-experimental.ea6d5eec → 0.0.0-experimental.ede38110
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 +76 -18
- package/dist/bin/rango.js +130 -47
- package/dist/vite/index.js +719 -240
- package/package.json +3 -3
- package/skills/cache-guide/SKILL.md +32 -0
- package/skills/caching/SKILL.md +8 -0
- package/skills/handler-use/SKILL.md +362 -0
- package/skills/intercept/SKILL.md +20 -0
- package/skills/layout/SKILL.md +22 -0
- package/skills/links/SKILL.md +3 -1
- package/skills/loader/SKILL.md +53 -43
- package/skills/middleware/SKILL.md +34 -3
- package/skills/migrate-nextjs/SKILL.md +560 -0
- package/skills/migrate-react-router/SKILL.md +764 -0
- package/skills/parallel/SKILL.md +185 -0
- package/skills/prerender/SKILL.md +110 -68
- package/skills/rango/SKILL.md +24 -22
- package/skills/route/SKILL.md +55 -0
- package/skills/router-setup/SKILL.md +87 -2
- package/skills/typesafety/SKILL.md +10 -0
- package/src/__internal.ts +1 -1
- package/src/browser/app-version.ts +14 -0
- package/src/browser/event-controller.ts +5 -0
- package/src/browser/navigation-bridge.ts +37 -5
- package/src/browser/navigation-client.ts +107 -75
- package/src/browser/navigation-store.ts +43 -8
- package/src/browser/partial-update.ts +51 -6
- package/src/browser/prefetch/cache.ts +22 -12
- package/src/browser/prefetch/fetch.ts +81 -20
- package/src/browser/prefetch/queue.ts +61 -29
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/react/Link.tsx +67 -8
- package/src/browser/react/NavigationProvider.tsx +13 -4
- package/src/browser/react/context.ts +7 -2
- package/src/browser/react/use-handle.ts +9 -58
- package/src/browser/react/use-navigation.ts +11 -10
- package/src/browser/react/use-router.ts +21 -8
- package/src/browser/rsc-router.tsx +45 -3
- package/src/browser/scroll-restoration.ts +10 -8
- package/src/browser/segment-reconciler.ts +36 -9
- package/src/browser/server-action-bridge.ts +8 -6
- package/src/browser/types.ts +27 -5
- package/src/build/generate-manifest.ts +6 -6
- package/src/build/generate-route-types.ts +3 -0
- package/src/build/route-trie.ts +50 -24
- package/src/build/route-types/include-resolution.ts +8 -1
- package/src/build/route-types/router-processing.ts +211 -72
- package/src/build/route-types/scan-filter.ts +8 -1
- package/src/cache/cache-runtime.ts +15 -11
- package/src/cache/cache-scope.ts +46 -5
- package/src/cache/document-cache.ts +17 -7
- package/src/cache/taint.ts +55 -0
- package/src/client.tsx +84 -230
- package/src/context-var.ts +72 -2
- package/src/debug.ts +2 -2
- package/src/handle.ts +40 -0
- package/src/index.rsc.ts +3 -1
- package/src/index.ts +46 -6
- package/src/prerender/store.ts +5 -4
- package/src/prerender.ts +138 -77
- package/src/reverse.ts +25 -1
- package/src/route-definition/dsl-helpers.ts +224 -37
- package/src/route-definition/helpers-types.ts +67 -19
- package/src/route-definition/index.ts +3 -0
- package/src/route-definition/redirect.ts +9 -1
- package/src/route-definition/resolve-handler-use.ts +149 -0
- package/src/route-types.ts +18 -0
- package/src/router/content-negotiation.ts +100 -1
- package/src/router/handler-context.ts +82 -23
- package/src/router/intercept-resolution.ts +9 -4
- package/src/router/lazy-includes.ts +7 -6
- package/src/router/loader-resolution.ts +156 -21
- package/src/router/logging.ts +1 -1
- package/src/router/manifest.ts +28 -15
- package/src/router/match-api.ts +124 -189
- package/src/router/match-middleware/background-revalidation.ts +30 -2
- package/src/router/match-middleware/cache-lookup.ts +94 -17
- package/src/router/match-middleware/cache-store.ts +53 -10
- package/src/router/match-middleware/intercept-resolution.ts +9 -7
- package/src/router/match-middleware/segment-resolution.ts +60 -5
- package/src/router/match-result.ts +104 -10
- package/src/router/metrics.ts +6 -1
- package/src/router/middleware-types.ts +6 -8
- package/src/router/middleware.ts +2 -5
- package/src/router/navigation-snapshot.ts +182 -0
- package/src/router/prerender-match.ts +110 -10
- package/src/router/preview-match.ts +30 -102
- package/src/router/request-classification.ts +310 -0
- package/src/router/route-snapshot.ts +245 -0
- package/src/router/router-context.ts +1 -0
- package/src/router/router-interfaces.ts +36 -4
- package/src/router/router-options.ts +37 -11
- package/src/router/segment-resolution/fresh.ts +198 -20
- package/src/router/segment-resolution/helpers.ts +29 -24
- package/src/router/segment-resolution/loader-cache.ts +1 -0
- package/src/router/segment-resolution/revalidation.ts +433 -296
- package/src/router/types.ts +1 -0
- package/src/router.ts +55 -6
- package/src/rsc/handler.ts +472 -372
- package/src/rsc/loader-fetch.ts +23 -3
- package/src/rsc/manifest-init.ts +5 -1
- package/src/rsc/progressive-enhancement.ts +14 -2
- package/src/rsc/rsc-rendering.ts +10 -1
- package/src/rsc/server-action.ts +8 -0
- package/src/rsc/ssr-setup.ts +2 -2
- package/src/rsc/types.ts +9 -1
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +109 -23
- package/src/server/context.ts +166 -17
- package/src/server/handle-store.ts +19 -0
- package/src/server/loader-registry.ts +9 -8
- package/src/server/request-context.ts +175 -15
- package/src/ssr/index.tsx +4 -0
- package/src/static-handler.ts +18 -6
- package/src/types/cache-types.ts +4 -4
- package/src/types/handler-context.ts +137 -33
- package/src/types/loader-types.ts +36 -9
- package/src/types/route-entry.ts +12 -1
- package/src/types/segments.ts +2 -0
- package/src/urls/include-helper.ts +24 -14
- package/src/urls/path-helper-types.ts +39 -6
- package/src/urls/path-helper.ts +48 -13
- package/src/urls/pattern-types.ts +12 -0
- package/src/urls/response-types.ts +16 -6
- package/src/use-loader.tsx +77 -5
- package/src/vite/discovery/bundle-postprocess.ts +30 -33
- package/src/vite/discovery/discover-routers.ts +5 -1
- package/src/vite/discovery/prerender-collection.ts +128 -74
- package/src/vite/discovery/state.ts +13 -4
- package/src/vite/index.ts +4 -0
- package/src/vite/plugin-types.ts +60 -5
- package/src/vite/plugins/expose-id-utils.ts +12 -0
- package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
- package/src/vite/plugins/expose-internal-ids.ts +257 -40
- package/src/vite/plugins/performance-tracks.ts +88 -0
- package/src/vite/plugins/refresh-cmd.ts +88 -26
- package/src/vite/rango.ts +19 -2
- package/src/vite/router-discovery.ts +178 -37
- package/src/vite/utils/banner.ts +3 -3
- package/src/vite/utils/prerender-utils.ts +37 -5
- package/src/vite/utils/shared-utils.ts +3 -2
|
@@ -5,6 +5,7 @@ import React, {
|
|
|
5
5
|
useCallback,
|
|
6
6
|
useContext,
|
|
7
7
|
useEffect,
|
|
8
|
+
useMemo,
|
|
8
9
|
useRef,
|
|
9
10
|
type ForwardRefExoticComponent,
|
|
10
11
|
type RefAttributes,
|
|
@@ -32,6 +33,7 @@ export type LinkState =
|
|
|
32
33
|
| StateOrGetter<Record<string, unknown>>;
|
|
33
34
|
|
|
34
35
|
import { prefetchDirect, prefetchQueued } from "../prefetch/fetch.js";
|
|
36
|
+
import { getAppVersion } from "../app-version.js";
|
|
35
37
|
import {
|
|
36
38
|
observeForPrefetch,
|
|
37
39
|
unobserveForPrefetch,
|
|
@@ -95,6 +97,26 @@ export interface LinkProps extends Omit<
|
|
|
95
97
|
* @default "none"
|
|
96
98
|
*/
|
|
97
99
|
prefetch?: PrefetchStrategy;
|
|
100
|
+
/**
|
|
101
|
+
* Custom prefetch cache key for source-agnostic cache reuse.
|
|
102
|
+
* When set, prefetch responses are cached independently of the current
|
|
103
|
+
* page URL, so navigating to the same target from different source pages
|
|
104
|
+
* reuses the cached prefetch.
|
|
105
|
+
*
|
|
106
|
+
* - String: static group name (e.g., `"pages"`)
|
|
107
|
+
* - Function: receives current URL (`window.location.href`), returns a
|
|
108
|
+
* normalized source key
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* ```tsx
|
|
112
|
+
* // Static group — all "pages" links share one cache entry per target
|
|
113
|
+
* <Link to="/page/3" prefetch="hover" prefetchKey="pages" />
|
|
114
|
+
*
|
|
115
|
+
* // Normalize — strip trailing page number from source URL
|
|
116
|
+
* <Link to="/page/3" prefetch="hover" prefetchKey={(from) => from.replace(/\/\d+$/, '')} />
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
prefetchKey?: string | ((from: string) => string);
|
|
98
120
|
/**
|
|
99
121
|
* State to pass to history.pushState/replaceState.
|
|
100
122
|
* Accessible via useLocationState() hook.
|
|
@@ -182,6 +204,7 @@ export const Link: ForwardRefExoticComponent<
|
|
|
182
204
|
reloadDocument = false,
|
|
183
205
|
revalidate,
|
|
184
206
|
prefetch = "none",
|
|
207
|
+
prefetchKey,
|
|
185
208
|
state,
|
|
186
209
|
children,
|
|
187
210
|
onClick,
|
|
@@ -192,6 +215,16 @@ export const Link: ForwardRefExoticComponent<
|
|
|
192
215
|
const ctx = useContext(NavigationStoreContext);
|
|
193
216
|
const isExternal = isExternalUrl(to);
|
|
194
217
|
|
|
218
|
+
// Auto-prefix with basename for app-local paths.
|
|
219
|
+
// Skip if external, already prefixed, or not a root-relative path.
|
|
220
|
+
const resolvedTo = useMemo(() => {
|
|
221
|
+
if (isExternal) return to;
|
|
222
|
+
const bn = ctx?.basename;
|
|
223
|
+
if (!bn || !to.startsWith("/") || to.startsWith(bn + "/") || to === bn)
|
|
224
|
+
return to;
|
|
225
|
+
return to === "/" ? bn : bn + to;
|
|
226
|
+
}, [to, isExternal, ctx?.basename]);
|
|
227
|
+
|
|
195
228
|
// Resolve adaptive: viewport on touch devices, hover on pointer devices
|
|
196
229
|
const resolvedStrategy =
|
|
197
230
|
prefetch === "adaptive" ? (isTouchDevice ? "viewport" : "hover") : prefetch;
|
|
@@ -273,9 +306,23 @@ export const Link: ForwardRefExoticComponent<
|
|
|
273
306
|
resolvedState = currentState;
|
|
274
307
|
}
|
|
275
308
|
|
|
276
|
-
ctx.navigate(
|
|
309
|
+
ctx.navigate(resolvedTo, {
|
|
310
|
+
replace,
|
|
311
|
+
scroll,
|
|
312
|
+
state: resolvedState,
|
|
313
|
+
revalidate,
|
|
314
|
+
});
|
|
277
315
|
},
|
|
278
|
-
[
|
|
316
|
+
[
|
|
317
|
+
resolvedTo,
|
|
318
|
+
isExternal,
|
|
319
|
+
reloadDocument,
|
|
320
|
+
replace,
|
|
321
|
+
scroll,
|
|
322
|
+
revalidate,
|
|
323
|
+
ctx,
|
|
324
|
+
onClick,
|
|
325
|
+
],
|
|
279
326
|
);
|
|
280
327
|
|
|
281
328
|
const handleMouseEnter = useCallback(() => {
|
|
@@ -289,9 +336,15 @@ export const Link: ForwardRefExoticComponent<
|
|
|
289
336
|
// prefetch — prefetchDirect bypasses the queue, and hasPrefetch
|
|
290
337
|
// deduplicates if the viewport prefetch already completed.
|
|
291
338
|
const segmentState = ctx.store.getSegmentState();
|
|
292
|
-
prefetchDirect(
|
|
339
|
+
prefetchDirect(
|
|
340
|
+
resolvedTo,
|
|
341
|
+
segmentState.currentSegmentIds,
|
|
342
|
+
getAppVersion(),
|
|
343
|
+
ctx.store.getRouterId?.(),
|
|
344
|
+
prefetchKey,
|
|
345
|
+
);
|
|
293
346
|
}
|
|
294
|
-
}, [resolvedStrategy,
|
|
347
|
+
}, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
|
|
295
348
|
|
|
296
349
|
// Viewport/render prefetch: waits for idle before starting,
|
|
297
350
|
// uses concurrency-limited queue to avoid flooding.
|
|
@@ -308,7 +361,13 @@ export const Link: ForwardRefExoticComponent<
|
|
|
308
361
|
const triggerPrefetch = () => {
|
|
309
362
|
if (cancelled) return;
|
|
310
363
|
const segmentState = ctx.store.getSegmentState();
|
|
311
|
-
prefetchQueued(
|
|
364
|
+
prefetchQueued(
|
|
365
|
+
resolvedTo,
|
|
366
|
+
segmentState.currentSegmentIds,
|
|
367
|
+
getAppVersion(),
|
|
368
|
+
ctx.store.getRouterId?.(),
|
|
369
|
+
prefetchKey,
|
|
370
|
+
);
|
|
312
371
|
};
|
|
313
372
|
|
|
314
373
|
// Schedule prefetch only when the app is idle (no navigation/streaming).
|
|
@@ -347,12 +406,12 @@ export const Link: ForwardRefExoticComponent<
|
|
|
347
406
|
unobserveForPrefetch(observedElement);
|
|
348
407
|
}
|
|
349
408
|
};
|
|
350
|
-
}, [resolvedStrategy,
|
|
409
|
+
}, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
|
|
351
410
|
|
|
352
411
|
return (
|
|
353
412
|
<a
|
|
354
413
|
ref={setRef}
|
|
355
|
-
href={
|
|
414
|
+
href={resolvedTo}
|
|
356
415
|
onClick={handleClick}
|
|
357
416
|
onMouseEnter={handleMouseEnter}
|
|
358
417
|
data-link-component
|
|
@@ -362,7 +421,7 @@ export const Link: ForwardRefExoticComponent<
|
|
|
362
421
|
data-revalidate={revalidate === false ? "false" : undefined}
|
|
363
422
|
{...props}
|
|
364
423
|
>
|
|
365
|
-
<LinkContext.Provider value={
|
|
424
|
+
<LinkContext.Provider value={resolvedTo}>{children}</LinkContext.Provider>
|
|
366
425
|
</a>
|
|
367
426
|
);
|
|
368
427
|
});
|
|
@@ -134,9 +134,14 @@ export interface NavigationProviderProps {
|
|
|
134
134
|
|
|
135
135
|
/**
|
|
136
136
|
* App version from server payload (stable, immutable).
|
|
137
|
-
* Forwarded to
|
|
137
|
+
* Forwarded to context for cache key building.
|
|
138
138
|
*/
|
|
139
139
|
version?: string;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* URL prefix for all routes (from createRouter({ basename })).
|
|
143
|
+
*/
|
|
144
|
+
basename?: string;
|
|
140
145
|
}
|
|
141
146
|
|
|
142
147
|
/**
|
|
@@ -169,6 +174,7 @@ export function NavigationProvider({
|
|
|
169
174
|
initialTheme,
|
|
170
175
|
warmupEnabled,
|
|
171
176
|
version,
|
|
177
|
+
basename,
|
|
172
178
|
}: NavigationProviderProps): ReactNode {
|
|
173
179
|
// Track current payload for rendering (this triggers re-renders)
|
|
174
180
|
const [payload, setPayload] = useState(initialPayload);
|
|
@@ -198,6 +204,7 @@ export function NavigationProvider({
|
|
|
198
204
|
navigate,
|
|
199
205
|
refresh,
|
|
200
206
|
version,
|
|
207
|
+
basename,
|
|
201
208
|
}),
|
|
202
209
|
[],
|
|
203
210
|
);
|
|
@@ -289,15 +296,17 @@ export function NavigationProvider({
|
|
|
289
296
|
};
|
|
290
297
|
}, [warmupEnabled]);
|
|
291
298
|
|
|
292
|
-
// Cancel
|
|
293
|
-
//
|
|
299
|
+
// Cancel non-matching prefetches when navigation starts.
|
|
300
|
+
// Frees connections so the navigation fetch isn't competing with
|
|
301
|
+
// speculative prefetches. The prefetch matching the navigation target
|
|
302
|
+
// is kept alive so it can be reused via consumeInflightPrefetch.
|
|
294
303
|
useEffect(() => {
|
|
295
304
|
let wasIdle = true;
|
|
296
305
|
const unsub = eventController.subscribe(() => {
|
|
297
306
|
const state = eventController.getState();
|
|
298
307
|
const isIdle = state.state === "idle" && !state.isStreaming;
|
|
299
308
|
if (wasIdle && !isIdle) {
|
|
300
|
-
cancelAllPrefetches();
|
|
309
|
+
cancelAllPrefetches(state.pendingUrl);
|
|
301
310
|
}
|
|
302
311
|
wasIdle = isIdle;
|
|
303
312
|
});
|
|
@@ -43,10 +43,15 @@ export interface NavigationStoreContextValue {
|
|
|
43
43
|
refresh: () => Promise<void>;
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* App version from server payload
|
|
47
|
-
* Used in prefetch requests for version mismatch detection.
|
|
46
|
+
* App version from the initial server payload.
|
|
48
47
|
*/
|
|
49
48
|
version: string | undefined;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* URL prefix for all routes (from createRouter({ basename })).
|
|
52
|
+
* Used by Link and useRouter() to auto-prefix app-local paths.
|
|
53
|
+
*/
|
|
54
|
+
basename: string | undefined;
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
/**
|
|
@@ -9,64 +9,11 @@ import {
|
|
|
9
9
|
startTransition,
|
|
10
10
|
} from "react";
|
|
11
11
|
import type { Handle } from "../../handle.js";
|
|
12
|
-
import {
|
|
12
|
+
import { collectHandleData } from "../../handle.js";
|
|
13
13
|
import type { HandleData } from "../types.js";
|
|
14
14
|
import { NavigationStoreContext } from "./context.js";
|
|
15
15
|
import { shallowEqual } from "./shallow-equal.js";
|
|
16
16
|
|
|
17
|
-
/**
|
|
18
|
-
* Resolve the collect function for a handle.
|
|
19
|
-
* Handle objects are plain { __brand, $$id } - collect is stored in the registry
|
|
20
|
-
* (populated when createHandle runs on the client).
|
|
21
|
-
*/
|
|
22
|
-
function resolveCollect<T, A>(handle: Handle<T, A>): (segments: T[][]) => A {
|
|
23
|
-
// Look up collect from the registry (populated when the handle module is imported).
|
|
24
|
-
const registered = getCollectFn(handle.$$id);
|
|
25
|
-
if (registered) {
|
|
26
|
-
return registered as (segments: T[][]) => A;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Fall back to default flat collect with a dev warning.
|
|
30
|
-
if (process.env.NODE_ENV !== "production") {
|
|
31
|
-
console.warn(
|
|
32
|
-
`[rsc-router] Handle "${handle.$$id}" was passed as a prop but its collect ` +
|
|
33
|
-
`function could not be resolved. Falling back to flat array. ` +
|
|
34
|
-
`Import the handle module in a client component to register its collect function.`,
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
return ((segments: unknown[][]) => segments.flat()) as unknown as (
|
|
38
|
-
segments: T[][],
|
|
39
|
-
) => A;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Collect handle data from segments and transform to final value.
|
|
44
|
-
*/
|
|
45
|
-
function collectHandle<T, A>(
|
|
46
|
-
handle: Handle<T, A>,
|
|
47
|
-
data: HandleData,
|
|
48
|
-
segmentOrder: string[],
|
|
49
|
-
): A {
|
|
50
|
-
const collect = resolveCollect(handle);
|
|
51
|
-
const segmentData = data[handle.$$id];
|
|
52
|
-
|
|
53
|
-
if (!segmentData) {
|
|
54
|
-
return collect([]);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Build array of segment arrays in parent -> child order
|
|
58
|
-
const segmentArrays: T[][] = [];
|
|
59
|
-
for (const segmentId of segmentOrder) {
|
|
60
|
-
const entries = segmentData[segmentId];
|
|
61
|
-
if (entries && entries.length > 0) {
|
|
62
|
-
segmentArrays.push(entries as T[]);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Call collect once with all segment data
|
|
67
|
-
return collect(segmentArrays);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
17
|
/**
|
|
71
18
|
* Hook to access collected handle data.
|
|
72
19
|
*
|
|
@@ -99,13 +46,13 @@ export function useHandle<T, A, S>(
|
|
|
99
46
|
// Initial state from context event controller, or empty fallback without provider.
|
|
100
47
|
const [value, setValue] = useState<A | S>(() => {
|
|
101
48
|
if (!ctx) {
|
|
102
|
-
const collected =
|
|
49
|
+
const collected = collectHandleData(handle, {}, []);
|
|
103
50
|
return selector ? selector(collected) : collected;
|
|
104
51
|
}
|
|
105
52
|
|
|
106
53
|
// On client, use event controller state
|
|
107
54
|
const state = ctx.eventController.getHandleState();
|
|
108
|
-
const collected =
|
|
55
|
+
const collected = collectHandleData(handle, state.data, state.segmentOrder);
|
|
109
56
|
return selector ? selector(collected) : collected;
|
|
110
57
|
});
|
|
111
58
|
const [optimisticValue, setOptimisticValue] = useOptimistic(value);
|
|
@@ -125,7 +72,7 @@ export function useHandle<T, A, S>(
|
|
|
125
72
|
// Sync current state for the (possibly new) handle so that switching
|
|
126
73
|
// handles on an idle page doesn't leave stale data from the old handle.
|
|
127
74
|
const currentHandleState = ctx.eventController.getHandleState();
|
|
128
|
-
const currentCollected =
|
|
75
|
+
const currentCollected = collectHandleData(
|
|
129
76
|
handle,
|
|
130
77
|
currentHandleState.data,
|
|
131
78
|
currentHandleState.segmentOrder,
|
|
@@ -142,7 +89,11 @@ export function useHandle<T, A, S>(
|
|
|
142
89
|
const state = ctx.eventController.getHandleState();
|
|
143
90
|
const isAction =
|
|
144
91
|
ctx.eventController.getState().inflightActions.length > 0;
|
|
145
|
-
const collected =
|
|
92
|
+
const collected = collectHandleData(
|
|
93
|
+
handle,
|
|
94
|
+
state.data,
|
|
95
|
+
state.segmentOrder,
|
|
96
|
+
);
|
|
146
97
|
const nextValue = selectorRef.current
|
|
147
98
|
? selectorRef.current(collected)
|
|
148
99
|
: collected;
|
|
@@ -78,16 +78,17 @@ export function useNavigation<T>(
|
|
|
78
78
|
if (!shallowEqual(nextSelected, prevState.current)) {
|
|
79
79
|
prevState.current = nextSelected;
|
|
80
80
|
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
81
|
+
// Always mirror into the optimistic store inside a transition.
|
|
82
|
+
// useOptimistic returns the optimistic value while any transition that
|
|
83
|
+
// includes a setOptimisticValue is pending. Calling it only when
|
|
84
|
+
// entering "loading" left the optimistic store stuck on the previous
|
|
85
|
+
// value when going back to "idle" — if a parent transition (e.g., a
|
|
86
|
+
// <Link> click) was still pending, useOptimistic would keep returning
|
|
87
|
+
// the stale loading value even after baseValue flipped to idle.
|
|
88
|
+
// Updating in both directions keeps the optimistic store in sync.
|
|
89
|
+
startTransition(() => {
|
|
90
|
+
setOptimisticValue(nextSelected);
|
|
91
|
+
});
|
|
91
92
|
|
|
92
93
|
// Always update base state so UI reflects current state
|
|
93
94
|
setBaseValue(nextSelected);
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useContext, useMemo } from "react";
|
|
4
4
|
import { NavigationStoreContext } from "./context.js";
|
|
5
5
|
import { prefetchDirect } from "../prefetch/fetch.js";
|
|
6
|
+
import { getAppVersion } from "../app-version.js";
|
|
6
7
|
import type { RouterInstance, RouterNavigateOptions } from "../types.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -29,14 +30,22 @@ export function useRouter(): RouterInstance {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
// Stable reference: ctx is itself stable (NavigationProvider memoizes with [])
|
|
32
|
-
return useMemo<RouterInstance>(
|
|
33
|
-
|
|
33
|
+
return useMemo<RouterInstance>(() => {
|
|
34
|
+
/** Prefix a root-relative path with basename if not already prefixed. */
|
|
35
|
+
function withBasename(url: string): string {
|
|
36
|
+
const bn = ctx!.basename;
|
|
37
|
+
if (!bn || !url.startsWith("/") || url.startsWith(bn + "/") || url === bn)
|
|
38
|
+
return url;
|
|
39
|
+
return url === "/" ? bn : bn + url;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
34
43
|
push(url: string, options?: RouterNavigateOptions): Promise<void> {
|
|
35
|
-
return ctx.navigate(url, { ...options, replace: false });
|
|
44
|
+
return ctx.navigate(withBasename(url), { ...options, replace: false });
|
|
36
45
|
},
|
|
37
46
|
|
|
38
47
|
replace(url: string, options?: RouterNavigateOptions): Promise<void> {
|
|
39
|
-
return ctx.navigate(url, { ...options, replace: true });
|
|
48
|
+
return ctx.navigate(withBasename(url), { ...options, replace: true });
|
|
40
49
|
},
|
|
41
50
|
|
|
42
51
|
refresh(): Promise<void> {
|
|
@@ -46,7 +55,12 @@ export function useRouter(): RouterInstance {
|
|
|
46
55
|
prefetch(url: string): void {
|
|
47
56
|
const segmentState = ctx.store?.getSegmentState();
|
|
48
57
|
if (segmentState) {
|
|
49
|
-
prefetchDirect(
|
|
58
|
+
prefetchDirect(
|
|
59
|
+
withBasename(url),
|
|
60
|
+
segmentState.currentSegmentIds,
|
|
61
|
+
getAppVersion(),
|
|
62
|
+
ctx.store?.getRouterId?.(),
|
|
63
|
+
);
|
|
50
64
|
}
|
|
51
65
|
},
|
|
52
66
|
|
|
@@ -57,7 +71,6 @@ export function useRouter(): RouterInstance {
|
|
|
57
71
|
forward(): void {
|
|
58
72
|
window.history.forward();
|
|
59
73
|
},
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
);
|
|
74
|
+
};
|
|
75
|
+
}, []);
|
|
63
76
|
}
|
|
@@ -23,6 +23,7 @@ import type { EventController } from "./event-controller.js";
|
|
|
23
23
|
import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
|
|
24
24
|
import { initRangoState } from "./rango-state.js";
|
|
25
25
|
import { initPrefetchCache } from "./prefetch/cache.js";
|
|
26
|
+
import { setAppVersion } from "./app-version.js";
|
|
26
27
|
import {
|
|
27
28
|
isInterceptSegment,
|
|
28
29
|
splitInterceptSegments,
|
|
@@ -139,7 +140,6 @@ export async function initBrowserApp(
|
|
|
139
140
|
initialTheme,
|
|
140
141
|
} = options;
|
|
141
142
|
|
|
142
|
-
// Load initial payload from SSR-injected __FLIGHT_DATA__
|
|
143
143
|
const initialPayload =
|
|
144
144
|
await deps.createFromReadableStream<RscPayload>(rscStream);
|
|
145
145
|
|
|
@@ -164,6 +164,12 @@ export async function initBrowserApp(
|
|
|
164
164
|
...(storeOptions?.cacheSize && { cacheSize: storeOptions.cacheSize }),
|
|
165
165
|
});
|
|
166
166
|
|
|
167
|
+
// Seed router identity from the initial SSR payload so the first
|
|
168
|
+
// cross-app SPA navigation can detect the app switch.
|
|
169
|
+
if (initialPayload.metadata?.routerId) {
|
|
170
|
+
store.setRouterId?.(initialPayload.metadata.routerId);
|
|
171
|
+
}
|
|
172
|
+
|
|
167
173
|
// Create event controller for reactive state management
|
|
168
174
|
const eventController = createEventController({
|
|
169
175
|
initialLocation: new URL(window.location.href),
|
|
@@ -205,6 +211,7 @@ export async function initBrowserApp(
|
|
|
205
211
|
// Initialize the localStorage state key for cache invalidation.
|
|
206
212
|
// Uses the build version so a new deploy automatically busts all cached prefetches.
|
|
207
213
|
initRangoState(version ?? "0");
|
|
214
|
+
setAppVersion(version);
|
|
208
215
|
|
|
209
216
|
// Initialize the in-memory prefetch cache TTL from server config.
|
|
210
217
|
// A value of 0 disables the cache; undefined falls back to the module default.
|
|
@@ -231,7 +238,6 @@ export async function initBrowserApp(
|
|
|
231
238
|
deps,
|
|
232
239
|
onUpdate: (update) => store.emitUpdate(update),
|
|
233
240
|
renderSegments,
|
|
234
|
-
version,
|
|
235
241
|
onNavigate: (url, options) => {
|
|
236
242
|
if (!navigateFn) {
|
|
237
243
|
window.location.href = url;
|
|
@@ -249,7 +255,7 @@ export async function initBrowserApp(
|
|
|
249
255
|
client,
|
|
250
256
|
onUpdate: (update) => store.emitUpdate(update),
|
|
251
257
|
renderSegments,
|
|
252
|
-
version,
|
|
258
|
+
version: version,
|
|
253
259
|
});
|
|
254
260
|
|
|
255
261
|
// Connect action redirect → navigation bridge (now that both are initialized)
|
|
@@ -286,6 +292,18 @@ export async function initBrowserApp(
|
|
|
286
292
|
// Debounce: wait 200ms of quiet before fetching
|
|
287
293
|
hmrTimer = setTimeout(async () => {
|
|
288
294
|
hmrTimer = null;
|
|
295
|
+
|
|
296
|
+
// Don't interrupt an active user navigation — startNavigation()
|
|
297
|
+
// would abort it and refetch the old URL (window.location.href
|
|
298
|
+
// hasn't updated yet). The user's navigation will pick up the
|
|
299
|
+
// new server code when it completes. isNavigating covers the
|
|
300
|
+
// full lifecycle (fetching + streaming, before commit) without
|
|
301
|
+
// blocking on server actions.
|
|
302
|
+
if (eventController.getState().isNavigating) {
|
|
303
|
+
console.log("[RSCRouter] HMR: Skipping — navigation in progress");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
289
307
|
console.log("[RSCRouter] HMR: Server update, refetching RSC");
|
|
290
308
|
|
|
291
309
|
const abort = new AbortController();
|
|
@@ -304,12 +322,35 @@ export async function initBrowserApp(
|
|
|
304
322
|
segmentIds: [],
|
|
305
323
|
previousUrl: store.getSegmentState().currentUrl,
|
|
306
324
|
interceptSourceUrl: interceptSourceUrl || undefined,
|
|
325
|
+
routerId: store.getRouterId?.(),
|
|
307
326
|
hmr: true,
|
|
308
327
|
signal: abort.signal,
|
|
309
328
|
});
|
|
310
329
|
|
|
311
330
|
if (abort.signal.aborted) return;
|
|
312
331
|
|
|
332
|
+
// If the server returned a non-RSC response (404, 500 without
|
|
333
|
+
// error boundary), the payload won't have valid metadata.
|
|
334
|
+
// Reload to recover rather than leaving the page stale.
|
|
335
|
+
if (!payload.metadata) {
|
|
336
|
+
throw new Error("HMR refetch returned invalid payload");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Update version BEFORE rebuilding state so that
|
|
340
|
+
// clearHistoryCache() runs first, then the fresh segment
|
|
341
|
+
// cache entry we create below survives.
|
|
342
|
+
const newVersion = payload.metadata.version;
|
|
343
|
+
if (newVersion && newVersion !== version) {
|
|
344
|
+
console.log(
|
|
345
|
+
"[RSCRouter] HMR: version changed",
|
|
346
|
+
version,
|
|
347
|
+
"→",
|
|
348
|
+
newVersion,
|
|
349
|
+
"clearing caches",
|
|
350
|
+
);
|
|
351
|
+
navigationBridge.updateVersion(newVersion);
|
|
352
|
+
}
|
|
353
|
+
|
|
313
354
|
if (payload.metadata?.isPartial) {
|
|
314
355
|
const segments = payload.metadata.segments || [];
|
|
315
356
|
const matched = payload.metadata.matched || [];
|
|
@@ -459,6 +500,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
|
|
|
459
500
|
initialTheme={initialTheme}
|
|
460
501
|
warmupEnabled={warmupEnabled}
|
|
461
502
|
version={version}
|
|
503
|
+
basename={initialPayload.metadata?.basename}
|
|
462
504
|
/>
|
|
463
505
|
);
|
|
464
506
|
}
|
|
@@ -356,19 +356,18 @@ export function handleNavigationEnd(options: {
|
|
|
356
356
|
scroll?: boolean;
|
|
357
357
|
isStreaming?: () => boolean;
|
|
358
358
|
}): void {
|
|
359
|
-
if (!initialized) {
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
359
|
const { restore = false, scroll = true, isStreaming } = options;
|
|
364
360
|
|
|
365
|
-
// Don't scroll if explicitly disabled
|
|
366
|
-
if (scroll === false) {
|
|
361
|
+
// Don't scroll if explicitly disabled or not in a browser
|
|
362
|
+
if (scroll === false || typeof window === "undefined") {
|
|
367
363
|
return;
|
|
368
364
|
}
|
|
369
365
|
|
|
370
|
-
//
|
|
371
|
-
|
|
366
|
+
// Save/restore requires initialization (sessionStorage, history state).
|
|
367
|
+
// But basic scroll-to-top and hash scrolling work without it — this
|
|
368
|
+
// matters during cross-app navigation where ScrollRestoration unmounts
|
|
369
|
+
// and remounts, creating a brief window where initialized is false.
|
|
370
|
+
if (restore && initialized) {
|
|
372
371
|
if (restoreScrollPosition({ retryIfStreaming: true, isStreaming })) {
|
|
373
372
|
return;
|
|
374
373
|
}
|
|
@@ -378,6 +377,9 @@ export function handleNavigationEnd(options: {
|
|
|
378
377
|
// Defer hash and scroll-to-top to after React paints the new content,
|
|
379
378
|
// so the user doesn't see the current page jump before the new route appears.
|
|
380
379
|
deferToNextPaint(() => {
|
|
380
|
+
// Re-check: the deferred callback may fire after environment teardown
|
|
381
|
+
if (typeof window === "undefined") return;
|
|
382
|
+
|
|
381
383
|
// Try hash scrolling first
|
|
382
384
|
if (scrollToHash()) {
|
|
383
385
|
return;
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "./merge-segment-loaders.js";
|
|
7
7
|
import { assertSegmentStructure } from "./segment-structure-assert.js";
|
|
8
8
|
import { splitInterceptSegments } from "./intercept-utils.js";
|
|
9
|
+
import { debugLog } from "./logging.js";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Determines the merging behavior for segment reconciliation.
|
|
@@ -85,14 +86,29 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
|
|
|
85
86
|
const cachedSegments = new Map<string, ResolvedSegment>();
|
|
86
87
|
input.cachedSegments.forEach((s) => cachedSegments.set(s.id, s));
|
|
87
88
|
|
|
89
|
+
const diffSet = new Set(diff);
|
|
90
|
+
debugLog(
|
|
91
|
+
`[reconcile] actor=${actor}, matched=${matched.length}, diff=${diff.length}`,
|
|
92
|
+
);
|
|
93
|
+
debugLog(
|
|
94
|
+
`[reconcile] server segments: ${[...serverSegments.keys()].join(", ")}`,
|
|
95
|
+
);
|
|
96
|
+
debugLog(
|
|
97
|
+
`[reconcile] cached segments: ${[...cachedSegments.keys()].join(", ")}`,
|
|
98
|
+
);
|
|
99
|
+
|
|
88
100
|
const segments = matched
|
|
89
101
|
.map((segId: string) => {
|
|
90
102
|
const fromServer = serverSegments.get(segId);
|
|
91
103
|
const fromCache = cachedSegments.get(segId);
|
|
92
104
|
|
|
93
105
|
if (fromServer) {
|
|
106
|
+
const inDiff = diffSet.has(segId);
|
|
94
107
|
// Merge partial loader data when server returns fewer loaders than cached
|
|
95
108
|
if (shouldMergeLoaders && needsLoaderMerge(fromServer, fromCache)) {
|
|
109
|
+
debugLog(
|
|
110
|
+
`[reconcile] ${segId}: MERGE loaders (server partial, ${inDiff ? "in diff" : "not in diff"})`,
|
|
111
|
+
);
|
|
96
112
|
return mergeSegmentLoaders(fromServer, fromCache);
|
|
97
113
|
}
|
|
98
114
|
|
|
@@ -143,8 +159,14 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
|
|
|
143
159
|
// above fails to preserve a value it should have.
|
|
144
160
|
assertSegmentStructure(fromCache, merged, context);
|
|
145
161
|
|
|
162
|
+
debugLog(
|
|
163
|
+
`[reconcile] ${segId}: SERVER+CACHE merge (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, component=${fromServer.component === null ? "null→cached" : "server"})`,
|
|
164
|
+
);
|
|
146
165
|
return merged;
|
|
147
166
|
}
|
|
167
|
+
debugLog(
|
|
168
|
+
`[reconcile] ${segId}: SERVER only (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, no cache entry)`,
|
|
169
|
+
);
|
|
148
170
|
return fromServer;
|
|
149
171
|
}
|
|
150
172
|
|
|
@@ -158,15 +180,20 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
|
|
|
158
180
|
return fromCache;
|
|
159
181
|
}
|
|
160
182
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
183
|
+
debugLog(
|
|
184
|
+
`[reconcile] ${segId}: CACHE only (not from server, type=${fromCache.type}, component=${fromCache.component != null ? "yes" : "null"})`,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// Return the cached segment as-is, regardless of actor. We used to clear
|
|
188
|
+
// truthy `loading` here to prevent a stale Suspense fallback from
|
|
189
|
+
// committing against cached content, but that swapped the render tree
|
|
190
|
+
// from the LoaderBoundary branch to the plain OutletProvider branch
|
|
191
|
+
// inside renderSegments, causing React to unmount the entire chain
|
|
192
|
+
// (LoaderBoundary > Suspense > LoaderResolver > RouteContentWrapper >
|
|
193
|
+
// Suspender) every time the user opened an intercept or navigated back
|
|
194
|
+
// to a cached page. The flicker is now prevented by renderSegments'
|
|
195
|
+
// promise memoization keeping React's use() in "known fulfilled" state,
|
|
196
|
+
// so preserving `loading` keeps the element tree stable.
|
|
170
197
|
return fromCache;
|
|
171
198
|
})
|
|
172
199
|
.filter(Boolean) as ResolvedSegment[];
|