@rangojs/router 0.0.0-experimental.8a4d0430 → 0.0.0-experimental.8bcfea43
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 +126 -38
- package/dist/bin/rango.js +138 -50
- package/dist/vite/index.js +1171 -461
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +19 -16
- package/skills/breadcrumbs/SKILL.md +3 -1
- 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/hooks/SKILL.md +28 -20
- package/skills/intercept/SKILL.md +20 -0
- package/skills/layout/SKILL.md +22 -0
- package/skills/links/SKILL.md +91 -17
- package/skills/loader/SKILL.md +88 -45
- package/skills/middleware/SKILL.md +34 -3
- package/skills/migrate-nextjs/SKILL.md +560 -0
- package/skills/migrate-react-router/SKILL.md +765 -0
- package/skills/parallel/SKILL.md +185 -0
- package/skills/prerender/SKILL.md +110 -68
- package/skills/rango/SKILL.md +24 -22
- package/skills/response-routes/SKILL.md +8 -0
- package/skills/route/SKILL.md +55 -0
- package/skills/router-setup/SKILL.md +87 -2
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/typesafety/SKILL.md +13 -1
- package/src/__internal.ts +1 -1
- package/src/browser/app-shell.ts +52 -0
- package/src/browser/app-version.ts +14 -0
- package/src/browser/event-controller.ts +5 -0
- package/src/browser/navigation-bridge.ts +90 -16
- package/src/browser/navigation-client.ts +167 -59
- package/src/browser/navigation-store.ts +68 -9
- package/src/browser/navigation-transaction.ts +11 -9
- package/src/browser/partial-update.ts +113 -17
- package/src/browser/prefetch/cache.ts +184 -16
- package/src/browser/prefetch/fetch.ts +180 -33
- package/src/browser/prefetch/policy.ts +6 -0
- package/src/browser/prefetch/queue.ts +123 -20
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/rango-state.ts +53 -13
- package/src/browser/react/Link.tsx +81 -9
- package/src/browser/react/NavigationProvider.tsx +89 -14
- package/src/browser/react/context.ts +7 -2
- package/src/browser/react/use-handle.ts +9 -58
- package/src/browser/react/use-navigation.ts +22 -2
- package/src/browser/react/use-params.ts +11 -1
- package/src/browser/react/use-router.ts +29 -9
- package/src/browser/rsc-router.tsx +168 -65
- 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 +49 -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 +455 -15
- 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 +6 -1
- package/src/index.ts +49 -6
- package/src/outlet-context.ts +1 -1
- package/src/prerender/store.ts +5 -4
- package/src/prerender.ts +138 -77
- package/src/response-utils.ts +28 -0
- package/src/reverse.ts +27 -2
- package/src/route-definition/dsl-helpers.ts +240 -40
- 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 +155 -0
- package/src/route-map-builder.ts +7 -1
- package/src/route-types.ts +18 -0
- package/src/router/content-negotiation.ts +100 -1
- package/src/router/find-match.ts +4 -2
- package/src/router/handler-context.ts +101 -25
- package/src/router/intercept-resolution.ts +11 -4
- package/src/router/lazy-includes.ts +10 -7
- package/src/router/loader-resolution.ts +159 -21
- package/src/router/logging.ts +5 -2
- package/src/router/manifest.ts +31 -16
- package/src/router/match-api.ts +127 -192
- 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 +8 -30
- package/src/router/middleware.ts +36 -10
- package/src/router/navigation-snapshot.ts +182 -0
- package/src/router/pattern-matching.ts +60 -9
- 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/trie-matching.ts +10 -4
- package/src/router/types.ts +1 -0
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +60 -8
- package/src/rsc/handler.ts +478 -374
- package/src/rsc/helpers.ts +69 -41
- package/src/rsc/loader-fetch.ts +23 -3
- package/src/rsc/manifest-init.ts +5 -1
- package/src/rsc/progressive-enhancement.ts +16 -2
- package/src/rsc/response-route-handler.ts +14 -1
- package/src/rsc/rsc-rendering.ts +19 -1
- package/src/rsc/server-action.ts +10 -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 +194 -60
- 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 -65
- package/src/types/loader-types.ts +41 -15
- package/src/types/request-scope.ts +126 -0
- package/src/types/route-entry.ts +19 -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 +18 -16
- package/src/use-loader.tsx +77 -5
- package/src/vite/debug.ts +55 -0
- 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/cloudflare-protocol-loader-hook.d.mts +23 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
- 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 +86 -0
- package/src/vite/plugins/refresh-cmd.ts +88 -26
- package/src/vite/plugins/version-plugin.ts +13 -1
- package/src/vite/rango.ts +204 -217
- package/src/vite/router-discovery.ts +335 -64
- package/src/vite/utils/banner.ts +4 -4
- package/src/vite/utils/package-resolution.ts +41 -1
- package/src/vite/utils/prerender-utils.ts +37 -5
- package/src/vite/utils/shared-utils.ts +3 -2
|
@@ -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
|
/**
|
|
@@ -12,6 +13,10 @@ import type { RouterInstance, RouterNavigateOptions } from "../types.js";
|
|
|
12
13
|
* useRouter() do not re-render on navigation state changes.
|
|
13
14
|
* For reactive navigation state, use useNavigation() instead.
|
|
14
15
|
*
|
|
16
|
+
* Methods read `basename` from the live context on each call so that
|
|
17
|
+
* cross-app navigation (app-switch) sees the current app's basename
|
|
18
|
+
* rather than the one captured at mount time.
|
|
19
|
+
*
|
|
15
20
|
* @example
|
|
16
21
|
* ```tsx
|
|
17
22
|
* const router = useRouter();
|
|
@@ -28,15 +33,26 @@ export function useRouter(): RouterInstance {
|
|
|
28
33
|
throw new Error("useRouter must be used within NavigationProvider");
|
|
29
34
|
}
|
|
30
35
|
|
|
31
|
-
// Stable reference: ctx is
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
// Stable reference: ctx itself is stable, and reads on each method call
|
|
37
|
+
// pick up live basename values from the context (backed by a live ref
|
|
38
|
+
// in NavigationProvider), so app-switch transitions are reflected without
|
|
39
|
+
// recreating this object.
|
|
40
|
+
return useMemo<RouterInstance>(() => {
|
|
41
|
+
/** Prefix a root-relative path with basename if not already prefixed. */
|
|
42
|
+
function withBasename(url: string): string {
|
|
43
|
+
const bn = ctx!.basename;
|
|
44
|
+
if (!bn || !url.startsWith("/") || url.startsWith(bn + "/") || url === bn)
|
|
45
|
+
return url;
|
|
46
|
+
return url === "/" ? bn : bn + url;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
34
50
|
push(url: string, options?: RouterNavigateOptions): Promise<void> {
|
|
35
|
-
return ctx.navigate(url, { ...options, replace: false });
|
|
51
|
+
return ctx.navigate(withBasename(url), { ...options, replace: false });
|
|
36
52
|
},
|
|
37
53
|
|
|
38
54
|
replace(url: string, options?: RouterNavigateOptions): Promise<void> {
|
|
39
|
-
return ctx.navigate(url, { ...options, replace: true });
|
|
55
|
+
return ctx.navigate(withBasename(url), { ...options, replace: true });
|
|
40
56
|
},
|
|
41
57
|
|
|
42
58
|
refresh(): Promise<void> {
|
|
@@ -46,7 +62,12 @@ export function useRouter(): RouterInstance {
|
|
|
46
62
|
prefetch(url: string): void {
|
|
47
63
|
const segmentState = ctx.store?.getSegmentState();
|
|
48
64
|
if (segmentState) {
|
|
49
|
-
prefetchDirect(
|
|
65
|
+
prefetchDirect(
|
|
66
|
+
withBasename(url),
|
|
67
|
+
segmentState.currentSegmentIds,
|
|
68
|
+
getAppVersion(),
|
|
69
|
+
ctx.store?.getRouterId?.(),
|
|
70
|
+
);
|
|
50
71
|
}
|
|
51
72
|
},
|
|
52
73
|
|
|
@@ -57,7 +78,6 @@ export function useRouter(): RouterInstance {
|
|
|
57
78
|
forward(): void {
|
|
58
79
|
window.history.forward();
|
|
59
80
|
},
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
);
|
|
81
|
+
};
|
|
82
|
+
}, []);
|
|
63
83
|
}
|
|
@@ -23,10 +23,12 @@ 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,
|
|
29
30
|
} from "./intercept-utils.js";
|
|
31
|
+
import { createAppShellRef } from "./app-shell.js";
|
|
30
32
|
|
|
31
33
|
// Vite HMR types are provided by vite/client
|
|
32
34
|
|
|
@@ -113,6 +115,13 @@ export interface BrowserAppContext {
|
|
|
113
115
|
warmupEnabled?: boolean;
|
|
114
116
|
/** App version for prefetch version mismatch detection */
|
|
115
117
|
version?: string;
|
|
118
|
+
/**
|
|
119
|
+
* Live app-shell ref. Cross-app navigations replace its contents so the
|
|
120
|
+
* NavigationProvider and renderSegments pick up the target app's
|
|
121
|
+
* rootLayout, basename, and version without consumer rerenders. Theme,
|
|
122
|
+
* warmup, and prefetch TTL are document-lifetime (see AppShell).
|
|
123
|
+
*/
|
|
124
|
+
appShellRef?: import("./app-shell.js").AppShellRef;
|
|
116
125
|
}
|
|
117
126
|
|
|
118
127
|
// Module-level state for the initialized app
|
|
@@ -139,7 +148,6 @@ export async function initBrowserApp(
|
|
|
139
148
|
initialTheme,
|
|
140
149
|
} = options;
|
|
141
150
|
|
|
142
|
-
// Load initial payload from SSR-injected __FLIGHT_DATA__
|
|
143
151
|
const initialPayload =
|
|
144
152
|
await deps.createFromReadableStream<RscPayload>(rscStream);
|
|
145
153
|
|
|
@@ -164,6 +172,12 @@ export async function initBrowserApp(
|
|
|
164
172
|
...(storeOptions?.cacheSize && { cacheSize: storeOptions.cacheSize }),
|
|
165
173
|
});
|
|
166
174
|
|
|
175
|
+
// Seed router identity from the initial SSR payload so the first
|
|
176
|
+
// cross-app SPA navigation can detect the app switch.
|
|
177
|
+
if (initialPayload.metadata?.routerId) {
|
|
178
|
+
store.setRouterId?.(initialPayload.metadata.routerId);
|
|
179
|
+
}
|
|
180
|
+
|
|
167
181
|
// Create event controller for reactive state management
|
|
168
182
|
const eventController = createEventController({
|
|
169
183
|
initialLocation: new URL(window.location.href),
|
|
@@ -198,13 +212,24 @@ export async function initBrowserApp(
|
|
|
198
212
|
// Create composable utilities
|
|
199
213
|
const client = createNavigationClient(deps);
|
|
200
214
|
|
|
201
|
-
//
|
|
202
|
-
|
|
215
|
+
// Capture the per-router app-shell so cross-app navigations can replace
|
|
216
|
+
// it atomically. rootLayout, basename, and version live here and are
|
|
217
|
+
// read through the ref at call time rather than closed over. Theme,
|
|
218
|
+
// warmup, and prefetch TTL are deliberately excluded — they are
|
|
219
|
+
// document-lifetime and stay stable across smooth cross-app transitions.
|
|
203
220
|
const version = initialPayload.metadata?.version;
|
|
221
|
+
const appShellRef = createAppShellRef({
|
|
222
|
+
routerId: initialPayload.metadata?.routerId,
|
|
223
|
+
rootLayout: initialPayload.metadata?.rootLayout,
|
|
224
|
+
basename: initialPayload.metadata?.basename,
|
|
225
|
+
version,
|
|
226
|
+
});
|
|
204
227
|
|
|
205
228
|
// Initialize the localStorage state key for cache invalidation.
|
|
206
|
-
//
|
|
207
|
-
|
|
229
|
+
// The build version busts cached prefetches on deploy; the routerId
|
|
230
|
+
// namespaces the key so sibling apps on the same origin don't collide.
|
|
231
|
+
initRangoState(version ?? "0", initialPayload.metadata?.routerId);
|
|
232
|
+
setAppVersion(version);
|
|
208
233
|
|
|
209
234
|
// Initialize the in-memory prefetch cache TTL from server config.
|
|
210
235
|
// A value of 0 disables the cache; undefined falls back to the module default.
|
|
@@ -213,11 +238,17 @@ export async function initBrowserApp(
|
|
|
213
238
|
initPrefetchCache(prefetchCacheTTL);
|
|
214
239
|
}
|
|
215
240
|
|
|
216
|
-
// Create a bound renderSegments that
|
|
241
|
+
// Create a bound renderSegments that reads rootLayout through the shell
|
|
242
|
+
// ref. On app switch the ref is updated before the tree re-renders, so
|
|
243
|
+
// the new app's Document (rootLayout) replaces the previous one.
|
|
217
244
|
const renderSegments = (
|
|
218
245
|
segments: ResolvedSegment[],
|
|
219
246
|
options?: RenderSegmentsOptions,
|
|
220
|
-
) =>
|
|
247
|
+
) =>
|
|
248
|
+
baseRenderSegments(segments, {
|
|
249
|
+
...options,
|
|
250
|
+
rootLayout: appShellRef.get().rootLayout,
|
|
251
|
+
});
|
|
221
252
|
|
|
222
253
|
// Lazy reference for navigation bridge — the action bridge is created first
|
|
223
254
|
// but may need to trigger SPA navigation for action redirects.
|
|
@@ -231,7 +262,6 @@ export async function initBrowserApp(
|
|
|
231
262
|
deps,
|
|
232
263
|
onUpdate: (update) => store.emitUpdate(update),
|
|
233
264
|
renderSegments,
|
|
234
|
-
version,
|
|
235
265
|
onNavigate: (url, options) => {
|
|
236
266
|
if (!navigateFn) {
|
|
237
267
|
window.location.href = url;
|
|
@@ -249,7 +279,8 @@ export async function initBrowserApp(
|
|
|
249
279
|
client,
|
|
250
280
|
onUpdate: (update) => store.emitUpdate(update),
|
|
251
281
|
renderSegments,
|
|
252
|
-
version,
|
|
282
|
+
version: version,
|
|
283
|
+
appShellRef,
|
|
253
284
|
});
|
|
254
285
|
|
|
255
286
|
// Connect action redirect → navigation bridge (now that both are initialized)
|
|
@@ -263,71 +294,139 @@ export async function initBrowserApp(
|
|
|
263
294
|
// Build initial tree with rootLayout
|
|
264
295
|
const initialTree = renderSegments(initialPayload.metadata!.segments);
|
|
265
296
|
|
|
266
|
-
// Setup HMR
|
|
297
|
+
// Setup HMR with debounce — burst saves (format-on-save, rapid edits)
|
|
298
|
+
// fire many rsc:update events in quick succession. Without debouncing,
|
|
299
|
+
// each event triggers a fetchPartial() which on slow routes can pile up
|
|
300
|
+
// and overwhelm the worker (cross-request promise issues, 500s).
|
|
267
301
|
if (import.meta.hot) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
302
|
+
let hmrTimer: ReturnType<typeof setTimeout> | null = null;
|
|
303
|
+
let hmrAbort: AbortController | null = null;
|
|
304
|
+
|
|
305
|
+
import.meta.hot.on("rsc:update", () => {
|
|
306
|
+
// Cancel any pending debounce timer
|
|
307
|
+
if (hmrTimer !== null) {
|
|
308
|
+
clearTimeout(hmrTimer);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Abort any in-flight HMR fetch so it doesn't race with the next one
|
|
312
|
+
if (hmrAbort) {
|
|
313
|
+
hmrAbort.abort();
|
|
314
|
+
hmrAbort = null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Debounce: wait 200ms of quiet before fetching
|
|
318
|
+
hmrTimer = setTimeout(async () => {
|
|
319
|
+
hmrTimer = null;
|
|
320
|
+
|
|
321
|
+
// Don't interrupt an active user navigation — startNavigation()
|
|
322
|
+
// would abort it and refetch the old URL (window.location.href
|
|
323
|
+
// hasn't updated yet). The user's navigation will pick up the
|
|
324
|
+
// new server code when it completes. isNavigating covers the
|
|
325
|
+
// full lifecycle (fetching + streaming, before commit) without
|
|
326
|
+
// blocking on server actions.
|
|
327
|
+
if (eventController.getState().isNavigating) {
|
|
328
|
+
console.log("[RSCRouter] HMR: Skipping — navigation in progress");
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
console.log("[RSCRouter] HMR: Server update, refetching RSC");
|
|
333
|
+
|
|
334
|
+
const abort = new AbortController();
|
|
335
|
+
hmrAbort = abort;
|
|
336
|
+
|
|
337
|
+
const handle = eventController.startNavigation(window.location.href, {
|
|
338
|
+
replace: true,
|
|
285
339
|
});
|
|
340
|
+
const streamingToken = handle.startStreaming();
|
|
341
|
+
|
|
342
|
+
const interceptSourceUrl = store.getInterceptSourceUrl();
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const { payload, streamComplete } = await client.fetchPartial({
|
|
346
|
+
targetUrl: window.location.href,
|
|
347
|
+
segmentIds: [],
|
|
348
|
+
previousUrl: store.getSegmentState().currentUrl,
|
|
349
|
+
interceptSourceUrl: interceptSourceUrl || undefined,
|
|
350
|
+
routerId: store.getRouterId?.(),
|
|
351
|
+
hmr: true,
|
|
352
|
+
signal: abort.signal,
|
|
353
|
+
});
|
|
286
354
|
|
|
287
|
-
|
|
288
|
-
const segments = payload.metadata.segments || [];
|
|
289
|
-
const matched = payload.metadata.matched || [];
|
|
355
|
+
if (abort.signal.aborted) return;
|
|
290
356
|
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
357
|
+
// If the server returned a non-RSC response (404, 500 without
|
|
358
|
+
// error boundary), the payload won't have valid metadata.
|
|
359
|
+
// Reload to recover rather than leaving the page stale.
|
|
360
|
+
if (!payload.metadata) {
|
|
361
|
+
throw new Error("HMR refetch returned invalid payload");
|
|
362
|
+
}
|
|
295
363
|
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
|
|
364
|
+
// Update version BEFORE rebuilding state so that
|
|
365
|
+
// clearHistoryCache() runs first, then the fresh segment
|
|
366
|
+
// cache entry we create below survives.
|
|
367
|
+
const newVersion = payload.metadata.version;
|
|
368
|
+
if (newVersion && newVersion !== version) {
|
|
369
|
+
console.log(
|
|
370
|
+
"[RSCRouter] HMR: version changed",
|
|
371
|
+
version,
|
|
372
|
+
"→",
|
|
373
|
+
newVersion,
|
|
374
|
+
"clearing caches",
|
|
375
|
+
);
|
|
376
|
+
navigationBridge.updateVersion(newVersion);
|
|
299
377
|
}
|
|
300
378
|
|
|
301
|
-
|
|
302
|
-
|
|
379
|
+
if (payload.metadata?.isPartial) {
|
|
380
|
+
const segments = payload.metadata.segments || [];
|
|
381
|
+
const matched = payload.metadata.matched || [];
|
|
382
|
+
|
|
383
|
+
// Derive intercept state from the returned payload, not the
|
|
384
|
+
// pre-fetch store snapshot. If the HMR edit removed intercept
|
|
385
|
+
// behavior, the response won't contain intercept segments.
|
|
386
|
+
const responseIsIntercept = segments.some(isInterceptSegment);
|
|
387
|
+
|
|
388
|
+
// Sync store intercept state with what the server returned
|
|
389
|
+
if (!responseIsIntercept && interceptSourceUrl) {
|
|
390
|
+
store.setInterceptSourceUrl(null);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
store.setSegmentIds(matched);
|
|
394
|
+
store.setCurrentUrl(window.location.href);
|
|
395
|
+
|
|
396
|
+
const historyKey = generateHistoryKey(window.location.href, {
|
|
397
|
+
intercept: responseIsIntercept,
|
|
398
|
+
});
|
|
399
|
+
store.setHistoryKey(historyKey);
|
|
400
|
+
const currentHandleData = eventController.getHandleState().data;
|
|
401
|
+
store.cacheSegmentsForHistory(
|
|
402
|
+
historyKey,
|
|
403
|
+
segments,
|
|
404
|
+
currentHandleData,
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
const { main, intercept } = splitInterceptSegments(segments);
|
|
408
|
+
store.emitUpdate({
|
|
409
|
+
root: renderSegments(main, {
|
|
410
|
+
interceptSegments: intercept.length > 0 ? intercept : undefined,
|
|
411
|
+
}),
|
|
412
|
+
metadata: payload.metadata,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
303
415
|
|
|
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
|
-
});
|
|
416
|
+
await streamComplete;
|
|
417
|
+
handle.complete(new URL(window.location.href));
|
|
418
|
+
console.log("[RSCRouter] HMR: RSC stream complete");
|
|
419
|
+
} catch (err) {
|
|
420
|
+
if (abort.signal.aborted) return;
|
|
421
|
+
console.warn("[RSCRouter] HMR: Refetch failed, reloading page", err);
|
|
422
|
+
window.location.reload();
|
|
423
|
+
return;
|
|
424
|
+
} finally {
|
|
425
|
+
if (hmrAbort === abort) hmrAbort = null;
|
|
426
|
+
streamingToken.end();
|
|
427
|
+
handle[Symbol.dispose]();
|
|
322
428
|
}
|
|
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
|
-
}
|
|
429
|
+
}, 200);
|
|
331
430
|
});
|
|
332
431
|
}
|
|
333
432
|
|
|
@@ -342,6 +441,7 @@ export async function initBrowserApp(
|
|
|
342
441
|
initialTheme: effectiveInitialTheme,
|
|
343
442
|
warmupEnabled: initialPayload.metadata?.warmupEnabled ?? true,
|
|
344
443
|
version,
|
|
444
|
+
appShellRef,
|
|
345
445
|
};
|
|
346
446
|
browserAppContext = context;
|
|
347
447
|
|
|
@@ -407,6 +507,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
|
|
|
407
507
|
initialTheme,
|
|
408
508
|
warmupEnabled,
|
|
409
509
|
version,
|
|
510
|
+
appShellRef,
|
|
410
511
|
} = getBrowserAppContext();
|
|
411
512
|
|
|
412
513
|
// Signal that the React tree has hydrated. useEffect only fires after
|
|
@@ -426,6 +527,8 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
|
|
|
426
527
|
initialTheme={initialTheme}
|
|
427
528
|
warmupEnabled={warmupEnabled}
|
|
428
529
|
version={version}
|
|
530
|
+
basename={initialPayload.metadata?.basename}
|
|
531
|
+
appShellRef={appShellRef}
|
|
429
532
|
/>
|
|
430
533
|
);
|
|
431
534
|
}
|
|
@@ -10,6 +10,15 @@
|
|
|
10
10
|
|
|
11
11
|
import { debugLog } from "./logging.js";
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Defers a callback to the next animation frame.
|
|
15
|
+
* Falls back to setTimeout(0) in environments without requestAnimationFrame.
|
|
16
|
+
*/
|
|
17
|
+
const deferToNextPaint: (fn: () => void) => void =
|
|
18
|
+
typeof requestAnimationFrame === "function"
|
|
19
|
+
? requestAnimationFrame
|
|
20
|
+
: (fn) => setTimeout(fn, 0);
|
|
21
|
+
|
|
13
22
|
const SCROLL_STORAGE_KEY = "rsc-router-scroll-positions";
|
|
14
23
|
|
|
15
24
|
/**
|
|
@@ -264,51 +273,35 @@ export function restoreScrollPosition(options?: {
|
|
|
264
273
|
return false;
|
|
265
274
|
}
|
|
266
275
|
|
|
267
|
-
//
|
|
268
|
-
const maxScrollY = document.documentElement.scrollHeight - window.innerHeight;
|
|
269
|
-
const canScrollToPosition = savedY <= maxScrollY;
|
|
270
|
-
|
|
271
|
-
if (canScrollToPosition) {
|
|
272
|
-
window.scrollTo(0, savedY);
|
|
273
|
-
debugLog("[Scroll] Restored position:", savedY, "for key:", key);
|
|
274
|
-
return true;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Scroll as far as we can for now
|
|
278
|
-
window.scrollTo(0, maxScrollY);
|
|
279
|
-
debugLog("[Scroll] Partial restore to:", maxScrollY, "target:", savedY);
|
|
280
|
-
|
|
281
|
-
// Poll while streaming until we can scroll to target position
|
|
276
|
+
// If streaming, poll until streaming ends then scroll to saved position
|
|
282
277
|
if (options?.retryIfStreaming && options?.isStreaming?.()) {
|
|
283
278
|
const startTime = Date.now();
|
|
284
279
|
|
|
285
280
|
pendingPollInterval = setInterval(() => {
|
|
286
|
-
// Stop if we've exceeded the timeout
|
|
287
281
|
if (Date.now() - startTime > SCROLL_POLL_TIMEOUT_MS) {
|
|
288
282
|
debugLog("[Scroll] Polling timeout, giving up");
|
|
289
283
|
cancelScrollRestorationPolling();
|
|
290
284
|
return;
|
|
291
285
|
}
|
|
292
286
|
|
|
293
|
-
// Stop if streaming ended
|
|
294
287
|
if (!options.isStreaming?.()) {
|
|
295
|
-
debugLog("[Scroll] Streaming ended, stopping poll");
|
|
296
|
-
cancelScrollRestorationPolling();
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Check if we can now scroll to the target position
|
|
301
|
-
const currentMaxScrollY =
|
|
302
|
-
document.documentElement.scrollHeight - window.innerHeight;
|
|
303
|
-
if (savedY <= currentMaxScrollY) {
|
|
304
288
|
window.scrollTo(0, savedY);
|
|
305
|
-
debugLog("[Scroll]
|
|
289
|
+
debugLog("[Scroll] Restored after streaming:", savedY);
|
|
306
290
|
cancelScrollRestorationPolling();
|
|
307
291
|
}
|
|
308
292
|
}, SCROLL_POLL_INTERVAL_MS);
|
|
293
|
+
|
|
294
|
+
return true;
|
|
309
295
|
}
|
|
310
296
|
|
|
311
|
-
|
|
297
|
+
// Not streaming — scroll after React commits and browser paints.
|
|
298
|
+
// startTransition defers the DOM commit, so scrolling synchronously
|
|
299
|
+
// would be overwritten when React replaces the content.
|
|
300
|
+
deferToNextPaint(() => {
|
|
301
|
+
window.scrollTo(0, savedY);
|
|
302
|
+
debugLog("[Scroll] Restored position:", savedY, "for key:", key);
|
|
303
|
+
});
|
|
304
|
+
return true;
|
|
312
305
|
}
|
|
313
306
|
|
|
314
307
|
/**
|
|
@@ -363,32 +356,38 @@ export function handleNavigationEnd(options: {
|
|
|
363
356
|
scroll?: boolean;
|
|
364
357
|
isStreaming?: () => boolean;
|
|
365
358
|
}): void {
|
|
366
|
-
if (!initialized) {
|
|
367
|
-
return;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
359
|
const { restore = false, scroll = true, isStreaming } = options;
|
|
371
360
|
|
|
372
|
-
// Don't scroll if explicitly disabled
|
|
373
|
-
if (scroll === false) {
|
|
361
|
+
// Don't scroll if explicitly disabled or not in a browser
|
|
362
|
+
if (scroll === false || typeof window === "undefined") {
|
|
374
363
|
return;
|
|
375
364
|
}
|
|
376
365
|
|
|
377
|
-
//
|
|
378
|
-
|
|
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) {
|
|
379
371
|
if (restoreScrollPosition({ retryIfStreaming: true, isStreaming })) {
|
|
380
372
|
return;
|
|
381
373
|
}
|
|
382
374
|
// Fall through to hash or top if no saved position
|
|
383
375
|
}
|
|
384
376
|
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
377
|
+
// Defer hash and scroll-to-top to after React paints the new content,
|
|
378
|
+
// so the user doesn't see the current page jump before the new route appears.
|
|
379
|
+
deferToNextPaint(() => {
|
|
380
|
+
// Re-check: the deferred callback may fire after environment teardown
|
|
381
|
+
if (typeof window === "undefined") return;
|
|
382
|
+
|
|
383
|
+
// Try hash scrolling first
|
|
384
|
+
if (scrollToHash()) {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
389
387
|
|
|
390
|
-
|
|
391
|
-
|
|
388
|
+
// Default: scroll to top
|
|
389
|
+
scrollToTop();
|
|
390
|
+
});
|
|
392
391
|
}
|
|
393
392
|
|
|
394
393
|
/**
|
|
@@ -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[];
|