@rangojs/router 0.0.0-experimental.29-prefetch-cache.29972e92 → 0.0.0-experimental.2a0dea97

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.
Files changed (156) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +78 -19
  3. package/dist/bin/rango.js +138 -50
  4. package/dist/vite/index.js +853 -435
  5. package/package.json +17 -16
  6. package/skills/breadcrumbs/SKILL.md +250 -0
  7. package/skills/cache-guide/SKILL.md +32 -0
  8. package/skills/caching/SKILL.md +45 -4
  9. package/skills/handler-use/SKILL.md +362 -0
  10. package/skills/hooks/SKILL.md +22 -4
  11. package/skills/intercept/SKILL.md +20 -0
  12. package/skills/layout/SKILL.md +22 -0
  13. package/skills/links/SKILL.md +3 -1
  14. package/skills/loader/SKILL.md +71 -21
  15. package/skills/middleware/SKILL.md +34 -3
  16. package/skills/migrate-nextjs/SKILL.md +560 -0
  17. package/skills/migrate-react-router/SKILL.md +764 -0
  18. package/skills/parallel/SKILL.md +185 -0
  19. package/skills/prerender/SKILL.md +110 -68
  20. package/skills/rango/SKILL.md +24 -22
  21. package/skills/route/SKILL.md +56 -2
  22. package/skills/router-setup/SKILL.md +92 -2
  23. package/skills/typesafety/SKILL.md +33 -21
  24. package/src/__internal.ts +92 -0
  25. package/src/browser/app-version.ts +14 -0
  26. package/src/browser/event-controller.ts +5 -0
  27. package/src/browser/link-interceptor.ts +4 -0
  28. package/src/browser/navigation-bridge.ts +125 -16
  29. package/src/browser/navigation-client.ts +142 -57
  30. package/src/browser/navigation-store.ts +43 -8
  31. package/src/browser/navigation-transaction.ts +11 -9
  32. package/src/browser/partial-update.ts +94 -17
  33. package/src/browser/prefetch/cache.ts +101 -15
  34. package/src/browser/prefetch/fetch.ts +98 -27
  35. package/src/browser/prefetch/policy.ts +6 -0
  36. package/src/browser/prefetch/queue.ts +92 -20
  37. package/src/browser/prefetch/resource-ready.ts +77 -0
  38. package/src/browser/react/Link.tsx +88 -9
  39. package/src/browser/react/NavigationProvider.tsx +40 -4
  40. package/src/browser/react/context.ts +7 -2
  41. package/src/browser/react/use-handle.ts +9 -58
  42. package/src/browser/react/use-router.ts +21 -8
  43. package/src/browser/rsc-router.tsx +143 -60
  44. package/src/browser/scroll-restoration.ts +41 -42
  45. package/src/browser/segment-reconciler.ts +72 -10
  46. package/src/browser/server-action-bridge.ts +8 -6
  47. package/src/browser/types.ts +60 -5
  48. package/src/build/generate-manifest.ts +6 -6
  49. package/src/build/generate-route-types.ts +3 -0
  50. package/src/build/route-trie.ts +50 -24
  51. package/src/build/route-types/include-resolution.ts +8 -1
  52. package/src/build/route-types/router-processing.ts +223 -74
  53. package/src/build/route-types/scan-filter.ts +8 -1
  54. package/src/cache/cache-runtime.ts +15 -11
  55. package/src/cache/cache-scope.ts +48 -7
  56. package/src/cache/cf/cf-cache-store.ts +453 -11
  57. package/src/cache/cf/index.ts +5 -1
  58. package/src/cache/document-cache.ts +17 -7
  59. package/src/cache/index.ts +1 -0
  60. package/src/cache/taint.ts +55 -0
  61. package/src/client.rsc.tsx +2 -0
  62. package/src/client.tsx +6 -66
  63. package/src/context-var.ts +72 -2
  64. package/src/debug.ts +2 -2
  65. package/src/handle.ts +40 -0
  66. package/src/handles/breadcrumbs.ts +66 -0
  67. package/src/handles/index.ts +1 -0
  68. package/src/index.rsc.ts +6 -36
  69. package/src/index.ts +50 -43
  70. package/src/prerender/store.ts +5 -4
  71. package/src/prerender.ts +138 -77
  72. package/src/reverse.ts +25 -1
  73. package/src/route-definition/dsl-helpers.ts +224 -37
  74. package/src/route-definition/helpers-types.ts +67 -19
  75. package/src/route-definition/index.ts +3 -0
  76. package/src/route-definition/redirect.ts +11 -3
  77. package/src/route-definition/resolve-handler-use.ts +149 -0
  78. package/src/route-map-builder.ts +7 -1
  79. package/src/route-types.ts +11 -0
  80. package/src/router/content-negotiation.ts +100 -1
  81. package/src/router/find-match.ts +4 -2
  82. package/src/router/handler-context.ts +111 -25
  83. package/src/router/intercept-resolution.ts +11 -4
  84. package/src/router/lazy-includes.ts +4 -1
  85. package/src/router/loader-resolution.ts +156 -21
  86. package/src/router/logging.ts +5 -2
  87. package/src/router/manifest.ts +9 -3
  88. package/src/router/match-api.ts +125 -190
  89. package/src/router/match-middleware/background-revalidation.ts +30 -2
  90. package/src/router/match-middleware/cache-lookup.ts +94 -17
  91. package/src/router/match-middleware/cache-store.ts +53 -10
  92. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  93. package/src/router/match-middleware/segment-resolution.ts +61 -5
  94. package/src/router/match-result.ts +104 -10
  95. package/src/router/metrics.ts +6 -1
  96. package/src/router/middleware-types.ts +40 -12
  97. package/src/router/middleware.ts +43 -79
  98. package/src/router/navigation-snapshot.ts +182 -0
  99. package/src/router/prerender-match.ts +114 -10
  100. package/src/router/preview-match.ts +30 -102
  101. package/src/router/request-classification.ts +310 -0
  102. package/src/router/route-snapshot.ts +245 -0
  103. package/src/router/router-context.ts +6 -1
  104. package/src/router/router-interfaces.ts +44 -5
  105. package/src/router/router-options.ts +49 -18
  106. package/src/router/segment-resolution/fresh.ts +198 -20
  107. package/src/router/segment-resolution/helpers.ts +30 -25
  108. package/src/router/segment-resolution/loader-cache.ts +1 -0
  109. package/src/router/segment-resolution/revalidation.ts +438 -300
  110. package/src/router/segment-wrappers.ts +2 -0
  111. package/src/router/types.ts +1 -0
  112. package/src/router.ts +73 -13
  113. package/src/rsc/handler.ts +472 -372
  114. package/src/rsc/loader-fetch.ts +23 -3
  115. package/src/rsc/manifest-init.ts +5 -1
  116. package/src/rsc/progressive-enhancement.ts +14 -2
  117. package/src/rsc/rsc-rendering.ts +13 -1
  118. package/src/rsc/server-action.ts +8 -0
  119. package/src/rsc/ssr-setup.ts +2 -2
  120. package/src/rsc/types.ts +11 -1
  121. package/src/segment-content-promise.ts +33 -0
  122. package/src/segment-system.tsx +164 -23
  123. package/src/server/context.ts +140 -14
  124. package/src/server/handle-store.ts +19 -0
  125. package/src/server/loader-registry.ts +9 -8
  126. package/src/server/request-context.ts +204 -28
  127. package/src/ssr/index.tsx +4 -0
  128. package/src/static-handler.ts +18 -6
  129. package/src/types/cache-types.ts +4 -4
  130. package/src/types/handler-context.ts +149 -49
  131. package/src/types/loader-types.ts +36 -9
  132. package/src/types/route-entry.ts +8 -1
  133. package/src/types/segments.ts +6 -0
  134. package/src/urls/path-helper-types.ts +39 -6
  135. package/src/urls/path-helper.ts +48 -13
  136. package/src/urls/pattern-types.ts +12 -0
  137. package/src/urls/response-types.ts +16 -6
  138. package/src/use-loader.tsx +77 -5
  139. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  140. package/src/vite/discovery/discover-routers.ts +5 -1
  141. package/src/vite/discovery/prerender-collection.ts +128 -74
  142. package/src/vite/discovery/state.ts +13 -6
  143. package/src/vite/index.ts +4 -0
  144. package/src/vite/plugin-types.ts +51 -79
  145. package/src/vite/plugins/expose-action-id.ts +1 -3
  146. package/src/vite/plugins/expose-id-utils.ts +12 -0
  147. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  148. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  149. package/src/vite/plugins/performance-tracks.ts +88 -0
  150. package/src/vite/plugins/refresh-cmd.ts +88 -26
  151. package/src/vite/plugins/version-plugin.ts +13 -1
  152. package/src/vite/rango.ts +163 -211
  153. package/src/vite/router-discovery.ts +178 -45
  154. package/src/vite/utils/banner.ts +3 -3
  155. package/src/vite/utils/prerender-utils.ts +37 -5
  156. 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 prefetch requests for version mismatch detection.
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 speculative prefetches when navigation starts.
290
- // Viewport/render prefetches should not compete with navigation fetches.
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 (stable, immutable).
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 { getCollectFn } from "../../handle.js";
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 = collectHandle(handle, {}, []);
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 = collectHandle(handle, state.data, state.segmentOrder);
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 = collectHandle(
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 = collectHandle(handle, state.data, state.segmentOrder);
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;
@@ -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(url, segmentState.currentSegmentIds, ctx.version);
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
  }
@@ -22,6 +22,8 @@ import type {
22
22
  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
+ import { initPrefetchCache } from "./prefetch/cache.js";
26
+ import { setAppVersion } from "./app-version.js";
25
27
  import {
26
28
  isInterceptSegment,
27
29
  splitInterceptSegments,
@@ -138,7 +140,6 @@ export async function initBrowserApp(
138
140
  initialTheme,
139
141
  } = options;
140
142
 
141
- // Load initial payload from SSR-injected __FLIGHT_DATA__
142
143
  const initialPayload =
143
144
  await deps.createFromReadableStream<RscPayload>(rscStream);
144
145
 
@@ -163,6 +164,12 @@ export async function initBrowserApp(
163
164
  ...(storeOptions?.cacheSize && { cacheSize: storeOptions.cacheSize }),
164
165
  });
165
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
+
166
173
  // Create event controller for reactive state management
167
174
  const eventController = createEventController({
168
175
  initialLocation: new URL(window.location.href),
@@ -201,9 +208,17 @@ export async function initBrowserApp(
201
208
  const rootLayout = initialPayload.metadata?.rootLayout;
202
209
  const version = initialPayload.metadata?.version;
203
210
 
204
- // Initialize the localStorage state key for browser HTTP cache invalidation.
211
+ // Initialize the localStorage state key for cache invalidation.
205
212
  // Uses the build version so a new deploy automatically busts all cached prefetches.
206
213
  initRangoState(version ?? "0");
214
+ setAppVersion(version);
215
+
216
+ // Initialize the in-memory prefetch cache TTL from server config.
217
+ // A value of 0 disables the cache; undefined falls back to the module default.
218
+ const prefetchCacheTTL = initialPayload.metadata?.prefetchCacheTTL;
219
+ if (prefetchCacheTTL !== undefined) {
220
+ initPrefetchCache(prefetchCacheTTL);
221
+ }
207
222
 
208
223
  // Create a bound renderSegments that includes rootLayout
209
224
  const renderSegments = (
@@ -223,7 +238,6 @@ export async function initBrowserApp(
223
238
  deps,
224
239
  onUpdate: (update) => store.emitUpdate(update),
225
240
  renderSegments,
226
- version,
227
241
  onNavigate: (url, options) => {
228
242
  if (!navigateFn) {
229
243
  window.location.href = url;
@@ -241,7 +255,7 @@ export async function initBrowserApp(
241
255
  client,
242
256
  onUpdate: (update) => store.emitUpdate(update),
243
257
  renderSegments,
244
- version,
258
+ version: version,
245
259
  });
246
260
 
247
261
  // Connect action redirect → navigation bridge (now that both are initialized)
@@ -255,71 +269,139 @@ export async function initBrowserApp(
255
269
  // Build initial tree with rootLayout
256
270
  const initialTree = renderSegments(initialPayload.metadata!.segments);
257
271
 
258
- // 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).
259
276
  if (import.meta.hot) {
260
- import.meta.hot.on("rsc:update", async () => {
261
- console.log("[RSCRouter] HMR: Server update, refetching RSC");
262
-
263
- const handle = eventController.startNavigation(window.location.href, {
264
- replace: true,
265
- });
266
- const streamingToken = handle.startStreaming();
267
-
268
- const interceptSourceUrl = store.getInterceptSourceUrl();
269
-
270
- try {
271
- const { payload, streamComplete } = await client.fetchPartial({
272
- targetUrl: window.location.href,
273
- segmentIds: [],
274
- previousUrl: store.getSegmentState().currentUrl,
275
- interceptSourceUrl: interceptSourceUrl || undefined,
276
- hmr: true,
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,
277
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
+ });
278
329
 
279
- if (payload.metadata?.isPartial) {
280
- const segments = payload.metadata.segments || [];
281
- const matched = payload.metadata.matched || [];
330
+ if (abort.signal.aborted) return;
282
331
 
283
- // Derive intercept state from the returned payload, not the
284
- // pre-fetch store snapshot. If the HMR edit removed intercept
285
- // behavior, the response won't contain intercept segments.
286
- const responseIsIntercept = segments.some(isInterceptSegment);
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
+ }
287
338
 
288
- // Sync store intercept state with what the server returned
289
- if (!responseIsIntercept && interceptSourceUrl) {
290
- store.setInterceptSourceUrl(null);
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);
291
352
  }
292
353
 
293
- store.setSegmentIds(matched);
294
- store.setCurrentUrl(window.location.href);
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
+ }
295
390
 
296
- const historyKey = generateHistoryKey(window.location.href, {
297
- intercept: responseIsIntercept,
298
- });
299
- store.setHistoryKey(historyKey);
300
- const currentHandleData = eventController.getHandleState().data;
301
- store.cacheSegmentsForHistory(
302
- historyKey,
303
- segments,
304
- currentHandleData,
305
- );
306
-
307
- const { main, intercept } = splitInterceptSegments(segments);
308
- store.emitUpdate({
309
- root: renderSegments(main, {
310
- interceptSegments: intercept.length > 0 ? intercept : undefined,
311
- }),
312
- metadata: payload.metadata,
313
- });
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]();
314
403
  }
315
-
316
- await streamComplete;
317
- handle.complete(new URL(window.location.href));
318
- console.log("[RSCRouter] HMR: RSC stream complete");
319
- } finally {
320
- streamingToken.end();
321
- handle[Symbol.dispose]();
322
- }
404
+ }, 200);
323
405
  });
324
406
  }
325
407
 
@@ -418,6 +500,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
418
500
  initialTheme={initialTheme}
419
501
  warmupEnabled={warmupEnabled}
420
502
  version={version}
503
+ basename={initialPayload.metadata?.basename}
421
504
  />
422
505
  );
423
506
  }
@@ -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
- // Check if page is tall enough to scroll to saved position
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] Poll restored position:", savedY);
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
- return false;
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
- // For back/forward (restore), try to restore saved position
378
- if (restore) {
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
- // Try hash scrolling first
386
- if (scrollToHash()) {
387
- return;
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
- // Default: scroll to top
391
- scrollToTop();
388
+ // Default: scroll to top
389
+ scrollToTop();
390
+ });
392
391
  }
393
392
 
394
393
  /**