@rangojs/router 0.0.0-experimental.29 → 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 +87 -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 +82 -12
  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 +134 -59
  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 +55 -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 +16 -22
  97. package/src/router/middleware.ts +24 -30
  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 +36 -4
  105. package/src/router/router-options.ts +37 -11
  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 +59 -6
  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 +12 -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 +9 -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,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)
@@ -263,71 +269,139 @@ export async function initBrowserApp(
263
269
  // Build initial tree with rootLayout
264
270
  const initialTree = renderSegments(initialPayload.metadata!.segments);
265
271
 
266
- // Setup HMR
272
+ // Setup HMR with debounce — burst saves (format-on-save, rapid edits)
273
+ // fire many rsc:update events in quick succession. Without debouncing,
274
+ // each event triggers a fetchPartial() which on slow routes can pile up
275
+ // and overwhelm the worker (cross-request promise issues, 500s).
267
276
  if (import.meta.hot) {
268
- import.meta.hot.on("rsc:update", async () => {
269
- console.log("[RSCRouter] HMR: Server update, refetching RSC");
270
-
271
- const handle = eventController.startNavigation(window.location.href, {
272
- replace: true,
273
- });
274
- const streamingToken = handle.startStreaming();
275
-
276
- const interceptSourceUrl = store.getInterceptSourceUrl();
277
-
278
- try {
279
- const { payload, streamComplete } = await client.fetchPartial({
280
- targetUrl: window.location.href,
281
- segmentIds: [],
282
- previousUrl: store.getSegmentState().currentUrl,
283
- interceptSourceUrl: interceptSourceUrl || undefined,
284
- 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,
285
314
  });
315
+ const streamingToken = handle.startStreaming();
316
+
317
+ const interceptSourceUrl = store.getInterceptSourceUrl();
318
+
319
+ try {
320
+ const { payload, streamComplete } = await client.fetchPartial({
321
+ targetUrl: window.location.href,
322
+ segmentIds: [],
323
+ previousUrl: store.getSegmentState().currentUrl,
324
+ interceptSourceUrl: interceptSourceUrl || undefined,
325
+ routerId: store.getRouterId?.(),
326
+ hmr: true,
327
+ signal: abort.signal,
328
+ });
286
329
 
287
- if (payload.metadata?.isPartial) {
288
- const segments = payload.metadata.segments || [];
289
- const matched = payload.metadata.matched || [];
330
+ if (abort.signal.aborted) return;
290
331
 
291
- // Derive intercept state from the returned payload, not the
292
- // pre-fetch store snapshot. If the HMR edit removed intercept
293
- // behavior, the response won't contain intercept segments.
294
- 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
+ }
295
338
 
296
- // Sync store intercept state with what the server returned
297
- if (!responseIsIntercept && interceptSourceUrl) {
298
- 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);
299
352
  }
300
353
 
301
- store.setSegmentIds(matched);
302
- 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
+ }
303
390
 
304
- const historyKey = generateHistoryKey(window.location.href, {
305
- intercept: responseIsIntercept,
306
- });
307
- store.setHistoryKey(historyKey);
308
- const currentHandleData = eventController.getHandleState().data;
309
- store.cacheSegmentsForHistory(
310
- historyKey,
311
- segments,
312
- currentHandleData,
313
- );
314
-
315
- const { main, intercept } = splitInterceptSegments(segments);
316
- store.emitUpdate({
317
- root: renderSegments(main, {
318
- interceptSegments: intercept.length > 0 ? intercept : undefined,
319
- }),
320
- metadata: payload.metadata,
321
- });
391
+ await streamComplete;
392
+ handle.complete(new URL(window.location.href));
393
+ console.log("[RSCRouter] HMR: RSC stream complete");
394
+ } catch (err) {
395
+ if (abort.signal.aborted) return;
396
+ console.warn("[RSCRouter] HMR: Refetch failed, reloading page", err);
397
+ window.location.reload();
398
+ return;
399
+ } finally {
400
+ if (hmrAbort === abort) hmrAbort = null;
401
+ streamingToken.end();
402
+ handle[Symbol.dispose]();
322
403
  }
323
-
324
- await streamComplete;
325
- handle.complete(new URL(window.location.href));
326
- console.log("[RSCRouter] HMR: RSC stream complete");
327
- } finally {
328
- streamingToken.end();
329
- handle[Symbol.dispose]();
330
- }
404
+ }, 200);
331
405
  });
332
406
  }
333
407
 
@@ -426,6 +500,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
426
500
  initialTheme={initialTheme}
427
501
  warmupEnabled={warmupEnabled}
428
502
  version={version}
503
+ basename={initialPayload.metadata?.basename}
429
504
  />
430
505
  );
431
506
  }
@@ -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
  /**
@@ -6,6 +6,39 @@ 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";
10
+
11
+ /**
12
+ * Carry forward renderSegments' internal memoization fields from the cached
13
+ * segment onto a merged/spread result. Without this, every reconcile that
14
+ * produces a fresh object ref drops the stable Promise wrappers that keep
15
+ * React's use() in "known fulfilled" state. The hasSameReferences guards
16
+ * inside renderSegments invalidate stale memoization when the underlying
17
+ * sources actually change, so copying is always safe. Server-provided values
18
+ * on `merged` (e.g., parallel intercept loaderDataPromise) win via the
19
+ * undefined check.
20
+ */
21
+ const MEMO_FIELDS = [
22
+ "contentPromise",
23
+ "contentSource",
24
+ "layoutLoaderSources",
25
+ "parallelLoaderSources",
26
+ "loaderDataPromise",
27
+ ] as const;
28
+
29
+ function preserveMemoization(
30
+ merged: ResolvedSegment,
31
+ cached: ResolvedSegment,
32
+ ): ResolvedSegment {
33
+ let result: ResolvedSegment | null = null;
34
+ for (const field of MEMO_FIELDS) {
35
+ if (merged[field] === undefined && cached[field] !== undefined) {
36
+ if (!result) result = { ...merged };
37
+ (result as any)[field] = cached[field];
38
+ }
39
+ }
40
+ return result ?? merged;
41
+ }
9
42
 
10
43
  /**
11
44
  * Determines the merging behavior for segment reconciliation.
@@ -85,15 +118,33 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
85
118
  const cachedSegments = new Map<string, ResolvedSegment>();
86
119
  input.cachedSegments.forEach((s) => cachedSegments.set(s.id, s));
87
120
 
121
+ const diffSet = new Set(diff);
122
+ debugLog(
123
+ `[reconcile] actor=${actor}, matched=${matched.length}, diff=${diff.length}`,
124
+ );
125
+ debugLog(
126
+ `[reconcile] server segments: ${[...serverSegments.keys()].join(", ")}`,
127
+ );
128
+ debugLog(
129
+ `[reconcile] cached segments: ${[...cachedSegments.keys()].join(", ")}`,
130
+ );
131
+
88
132
  const segments = matched
89
133
  .map((segId: string) => {
90
134
  const fromServer = serverSegments.get(segId);
91
135
  const fromCache = cachedSegments.get(segId);
92
136
 
93
137
  if (fromServer) {
138
+ const inDiff = diffSet.has(segId);
94
139
  // Merge partial loader data when server returns fewer loaders than cached
95
140
  if (shouldMergeLoaders && needsLoaderMerge(fromServer, fromCache)) {
96
- return mergeSegmentLoaders(fromServer, fromCache);
141
+ debugLog(
142
+ `[reconcile] ${segId}: MERGE loaders (server partial, ${inDiff ? "in diff" : "not in diff"})`,
143
+ );
144
+ return preserveMemoization(
145
+ mergeSegmentLoaders(fromServer, fromCache),
146
+ fromCache,
147
+ );
97
148
  }
98
149
 
99
150
  // Preserve cached structural properties to maintain consistent React tree.
@@ -143,8 +194,14 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
143
194
  // above fails to preserve a value it should have.
144
195
  assertSegmentStructure(fromCache, merged, context);
145
196
 
146
- return merged;
197
+ debugLog(
198
+ `[reconcile] ${segId}: SERVER+CACHE merge (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, component=${fromServer.component === null ? "null→cached" : "server"})`,
199
+ );
200
+ return preserveMemoization(merged, fromCache);
147
201
  }
202
+ debugLog(
203
+ `[reconcile] ${segId}: SERVER only (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, no cache entry)`,
204
+ );
148
205
  return fromServer;
149
206
  }
150
207
 
@@ -158,15 +215,20 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
158
215
  return fromCache;
159
216
  }
160
217
 
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
- // - Clear truthy loading (active skeleton) to prevent suspense on cached content
164
- if (actor !== "action") {
165
- if (fromCache.loading !== undefined && fromCache.loading !== false) {
166
- return { ...fromCache, loading: undefined };
167
- }
168
- }
218
+ debugLog(
219
+ `[reconcile] ${segId}: CACHE only (not from server, type=${fromCache.type}, component=${fromCache.component != null ? "yes" : "null"})`,
220
+ );
169
221
 
222
+ // Return the cached segment as-is, regardless of actor. We used to clear
223
+ // truthy `loading` here to prevent a stale Suspense fallback from
224
+ // committing against cached content, but that swapped the render tree
225
+ // from the LoaderBoundary branch to the plain OutletProvider branch
226
+ // inside renderSegments, causing React to unmount the entire chain
227
+ // (LoaderBoundary > Suspense > LoaderResolver > RouteContentWrapper >
228
+ // Suspender) every time the user opened an intercept or navigated back
229
+ // to a cached page. The flicker is now prevented by renderSegments'
230
+ // promise memoization keeping React's use() in "known fulfilled" state,
231
+ // so preserving `loading` keeps the element tree stable.
170
232
  return fromCache;
171
233
  })
172
234
  .filter(Boolean) as ResolvedSegment[];
@@ -29,6 +29,7 @@ import {
29
29
  } from "./response-adapter.js";
30
30
  import { mergeLocationState } from "./history-state.js";
31
31
  import { classifyActionOutcome } from "./action-coordinator.js";
32
+ import { getAppVersion } from "./app-version.js";
32
33
 
33
34
  // Polyfill Symbol.dispose/asyncDispose for Safari and older browsers
34
35
  if (typeof Symbol.dispose === "undefined") {
@@ -43,8 +44,6 @@ if (typeof Symbol.asyncDispose === "undefined") {
43
44
  */
44
45
  export interface ServerActionBridgeConfigWithController extends ServerActionBridgeConfig {
45
46
  eventController: EventController;
46
- /** RSC version from initial payload metadata */
47
- version?: string;
48
47
  /** Callback to trigger SPA navigation (for action redirects) */
49
48
  onNavigate?: (
50
49
  url: string,
@@ -75,7 +74,6 @@ export function createServerActionBridge(
75
74
  deps,
76
75
  onUpdate,
77
76
  renderSegments,
78
- version,
79
77
  onNavigate,
80
78
  } = config;
81
79
 
@@ -86,7 +84,7 @@ export function createServerActionBridge(
86
84
  client,
87
85
  onUpdate,
88
86
  renderSegments,
89
- version,
87
+ getVersion: getAppVersion,
90
88
  });
91
89
 
92
90
  /**
@@ -165,9 +163,15 @@ export function createServerActionBridge(
165
163
  segmentState.currentSegmentIds.join(","),
166
164
  );
167
165
  // Add version param for version mismatch detection
166
+ const version = getAppVersion();
168
167
  if (version) {
169
168
  url.searchParams.set("_rsc_v", version);
170
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
+ }
171
175
 
172
176
  // Encode arguments
173
177
  const encodedBody = await deps.encodeReply(args, { temporaryReferences });
@@ -206,7 +210,6 @@ export function createServerActionBridge(
206
210
  "rsc-action": id,
207
211
  "X-RSC-Router-Client-Path": segmentState.currentUrl,
208
212
  ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
209
- // Send intercept source URL so server can maintain intercept context
210
213
  ...(interceptSourceUrl && {
211
214
  "X-RSC-Router-Intercept-Source": interceptSourceUrl,
212
215
  }),
@@ -309,7 +312,6 @@ export function createServerActionBridge(
309
312
  matchedCount: payload.metadata?.matched?.length ?? 0,
310
313
  diffCount: payload.metadata?.diff?.length ?? 0,
311
314
  });
312
-
313
315
  // Guard: if the action was aborted while streaming (e.g., user navigated
314
316
  // away or abortAllActions fired), bail out before any reconcile/render/cache
315
317
  // writes to avoid overwriting the current UI with stale action results.