@rangojs/router 0.0.0-experimental.8a4d0430 → 0.0.0-experimental.8bd1b239
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/AGENTS.md +4 -0
- package/README.md +76 -18
- package/dist/bin/rango.js +138 -50
- package/dist/vite/index.js +853 -435
- package/package.json +17 -16
- package/skills/cache-guide/SKILL.md +32 -0
- package/skills/caching/SKILL.md +45 -4
- 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 +40 -15
- package/src/browser/navigation-client.ts +142 -57
- package/src/browser/navigation-store.ts +43 -8
- package/src/browser/navigation-transaction.ts +11 -9
- package/src/browser/partial-update.ts +94 -17
- package/src/browser/prefetch/cache.ts +82 -12
- package/src/browser/prefetch/fetch.ts +98 -27
- package/src/browser/prefetch/policy.ts +6 -0
- package/src/browser/prefetch/queue.ts +92 -20
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/react/Link.tsx +76 -9
- package/src/browser/react/NavigationProvider.tsx +40 -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 +134 -59
- package/src/browser/scroll-restoration.ts +41 -42
- package/src/browser/segment-reconciler.ts +36 -9
- package/src/browser/server-action-bridge.ts +8 -6
- package/src/browser/types.ts +36 -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 +223 -74
- package/src/build/route-types/scan-filter.ts +8 -1
- package/src/cache/cache-runtime.ts +15 -11
- package/src/cache/cache-scope.ts +48 -7
- package/src/cache/cf/cf-cache-store.ts +453 -11
- package/src/cache/cf/index.ts +5 -1
- package/src/cache/document-cache.ts +17 -7
- package/src/cache/index.ts +1 -0
- 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 +11 -3
- package/src/route-definition/resolve-handler-use.ts +149 -0
- package/src/route-map-builder.ts +7 -1
- package/src/route-types.ts +11 -0
- package/src/router/content-negotiation.ts +100 -1
- package/src/router/find-match.ts +4 -2
- package/src/router/handler-context.ts +82 -23
- package/src/router/intercept-resolution.ts +11 -4
- package/src/router/lazy-includes.ts +4 -1
- package/src/router/loader-resolution.ts +156 -21
- package/src/router/logging.ts +5 -2
- package/src/router/manifest.ts +9 -3
- 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 +61 -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 +4 -6
- 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 +6 -1
- 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 +438 -300
- package/src/router/segment-wrappers.ts +2 -0
- package/src/router/types.ts +1 -0
- package/src/router.ts +59 -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 +12 -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 +140 -14
- package/src/server/handle-store.ts +19 -0
- package/src/server/loader-registry.ts +9 -8
- package/src/server/request-context.ts +185 -19
- 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 +8 -1
- package/src/types/segments.ts +2 -0
- 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 -6
- package/src/vite/index.ts +4 -0
- package/src/vite/plugin-types.ts +51 -79
- package/src/vite/plugins/expose-action-id.ts +1 -3
- 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/plugins/version-plugin.ts +13 -1
- package/src/vite/rango.ts +163 -211
- package/src/vite/router-discovery.ts +178 -45
- 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
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
import React, {
|
|
4
4
|
useState,
|
|
5
5
|
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
6
7
|
useCallback,
|
|
7
8
|
useMemo,
|
|
9
|
+
useRef,
|
|
8
10
|
use,
|
|
9
11
|
type ReactNode,
|
|
10
12
|
} from "react";
|
|
@@ -25,6 +27,7 @@ import { ThemeProvider } from "../../theme/ThemeProvider.js";
|
|
|
25
27
|
import { NonceContext } from "./nonce-context.js";
|
|
26
28
|
import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
|
|
27
29
|
import { cancelAllPrefetches } from "../prefetch/queue.js";
|
|
30
|
+
import { handleNavigationEnd } from "../scroll-restoration.js";
|
|
28
31
|
|
|
29
32
|
/**
|
|
30
33
|
* Process handles from an async generator, updating the event controller
|
|
@@ -131,9 +134,14 @@ export interface NavigationProviderProps {
|
|
|
131
134
|
|
|
132
135
|
/**
|
|
133
136
|
* App version from server payload (stable, immutable).
|
|
134
|
-
* Forwarded to
|
|
137
|
+
* Forwarded to context for cache key building.
|
|
135
138
|
*/
|
|
136
139
|
version?: string;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* URL prefix for all routes (from createRouter({ basename })).
|
|
143
|
+
*/
|
|
144
|
+
basename?: string;
|
|
137
145
|
}
|
|
138
146
|
|
|
139
147
|
/**
|
|
@@ -166,6 +174,7 @@ export function NavigationProvider({
|
|
|
166
174
|
initialTheme,
|
|
167
175
|
warmupEnabled,
|
|
168
176
|
version,
|
|
177
|
+
basename,
|
|
169
178
|
}: NavigationProviderProps): ReactNode {
|
|
170
179
|
// Track current payload for rendering (this triggers re-renders)
|
|
171
180
|
const [payload, setPayload] = useState(initialPayload);
|
|
@@ -195,6 +204,7 @@ export function NavigationProvider({
|
|
|
195
204
|
navigate,
|
|
196
205
|
refresh,
|
|
197
206
|
version,
|
|
207
|
+
basename,
|
|
198
208
|
}),
|
|
199
209
|
[],
|
|
200
210
|
);
|
|
@@ -286,24 +296,50 @@ export function NavigationProvider({
|
|
|
286
296
|
};
|
|
287
297
|
}, [warmupEnabled]);
|
|
288
298
|
|
|
289
|
-
// Cancel
|
|
290
|
-
//
|
|
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.
|
|
291
303
|
useEffect(() => {
|
|
292
304
|
let wasIdle = true;
|
|
293
305
|
const unsub = eventController.subscribe(() => {
|
|
294
306
|
const state = eventController.getState();
|
|
295
307
|
const isIdle = state.state === "idle" && !state.isStreaming;
|
|
296
308
|
if (wasIdle && !isIdle) {
|
|
297
|
-
cancelAllPrefetches();
|
|
309
|
+
cancelAllPrefetches(state.pendingUrl);
|
|
298
310
|
}
|
|
299
311
|
wasIdle = isIdle;
|
|
300
312
|
});
|
|
301
313
|
return unsub;
|
|
302
314
|
}, [eventController]);
|
|
303
315
|
|
|
316
|
+
// Pending scroll action to apply after React commits
|
|
317
|
+
const pendingScrollRef = useRef<NavigationUpdate["scroll"]>(undefined);
|
|
318
|
+
|
|
319
|
+
// Apply scroll after React commits the new content to the DOM
|
|
320
|
+
useLayoutEffect(() => {
|
|
321
|
+
const scrollAction = pendingScrollRef.current;
|
|
322
|
+
if (!scrollAction) return;
|
|
323
|
+
pendingScrollRef.current = undefined;
|
|
324
|
+
|
|
325
|
+
if (scrollAction.enabled === false) return;
|
|
326
|
+
|
|
327
|
+
handleNavigationEnd({
|
|
328
|
+
restore: scrollAction.restore,
|
|
329
|
+
scroll: scrollAction.enabled,
|
|
330
|
+
isStreaming: scrollAction.isStreaming,
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
304
334
|
// Subscribe to UI updates (for re-rendering the tree)
|
|
305
335
|
useEffect(() => {
|
|
306
336
|
const unsubscribe = store.onUpdate((update) => {
|
|
337
|
+
// Capture scroll intent — it will be applied in useLayoutEffect
|
|
338
|
+
// after React commits this state update to the DOM.
|
|
339
|
+
// Always assign (even undefined) to clear stale scroll from prior navigations,
|
|
340
|
+
// so server actions or error updates don't accidentally replay old scroll.
|
|
341
|
+
pendingScrollRef.current = update.scroll;
|
|
342
|
+
|
|
307
343
|
setPayload({
|
|
308
344
|
root: update.root,
|
|
309
345
|
metadata: update.metadata,
|
|
@@ -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)
|
|
@@ -263,71 +269,139 @@ export async function initBrowserApp(
|
|
|
263
269
|
// Build initial tree with rootLayout
|
|
264
270
|
const initialTree = renderSegments(initialPayload.metadata!.segments);
|
|
265
271
|
|
|
266
|
-
// Setup HMR
|
|
272
|
+
// Setup HMR with debounce — burst saves (format-on-save, rapid edits)
|
|
273
|
+
// fire many rsc:update events in quick succession. Without debouncing,
|
|
274
|
+
// each event triggers a fetchPartial() which on slow routes can pile up
|
|
275
|
+
// and overwhelm the worker (cross-request promise issues, 500s).
|
|
267
276
|
if (import.meta.hot) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
277
|
+
let hmrTimer: ReturnType<typeof setTimeout> | null = null;
|
|
278
|
+
let hmrAbort: AbortController | null = null;
|
|
279
|
+
|
|
280
|
+
import.meta.hot.on("rsc:update", () => {
|
|
281
|
+
// Cancel any pending debounce timer
|
|
282
|
+
if (hmrTimer !== null) {
|
|
283
|
+
clearTimeout(hmrTimer);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Abort any in-flight HMR fetch so it doesn't race with the next one
|
|
287
|
+
if (hmrAbort) {
|
|
288
|
+
hmrAbort.abort();
|
|
289
|
+
hmrAbort = null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Debounce: wait 200ms of quiet before fetching
|
|
293
|
+
hmrTimer = setTimeout(async () => {
|
|
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
|
+
|
|
307
|
+
console.log("[RSCRouter] HMR: Server update, refetching RSC");
|
|
308
|
+
|
|
309
|
+
const abort = new AbortController();
|
|
310
|
+
hmrAbort = abort;
|
|
311
|
+
|
|
312
|
+
const handle = eventController.startNavigation(window.location.href, {
|
|
313
|
+
replace: true,
|
|
285
314
|
});
|
|
315
|
+
const streamingToken = handle.startStreaming();
|
|
316
|
+
|
|
317
|
+
const interceptSourceUrl = store.getInterceptSourceUrl();
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
const { payload, streamComplete } = await client.fetchPartial({
|
|
321
|
+
targetUrl: window.location.href,
|
|
322
|
+
segmentIds: [],
|
|
323
|
+
previousUrl: store.getSegmentState().currentUrl,
|
|
324
|
+
interceptSourceUrl: interceptSourceUrl || undefined,
|
|
325
|
+
routerId: store.getRouterId?.(),
|
|
326
|
+
hmr: true,
|
|
327
|
+
signal: abort.signal,
|
|
328
|
+
});
|
|
286
329
|
|
|
287
|
-
|
|
288
|
-
const segments = payload.metadata.segments || [];
|
|
289
|
-
const matched = payload.metadata.matched || [];
|
|
330
|
+
if (abort.signal.aborted) return;
|
|
290
331
|
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
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
|
+
}
|
|
295
338
|
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
|
|
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);
|
|
299
352
|
}
|
|
300
353
|
|
|
301
|
-
|
|
302
|
-
|
|
354
|
+
if (payload.metadata?.isPartial) {
|
|
355
|
+
const segments = payload.metadata.segments || [];
|
|
356
|
+
const matched = payload.metadata.matched || [];
|
|
357
|
+
|
|
358
|
+
// Derive intercept state from the returned payload, not the
|
|
359
|
+
// pre-fetch store snapshot. If the HMR edit removed intercept
|
|
360
|
+
// behavior, the response won't contain intercept segments.
|
|
361
|
+
const responseIsIntercept = segments.some(isInterceptSegment);
|
|
362
|
+
|
|
363
|
+
// Sync store intercept state with what the server returned
|
|
364
|
+
if (!responseIsIntercept && interceptSourceUrl) {
|
|
365
|
+
store.setInterceptSourceUrl(null);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
store.setSegmentIds(matched);
|
|
369
|
+
store.setCurrentUrl(window.location.href);
|
|
370
|
+
|
|
371
|
+
const historyKey = generateHistoryKey(window.location.href, {
|
|
372
|
+
intercept: responseIsIntercept,
|
|
373
|
+
});
|
|
374
|
+
store.setHistoryKey(historyKey);
|
|
375
|
+
const currentHandleData = eventController.getHandleState().data;
|
|
376
|
+
store.cacheSegmentsForHistory(
|
|
377
|
+
historyKey,
|
|
378
|
+
segments,
|
|
379
|
+
currentHandleData,
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const { main, intercept } = splitInterceptSegments(segments);
|
|
383
|
+
store.emitUpdate({
|
|
384
|
+
root: renderSegments(main, {
|
|
385
|
+
interceptSegments: intercept.length > 0 ? intercept : undefined,
|
|
386
|
+
}),
|
|
387
|
+
metadata: payload.metadata,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
303
390
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
);
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
store.emitUpdate({
|
|
317
|
-
root: renderSegments(main, {
|
|
318
|
-
interceptSegments: intercept.length > 0 ? intercept : undefined,
|
|
319
|
-
}),
|
|
320
|
-
metadata: payload.metadata,
|
|
321
|
-
});
|
|
391
|
+
await streamComplete;
|
|
392
|
+
handle.complete(new URL(window.location.href));
|
|
393
|
+
console.log("[RSCRouter] HMR: RSC stream complete");
|
|
394
|
+
} catch (err) {
|
|
395
|
+
if (abort.signal.aborted) return;
|
|
396
|
+
console.warn("[RSCRouter] HMR: Refetch failed, reloading page", err);
|
|
397
|
+
window.location.reload();
|
|
398
|
+
return;
|
|
399
|
+
} finally {
|
|
400
|
+
if (hmrAbort === abort) hmrAbort = null;
|
|
401
|
+
streamingToken.end();
|
|
402
|
+
handle[Symbol.dispose]();
|
|
322
403
|
}
|
|
323
|
-
|
|
324
|
-
await streamComplete;
|
|
325
|
-
handle.complete(new URL(window.location.href));
|
|
326
|
-
console.log("[RSCRouter] HMR: RSC stream complete");
|
|
327
|
-
} finally {
|
|
328
|
-
streamingToken.end();
|
|
329
|
-
handle[Symbol.dispose]();
|
|
330
|
-
}
|
|
404
|
+
}, 200);
|
|
331
405
|
});
|
|
332
406
|
}
|
|
333
407
|
|
|
@@ -426,6 +500,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
|
|
|
426
500
|
initialTheme={initialTheme}
|
|
427
501
|
warmupEnabled={warmupEnabled}
|
|
428
502
|
version={version}
|
|
503
|
+
basename={initialPayload.metadata?.basename}
|
|
429
504
|
/>
|
|
430
505
|
);
|
|
431
506
|
}
|