@rangojs/router 0.0.0-experimental.8123bb7e → 0.0.0-experimental.82

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 (129) hide show
  1. package/README.md +76 -18
  2. package/dist/bin/rango.js +130 -47
  3. package/dist/vite/index.js +829 -380
  4. package/dist/vite/index.js.bak +5448 -0
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +4 -4
  7. package/skills/handler-use/SKILL.md +362 -0
  8. package/skills/hooks/SKILL.md +24 -18
  9. package/skills/intercept/SKILL.md +20 -0
  10. package/skills/layout/SKILL.md +22 -0
  11. package/skills/links/SKILL.md +3 -1
  12. package/skills/middleware/SKILL.md +34 -3
  13. package/skills/migrate-nextjs/SKILL.md +560 -0
  14. package/skills/migrate-react-router/SKILL.md +765 -0
  15. package/skills/parallel/SKILL.md +59 -0
  16. package/skills/prerender/SKILL.md +110 -68
  17. package/skills/rango/SKILL.md +24 -22
  18. package/skills/route/SKILL.md +24 -0
  19. package/skills/router-setup/SKILL.md +35 -0
  20. package/src/__internal.ts +1 -1
  21. package/src/browser/app-version.ts +14 -0
  22. package/src/browser/navigation-bridge.ts +37 -5
  23. package/src/browser/navigation-client.ts +128 -77
  24. package/src/browser/navigation-store.ts +43 -8
  25. package/src/browser/partial-update.ts +41 -7
  26. package/src/browser/prefetch/cache.ts +113 -21
  27. package/src/browser/prefetch/fetch.ts +156 -18
  28. package/src/browser/prefetch/queue.ts +36 -5
  29. package/src/browser/react/Link.tsx +72 -8
  30. package/src/browser/react/NavigationProvider.tsx +14 -3
  31. package/src/browser/react/context.ts +7 -2
  32. package/src/browser/react/use-handle.ts +9 -58
  33. package/src/browser/react/use-navigation.ts +22 -2
  34. package/src/browser/react/use-params.ts +11 -1
  35. package/src/browser/react/use-router.ts +21 -8
  36. package/src/browser/rsc-router.tsx +26 -3
  37. package/src/browser/scroll-restoration.ts +10 -8
  38. package/src/browser/segment-reconciler.ts +36 -14
  39. package/src/browser/server-action-bridge.ts +8 -18
  40. package/src/browser/types.ts +20 -5
  41. package/src/build/generate-manifest.ts +6 -6
  42. package/src/build/generate-route-types.ts +3 -0
  43. package/src/build/route-trie.ts +50 -24
  44. package/src/build/route-types/include-resolution.ts +8 -1
  45. package/src/build/route-types/router-processing.ts +211 -72
  46. package/src/build/route-types/scan-filter.ts +8 -1
  47. package/src/client.tsx +84 -230
  48. package/src/deps/browser.ts +0 -1
  49. package/src/handle.ts +40 -0
  50. package/src/index.rsc.ts +3 -1
  51. package/src/index.ts +46 -6
  52. package/src/prerender/store.ts +5 -4
  53. package/src/prerender.ts +138 -77
  54. package/src/reverse.ts +25 -1
  55. package/src/route-definition/dsl-helpers.ts +194 -32
  56. package/src/route-definition/helpers-types.ts +61 -14
  57. package/src/route-definition/index.ts +3 -0
  58. package/src/route-definition/redirect.ts +9 -1
  59. package/src/route-definition/resolve-handler-use.ts +149 -0
  60. package/src/route-types.ts +18 -0
  61. package/src/router/content-negotiation.ts +100 -1
  62. package/src/router/handler-context.ts +51 -15
  63. package/src/router/intercept-resolution.ts +9 -4
  64. package/src/router/lazy-includes.ts +5 -5
  65. package/src/router/loader-resolution.ts +150 -21
  66. package/src/router/manifest.ts +22 -13
  67. package/src/router/match-api.ts +124 -189
  68. package/src/router/match-middleware/cache-lookup.ts +28 -8
  69. package/src/router/match-middleware/segment-resolution.ts +53 -0
  70. package/src/router/match-result.ts +82 -4
  71. package/src/router/middleware-types.ts +0 -6
  72. package/src/router/middleware.ts +0 -3
  73. package/src/router/navigation-snapshot.ts +182 -0
  74. package/src/router/prerender-match.ts +110 -10
  75. package/src/router/preview-match.ts +30 -102
  76. package/src/router/request-classification.ts +310 -0
  77. package/src/router/route-snapshot.ts +245 -0
  78. package/src/router/router-interfaces.ts +36 -4
  79. package/src/router/router-options.ts +37 -11
  80. package/src/router/segment-resolution/fresh.ts +70 -5
  81. package/src/router/segment-resolution/revalidation.ts +87 -9
  82. package/src/router.ts +53 -5
  83. package/src/rsc/handler.ts +472 -397
  84. package/src/rsc/loader-fetch.ts +18 -3
  85. package/src/rsc/manifest-init.ts +5 -1
  86. package/src/rsc/progressive-enhancement.ts +14 -3
  87. package/src/rsc/rsc-rendering.ts +15 -2
  88. package/src/rsc/server-action.ts +10 -2
  89. package/src/rsc/ssr-setup.ts +2 -2
  90. package/src/rsc/types.ts +6 -4
  91. package/src/segment-content-promise.ts +67 -0
  92. package/src/segment-loader-promise.ts +122 -0
  93. package/src/segment-system.tsx +11 -61
  94. package/src/server/context.ts +65 -5
  95. package/src/server/handle-store.ts +19 -0
  96. package/src/server/loader-registry.ts +9 -8
  97. package/src/server/request-context.ts +132 -13
  98. package/src/ssr/index.tsx +3 -0
  99. package/src/static-handler.ts +18 -6
  100. package/src/types/cache-types.ts +4 -4
  101. package/src/types/handler-context.ts +17 -11
  102. package/src/types/loader-types.ts +32 -5
  103. package/src/types/route-entry.ts +12 -1
  104. package/src/types/segments.ts +1 -1
  105. package/src/urls/include-helper.ts +24 -14
  106. package/src/urls/path-helper-types.ts +39 -6
  107. package/src/urls/path-helper.ts +47 -12
  108. package/src/urls/pattern-types.ts +12 -0
  109. package/src/urls/response-types.ts +16 -6
  110. package/src/use-loader.tsx +77 -5
  111. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  112. package/src/vite/discovery/discover-routers.ts +5 -1
  113. package/src/vite/discovery/prerender-collection.ts +128 -74
  114. package/src/vite/discovery/state.ts +13 -4
  115. package/src/vite/index.ts +4 -0
  116. package/src/vite/plugin-types.ts +60 -5
  117. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  118. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  119. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  120. package/src/vite/plugins/expose-id-utils.ts +12 -0
  121. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  122. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  123. package/src/vite/plugins/performance-tracks.ts +64 -211
  124. package/src/vite/plugins/refresh-cmd.ts +88 -26
  125. package/src/vite/rango.ts +17 -11
  126. package/src/vite/router-discovery.ts +237 -37
  127. package/src/vite/utils/prerender-utils.ts +37 -5
  128. package/src/vite/utils/shared-utils.ts +3 -2
  129. package/src/browser/debug-channel.ts +0 -93
@@ -134,9 +134,14 @@ export interface NavigationProviderProps {
134
134
 
135
135
  /**
136
136
  * App version from server payload (stable, immutable).
137
- * Forwarded to prefetch requests for version mismatch detection.
137
+ * Forwarded to context for cache key building.
138
138
  */
139
139
  version?: string;
140
+
141
+ /**
142
+ * URL prefix for all routes (from createRouter({ basename })).
143
+ */
144
+ basename?: string;
140
145
  }
141
146
 
142
147
  /**
@@ -169,6 +174,7 @@ export function NavigationProvider({
169
174
  initialTheme,
170
175
  warmupEnabled,
171
176
  version,
177
+ basename,
172
178
  }: NavigationProviderProps): ReactNode {
173
179
  // Track current payload for rendering (this triggers re-renders)
174
180
  const [payload, setPayload] = useState(initialPayload);
@@ -198,6 +204,7 @@ export function NavigationProvider({
198
204
  navigate,
199
205
  refresh,
200
206
  version,
207
+ basename,
201
208
  }),
202
209
  [],
203
210
  );
@@ -338,8 +345,12 @@ export function NavigationProvider({
338
345
  metadata: update.metadata,
339
346
  });
340
347
 
341
- // Update route params
342
- eventController.setParams(update.metadata.params ?? {});
348
+ // Update route params. Only reset when the server actually sends a params
349
+ // map — an absent `params` field means "no change" (e.g., legacy action
350
+ // responses that omitted params). Explicit `{}` still clears correctly.
351
+ if (update.metadata.params !== undefined) {
352
+ eventController.setParams(update.metadata.params);
353
+ }
343
354
 
344
355
  // Update handle data progressively as it streams in
345
356
  if (update.metadata.handles) {
@@ -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
@@ -16,11 +16,21 @@ import { shallowEqual } from "./shallow-equal.js";
16
16
  * const params = useParams();
17
17
  * // { productId: "123" }
18
18
  *
19
+ * // Annotate the expected shape via a generic
20
+ * const { productId } = useParams<{ productId: string }>();
21
+ *
19
22
  * // With selector
20
23
  * const productId = useParams(p => p.productId);
21
24
  * ```
22
25
  */
23
- export function useParams(): Record<string, string>;
26
+ // `T extends object` (not `Record<string, string | undefined>`) so that
27
+ // interface shapes pass the constraint — interfaces lack an implicit
28
+ // index signature and would otherwise be rejected. The generic is a
29
+ // shape annotation, not a runtime check; the body always returns the
30
+ // underlying params map unchanged.
31
+ export function useParams<
32
+ T extends object = Record<string, string>,
33
+ >(): Readonly<T>;
24
34
  export function useParams<T>(
25
35
  selector: (params: Record<string, string>) => T,
26
36
  ): T;
@@ -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
  }
@@ -23,6 +23,7 @@ import type { EventController } from "./event-controller.js";
23
23
  import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
24
24
  import { initRangoState } from "./rango-state.js";
25
25
  import { initPrefetchCache } from "./prefetch/cache.js";
26
+ import { setAppVersion } from "./app-version.js";
26
27
  import {
27
28
  isInterceptSegment,
28
29
  splitInterceptSegments,
@@ -139,7 +140,6 @@ export async function initBrowserApp(
139
140
  initialTheme,
140
141
  } = options;
141
142
 
142
- // Load initial payload from SSR-injected __FLIGHT_DATA__
143
143
  const initialPayload =
144
144
  await deps.createFromReadableStream<RscPayload>(rscStream);
145
145
 
@@ -164,6 +164,12 @@ export async function initBrowserApp(
164
164
  ...(storeOptions?.cacheSize && { cacheSize: storeOptions.cacheSize }),
165
165
  });
166
166
 
167
+ // Seed router identity from the initial SSR payload so the first
168
+ // cross-app SPA navigation can detect the app switch.
169
+ if (initialPayload.metadata?.routerId) {
170
+ store.setRouterId?.(initialPayload.metadata.routerId);
171
+ }
172
+
167
173
  // Create event controller for reactive state management
168
174
  const eventController = createEventController({
169
175
  initialLocation: new URL(window.location.href),
@@ -205,6 +211,7 @@ export async function initBrowserApp(
205
211
  // Initialize the localStorage state key for cache invalidation.
206
212
  // Uses the build version so a new deploy automatically busts all cached prefetches.
207
213
  initRangoState(version ?? "0");
214
+ setAppVersion(version);
208
215
 
209
216
  // Initialize the in-memory prefetch cache TTL from server config.
210
217
  // A value of 0 disables the cache; undefined falls back to the module default.
@@ -231,7 +238,6 @@ export async function initBrowserApp(
231
238
  deps,
232
239
  onUpdate: (update) => store.emitUpdate(update),
233
240
  renderSegments,
234
- version,
235
241
  onNavigate: (url, options) => {
236
242
  if (!navigateFn) {
237
243
  window.location.href = url;
@@ -249,7 +255,7 @@ export async function initBrowserApp(
249
255
  client,
250
256
  onUpdate: (update) => store.emitUpdate(update),
251
257
  renderSegments,
252
- version,
258
+ version: version,
253
259
  });
254
260
 
255
261
  // Connect action redirect → navigation bridge (now that both are initialized)
@@ -316,6 +322,7 @@ export async function initBrowserApp(
316
322
  segmentIds: [],
317
323
  previousUrl: store.getSegmentState().currentUrl,
318
324
  interceptSourceUrl: interceptSourceUrl || undefined,
325
+ routerId: store.getRouterId?.(),
319
326
  hmr: true,
320
327
  signal: abort.signal,
321
328
  });
@@ -329,6 +336,21 @@ export async function initBrowserApp(
329
336
  throw new Error("HMR refetch returned invalid payload");
330
337
  }
331
338
 
339
+ // Update version BEFORE rebuilding state so that
340
+ // clearHistoryCache() runs first, then the fresh segment
341
+ // cache entry we create below survives.
342
+ const newVersion = payload.metadata.version;
343
+ if (newVersion && newVersion !== version) {
344
+ console.log(
345
+ "[RSCRouter] HMR: version changed",
346
+ version,
347
+ "→",
348
+ newVersion,
349
+ "clearing caches",
350
+ );
351
+ navigationBridge.updateVersion(newVersion);
352
+ }
353
+
332
354
  if (payload.metadata?.isPartial) {
333
355
  const segments = payload.metadata.segments || [];
334
356
  const matched = payload.metadata.matched || [];
@@ -478,6 +500,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
478
500
  initialTheme={initialTheme}
479
501
  warmupEnabled={warmupEnabled}
480
502
  version={version}
503
+ basename={initialPayload.metadata?.basename}
481
504
  />
482
505
  );
483
506
  }
@@ -356,19 +356,18 @@ export function handleNavigationEnd(options: {
356
356
  scroll?: boolean;
357
357
  isStreaming?: () => boolean;
358
358
  }): void {
359
- if (!initialized) {
360
- return;
361
- }
362
-
363
359
  const { restore = false, scroll = true, isStreaming } = options;
364
360
 
365
- // Don't scroll if explicitly disabled
366
- if (scroll === false) {
361
+ // Don't scroll if explicitly disabled or not in a browser
362
+ if (scroll === false || typeof window === "undefined") {
367
363
  return;
368
364
  }
369
365
 
370
- // For back/forward (restore), try to restore saved position
371
- 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) {
372
371
  if (restoreScrollPosition({ retryIfStreaming: true, isStreaming })) {
373
372
  return;
374
373
  }
@@ -378,6 +377,9 @@ export function handleNavigationEnd(options: {
378
377
  // Defer hash and scroll-to-top to after React paints the new content,
379
378
  // so the user doesn't see the current page jump before the new route appears.
380
379
  deferToNextPaint(() => {
380
+ // Re-check: the deferred callback may fire after environment teardown
381
+ if (typeof window === "undefined") return;
382
+
381
383
  // Try hash scrolling first
382
384
  if (scrollToHash()) {
383
385
  return;
@@ -6,6 +6,7 @@ import {
6
6
  } from "./merge-segment-loaders.js";
7
7
  import { assertSegmentStructure } from "./segment-structure-assert.js";
8
8
  import { splitInterceptSegments } from "./intercept-utils.js";
9
+ import { debugLog } from "./logging.js";
9
10
 
10
11
  /**
11
12
  * Determines the merging behavior for segment reconciliation.
@@ -85,14 +86,29 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
85
86
  const cachedSegments = new Map<string, ResolvedSegment>();
86
87
  input.cachedSegments.forEach((s) => cachedSegments.set(s.id, s));
87
88
 
89
+ const diffSet = new Set(diff);
90
+ debugLog(
91
+ `[reconcile] actor=${actor}, matched=${matched.length}, diff=${diff.length}`,
92
+ );
93
+ debugLog(
94
+ `[reconcile] server segments: ${[...serverSegments.keys()].join(", ")}`,
95
+ );
96
+ debugLog(
97
+ `[reconcile] cached segments: ${[...cachedSegments.keys()].join(", ")}`,
98
+ );
99
+
88
100
  const segments = matched
89
101
  .map((segId: string) => {
90
102
  const fromServer = serverSegments.get(segId);
91
103
  const fromCache = cachedSegments.get(segId);
92
104
 
93
105
  if (fromServer) {
106
+ const inDiff = diffSet.has(segId);
94
107
  // Merge partial loader data when server returns fewer loaders than cached
95
108
  if (shouldMergeLoaders && needsLoaderMerge(fromServer, fromCache)) {
109
+ debugLog(
110
+ `[reconcile] ${segId}: MERGE loaders (server partial, ${inDiff ? "in diff" : "not in diff"})`,
111
+ );
96
112
  return mergeSegmentLoaders(fromServer, fromCache);
97
113
  }
98
114
 
@@ -143,8 +159,14 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
143
159
  // above fails to preserve a value it should have.
144
160
  assertSegmentStructure(fromCache, merged, context);
145
161
 
162
+ debugLog(
163
+ `[reconcile] ${segId}: SERVER+CACHE merge (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, component=${fromServer.component === null ? "null→cached" : "server"})`,
164
+ );
146
165
  return merged;
147
166
  }
167
+ debugLog(
168
+ `[reconcile] ${segId}: SERVER only (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, no cache entry)`,
169
+ );
148
170
  return fromServer;
149
171
  }
150
172
 
@@ -158,20 +180,20 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
158
180
  return fromCache;
159
181
  }
160
182
 
161
- // For non-action actors: cached segments the server decided not to re-render.
162
- // - Preserve loading=false (suppressed boundary) to maintain tree structure
163
- // - Preserve parallel segment loading so renderSegments can reconstruct
164
- // parallel-owned loader markers from the cached slot metadata
165
- // - Clear other truthy loading values to prevent suspense on cached content
166
- if (actor !== "action") {
167
- if (fromCache.type === "parallel" && fromCache.loading !== undefined) {
168
- return fromCache;
169
- }
170
- if (fromCache.loading !== undefined && fromCache.loading !== false) {
171
- return { ...fromCache, loading: undefined };
172
- }
173
- }
174
-
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.
175
197
  return fromCache;
176
198
  })
177
199
  .filter(Boolean) as ResolvedSegment[];
@@ -4,8 +4,6 @@ import type {
4
4
  RscPayload,
5
5
  } from "./types.js";
6
6
  import { createPartialUpdater } from "./partial-update.js";
7
- import { createClientDebugChannel, DEBUG_ID_HEADER } from "./debug-channel.js";
8
- import { findSourceMapURL } from "../deps/browser.js";
9
7
  import { createNavigationTransaction } from "./navigation-transaction.js";
10
8
  import {
11
9
  reconcileSegments,
@@ -31,6 +29,7 @@ import {
31
29
  } from "./response-adapter.js";
32
30
  import { mergeLocationState } from "./history-state.js";
33
31
  import { classifyActionOutcome } from "./action-coordinator.js";
32
+ import { getAppVersion } from "./app-version.js";
34
33
 
35
34
  // Polyfill Symbol.dispose/asyncDispose for Safari and older browsers
36
35
  if (typeof Symbol.dispose === "undefined") {
@@ -45,8 +44,6 @@ if (typeof Symbol.asyncDispose === "undefined") {
45
44
  */
46
45
  export interface ServerActionBridgeConfigWithController extends ServerActionBridgeConfig {
47
46
  eventController: EventController;
48
- /** RSC version from initial payload metadata */
49
- version?: string;
50
47
  /** Callback to trigger SPA navigation (for action redirects) */
51
48
  onNavigate?: (
52
49
  url: string,
@@ -77,7 +74,6 @@ export function createServerActionBridge(
77
74
  deps,
78
75
  onUpdate,
79
76
  renderSegments,
80
- version,
81
77
  onNavigate,
82
78
  } = config;
83
79
 
@@ -88,7 +84,7 @@ export function createServerActionBridge(
88
84
  client,
89
85
  onUpdate,
90
86
  renderSegments,
91
- version,
87
+ getVersion: getAppVersion,
92
88
  });
93
89
 
94
90
  /**
@@ -167,9 +163,15 @@ export function createServerActionBridge(
167
163
  segmentState.currentSegmentIds.join(","),
168
164
  );
169
165
  // Add version param for version mismatch detection
166
+ const version = getAppVersion();
170
167
  if (version) {
171
168
  url.searchParams.set("_rsc_v", version);
172
169
  }
170
+ // Add router ID for app switch detection
171
+ const rid = store.getRouterId?.();
172
+ if (rid) {
173
+ url.searchParams.set("_rsc_rid", rid);
174
+ }
173
175
 
174
176
  // Encode arguments
175
177
  const encodedBody = await deps.encodeReply(args, { temporaryReferences });
@@ -201,14 +203,6 @@ export function createServerActionBridge(
201
203
  const onHandleAbort = () => fetchAbort.abort();
202
204
  handle.signal.addEventListener("abort", onHandleAbort, { once: true });
203
205
 
204
- // Dev-only: create debug channel for React Performance Tracks
205
- const debugId = (import.meta as any).hot
206
- ? crypto.randomUUID()
207
- : undefined;
208
- const debugChannel = debugId
209
- ? createClientDebugChannel(debugId)
210
- : undefined;
211
-
212
206
  // Send action request with stream tracking
213
207
  const responsePromise = fetch(url, {
214
208
  method: "POST",
@@ -216,11 +210,9 @@ export function createServerActionBridge(
216
210
  "rsc-action": id,
217
211
  "X-RSC-Router-Client-Path": segmentState.currentUrl,
218
212
  ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
219
- // Send intercept source URL so server can maintain intercept context
220
213
  ...(interceptSourceUrl && {
221
214
  "X-RSC-Router-Intercept-Source": interceptSourceUrl,
222
215
  }),
223
- ...(debugId && { [DEBUG_ID_HEADER]: debugId }),
224
216
  },
225
217
  body: encodedBody,
226
218
  signal: fetchAbort.signal,
@@ -283,7 +275,6 @@ export function createServerActionBridge(
283
275
  try {
284
276
  payload = await deps.createFromFetch<RscPayload>(responsePromise, {
285
277
  temporaryReferences,
286
- ...(debugChannel && { debugChannel, findSourceMapURL }),
287
278
  });
288
279
  } catch (error) {
289
280
  // Clean up streaming token on error (may be null if fetch failed before .then() ran)
@@ -321,7 +312,6 @@ export function createServerActionBridge(
321
312
  matchedCount: payload.metadata?.matched?.length ?? 0,
322
313
  diffCount: payload.metadata?.diff?.length ?? 0,
323
314
  });
324
-
325
315
  // Guard: if the action was aborted while streaming (e.g., user navigated
326
316
  // away or abortAllActions fired), bail out before any reconcile/render/cache
327
317
  // writes to avoid overwriting the current UI with stale action results.
@@ -32,6 +32,9 @@ export type HandleData = Record<string, Record<string, unknown[]>>;
32
32
  export interface RscMetadata {
33
33
  pathname: string;
34
34
  segments: ResolvedSegment[];
35
+ /** Router instance ID. When this changes between navigations, the client
36
+ * forces a full tree replacement (app switch via host router). */
37
+ routerId?: string;
35
38
  isPartial?: boolean;
36
39
  isError?: boolean;
37
40
  matched?: string[];
@@ -70,6 +73,8 @@ export interface RscMetadata {
70
73
  * Included when theme is enabled in router config.
71
74
  */
72
75
  initialTheme?: Theme;
76
+ /** URL prefix for all routes (from createRouter({ basename })). */
77
+ basename?: string;
73
78
  /** Whether connection warmup is enabled */
74
79
  warmupEnabled?: boolean;
75
80
  /** Server-side redirect with optional state (for partial requests) */
@@ -343,7 +348,6 @@ export interface RscBrowserDependencies {
343
348
  response: Promise<Response>,
344
349
  options?: {
345
350
  temporaryReferences?: any;
346
- debugChannel?: { readable?: ReadableStream; writable?: WritableStream };
347
351
  findSourceMapURL?: (
348
352
  filename: string,
349
353
  environmentName: string,
@@ -410,10 +414,13 @@ export interface NavigationStore {
410
414
  segments: ResolvedSegment[],
411
415
  handleData?: HandleData,
412
416
  ): void;
413
- getCachedSegments(
414
- historyKey: string,
415
- ):
416
- | { segments: ResolvedSegment[]; stale: boolean; handleData?: HandleData }
417
+ getCachedSegments(historyKey: string):
418
+ | {
419
+ segments: ResolvedSegment[];
420
+ stale: boolean;
421
+ handleData?: HandleData;
422
+ routerId?: string;
423
+ }
417
424
  | undefined;
418
425
  hasHistoryCache(historyKey: string): boolean;
419
426
  updateCacheHandleData(historyKey: string, handleData: HandleData): void;
@@ -429,6 +436,10 @@ export interface NavigationStore {
429
436
  getInterceptSourceUrl(): string | null;
430
437
  setInterceptSourceUrl(url: string | null): void;
431
438
 
439
+ // Router identity tracking (for cross-app navigation detection)
440
+ getRouterId?(): string | undefined;
441
+ setRouterId?(id: string): void;
442
+
432
443
  // UI update notifications
433
444
  onUpdate(callback: UpdateSubscriber): () => void;
434
445
  emitUpdate(update: NavigationUpdate): void;
@@ -459,6 +470,8 @@ export interface FetchPartialOptions {
459
470
  interceptSourceUrl?: string;
460
471
  /** RSC version for cache invalidation detection */
461
472
  version?: string;
473
+ /** Current router ID — server detects app switch and returns full response */
474
+ routerId?: string;
462
475
  /** If true, this is an HMR refetch - server should invalidate manifest cache */
463
476
  hmr?: boolean;
464
477
  }
@@ -527,6 +540,8 @@ export interface NavigationBridge {
527
540
  refresh(): Promise<void>;
528
541
  handlePopstate(): Promise<void>;
529
542
  registerLinkInterception(): () => void;
543
+ /** Update the RSC version (e.g. after HMR). Clears prefetch cache. */
544
+ updateVersion(newVersion: string): void;
530
545
  }
531
546
 
532
547
  /**