@rangojs/router 0.0.0-experimental.27 → 0.0.0-experimental.289231ba

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 (160) 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 +154 -44
  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 +176 -27
  34. package/src/browser/prefetch/fetch.ts +110 -41
  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-navigation.ts +22 -2
  43. package/src/browser/react/use-router.ts +21 -8
  44. package/src/browser/rsc-router.tsx +143 -60
  45. package/src/browser/scroll-restoration.ts +41 -42
  46. package/src/browser/segment-reconciler.ts +36 -9
  47. package/src/browser/server-action-bridge.ts +8 -6
  48. package/src/browser/types.ts +60 -5
  49. package/src/build/generate-manifest.ts +6 -6
  50. package/src/build/generate-route-types.ts +3 -0
  51. package/src/build/route-trie.ts +50 -24
  52. package/src/build/route-types/include-resolution.ts +8 -1
  53. package/src/build/route-types/router-processing.ts +223 -74
  54. package/src/build/route-types/scan-filter.ts +8 -1
  55. package/src/cache/cache-runtime.ts +15 -11
  56. package/src/cache/cache-scope.ts +48 -7
  57. package/src/cache/cf/cf-cache-store.ts +453 -11
  58. package/src/cache/cf/index.ts +5 -1
  59. package/src/cache/document-cache.ts +17 -7
  60. package/src/cache/index.ts +1 -0
  61. package/src/cache/taint.ts +55 -0
  62. package/src/client.rsc.tsx +2 -0
  63. package/src/client.tsx +85 -230
  64. package/src/context-var.ts +72 -2
  65. package/src/debug.ts +2 -2
  66. package/src/handle.ts +40 -0
  67. package/src/handles/breadcrumbs.ts +66 -0
  68. package/src/handles/index.ts +1 -0
  69. package/src/index.rsc.ts +6 -36
  70. package/src/index.ts +50 -43
  71. package/src/prerender/store.ts +5 -4
  72. package/src/prerender.ts +138 -77
  73. package/src/reverse.ts +25 -1
  74. package/src/route-definition/dsl-helpers.ts +224 -37
  75. package/src/route-definition/helpers-types.ts +67 -19
  76. package/src/route-definition/index.ts +3 -0
  77. package/src/route-definition/redirect.ts +11 -3
  78. package/src/route-definition/resolve-handler-use.ts +149 -0
  79. package/src/route-map-builder.ts +7 -1
  80. package/src/route-types.ts +18 -0
  81. package/src/router/content-negotiation.ts +100 -1
  82. package/src/router/find-match.ts +4 -2
  83. package/src/router/handler-context.ts +111 -25
  84. package/src/router/intercept-resolution.ts +11 -4
  85. package/src/router/lazy-includes.ts +9 -6
  86. package/src/router/loader-resolution.ts +156 -21
  87. package/src/router/logging.ts +5 -2
  88. package/src/router/manifest.ts +31 -16
  89. package/src/router/match-api.ts +125 -190
  90. package/src/router/match-middleware/background-revalidation.ts +30 -2
  91. package/src/router/match-middleware/cache-lookup.ts +94 -17
  92. package/src/router/match-middleware/cache-store.ts +53 -10
  93. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  94. package/src/router/match-middleware/segment-resolution.ts +61 -5
  95. package/src/router/match-result.ts +104 -10
  96. package/src/router/metrics.ts +6 -1
  97. package/src/router/middleware-types.ts +40 -12
  98. package/src/router/middleware.ts +43 -79
  99. package/src/router/navigation-snapshot.ts +182 -0
  100. package/src/router/prerender-match.ts +114 -10
  101. package/src/router/preview-match.ts +30 -102
  102. package/src/router/request-classification.ts +310 -0
  103. package/src/router/revalidation.ts +21 -10
  104. package/src/router/route-snapshot.ts +245 -0
  105. package/src/router/router-context.ts +6 -1
  106. package/src/router/router-interfaces.ts +44 -5
  107. package/src/router/router-options.ts +49 -18
  108. package/src/router/segment-resolution/fresh.ts +198 -20
  109. package/src/router/segment-resolution/helpers.ts +30 -25
  110. package/src/router/segment-resolution/loader-cache.ts +1 -0
  111. package/src/router/segment-resolution/revalidation.ts +438 -300
  112. package/src/router/segment-wrappers.ts +2 -0
  113. package/src/router/types.ts +1 -0
  114. package/src/router.ts +73 -13
  115. package/src/rsc/handler.ts +472 -372
  116. package/src/rsc/loader-fetch.ts +23 -3
  117. package/src/rsc/manifest-init.ts +5 -1
  118. package/src/rsc/progressive-enhancement.ts +14 -2
  119. package/src/rsc/rsc-rendering.ts +13 -1
  120. package/src/rsc/server-action.ts +8 -0
  121. package/src/rsc/ssr-setup.ts +2 -2
  122. package/src/rsc/types.ts +11 -1
  123. package/src/segment-content-promise.ts +67 -0
  124. package/src/segment-loader-promise.ts +122 -0
  125. package/src/segment-system.tsx +109 -23
  126. package/src/server/context.ts +166 -17
  127. package/src/server/handle-store.ts +19 -0
  128. package/src/server/loader-registry.ts +9 -8
  129. package/src/server/request-context.ts +204 -28
  130. package/src/ssr/index.tsx +4 -0
  131. package/src/static-handler.ts +18 -6
  132. package/src/types/cache-types.ts +4 -4
  133. package/src/types/handler-context.ts +149 -49
  134. package/src/types/loader-types.ts +36 -9
  135. package/src/types/route-entry.ts +19 -1
  136. package/src/types/segments.ts +2 -0
  137. package/src/urls/include-helper.ts +24 -14
  138. package/src/urls/path-helper-types.ts +39 -6
  139. package/src/urls/path-helper.ts +48 -13
  140. package/src/urls/pattern-types.ts +12 -0
  141. package/src/urls/response-types.ts +16 -6
  142. package/src/use-loader.tsx +77 -5
  143. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  144. package/src/vite/discovery/discover-routers.ts +5 -1
  145. package/src/vite/discovery/prerender-collection.ts +128 -74
  146. package/src/vite/discovery/state.ts +13 -6
  147. package/src/vite/index.ts +4 -0
  148. package/src/vite/plugin-types.ts +51 -79
  149. package/src/vite/plugins/expose-action-id.ts +1 -3
  150. package/src/vite/plugins/expose-id-utils.ts +12 -0
  151. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  152. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  153. package/src/vite/plugins/performance-tracks.ts +88 -0
  154. package/src/vite/plugins/refresh-cmd.ts +88 -26
  155. package/src/vite/plugins/version-plugin.ts +13 -1
  156. package/src/vite/rango.ts +163 -211
  157. package/src/vite/router-discovery.ts +178 -45
  158. package/src/vite/utils/banner.ts +3 -3
  159. package/src/vite/utils/prerender-utils.ts +37 -5
  160. 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;
@@ -53,6 +53,12 @@ export function useNavigation<T>(
53
53
  });
54
54
  const prevState = useRef(baseValue);
55
55
 
56
+ // Tracks whether the most recent setOptimisticValue call pinned the value
57
+ // to a non-idle state. Used to decide whether to emit a release update when
58
+ // returning to idle, so the optimistic store doesn't stay pinned if a
59
+ // parent transition (e.g. <Link> click) is still pending.
60
+ const optimisticPinnedRef = useRef(false);
61
+
56
62
  // useOptimistic allows immediate updates during transitions/actions
57
63
  const [value, setOptimisticValue] = useOptimistic(baseValue);
58
64
 
@@ -82,11 +88,25 @@ export function useNavigation<T>(
82
88
  const hasInflightActions =
83
89
  ctx.eventController.getInflightActions().size > 0;
84
90
 
85
- if (hasInflightActions || publicState.state !== "idle") {
86
- // Use optimistic update for immediate feedback during transitions
91
+ const shouldPin = hasInflightActions || publicState.state !== "idle";
92
+
93
+ if (shouldPin) {
94
+ // Pin the optimistic store so the loading value shows immediately
95
+ // even if a parent transition (e.g. <Link> click) defers the
96
+ // urgent setBaseValue commit.
97
+ startTransition(() => {
98
+ setOptimisticValue(nextSelected);
99
+ });
100
+ optimisticPinnedRef.current = true;
101
+ } else if (optimisticPinnedRef.current) {
102
+ // Release a previously-pinned optimistic value. Without this,
103
+ // useOptimistic keeps returning the stale loading value while
104
+ // any parent transition is still pending, even after baseValue
105
+ // flipped to idle.
87
106
  startTransition(() => {
88
107
  setOptimisticValue(nextSelected);
89
108
  });
109
+ optimisticPinnedRef.current = false;
90
110
  }
91
111
 
92
112
  // Always update base state so UI reflects current state
@@ -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
  }