@rangojs/router 0.0.0-experimental.8a4d0430 → 0.0.0-experimental.8bcfea43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +126 -38
  3. package/dist/bin/rango.js +138 -50
  4. package/dist/vite/index.js +1171 -461
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +19 -16
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/cache-guide/SKILL.md +32 -0
  9. package/skills/caching/SKILL.md +45 -4
  10. package/skills/handler-use/SKILL.md +362 -0
  11. package/skills/hooks/SKILL.md +28 -20
  12. package/skills/intercept/SKILL.md +20 -0
  13. package/skills/layout/SKILL.md +22 -0
  14. package/skills/links/SKILL.md +91 -17
  15. package/skills/loader/SKILL.md +88 -45
  16. package/skills/middleware/SKILL.md +34 -3
  17. package/skills/migrate-nextjs/SKILL.md +560 -0
  18. package/skills/migrate-react-router/SKILL.md +765 -0
  19. package/skills/parallel/SKILL.md +185 -0
  20. package/skills/prerender/SKILL.md +110 -68
  21. package/skills/rango/SKILL.md +24 -22
  22. package/skills/response-routes/SKILL.md +8 -0
  23. package/skills/route/SKILL.md +55 -0
  24. package/skills/router-setup/SKILL.md +87 -2
  25. package/skills/streams-and-websockets/SKILL.md +283 -0
  26. package/skills/typesafety/SKILL.md +13 -1
  27. package/src/__internal.ts +1 -1
  28. package/src/browser/app-shell.ts +52 -0
  29. package/src/browser/app-version.ts +14 -0
  30. package/src/browser/event-controller.ts +5 -0
  31. package/src/browser/navigation-bridge.ts +90 -16
  32. package/src/browser/navigation-client.ts +167 -59
  33. package/src/browser/navigation-store.ts +68 -9
  34. package/src/browser/navigation-transaction.ts +11 -9
  35. package/src/browser/partial-update.ts +113 -17
  36. package/src/browser/prefetch/cache.ts +184 -16
  37. package/src/browser/prefetch/fetch.ts +180 -33
  38. package/src/browser/prefetch/policy.ts +6 -0
  39. package/src/browser/prefetch/queue.ts +123 -20
  40. package/src/browser/prefetch/resource-ready.ts +77 -0
  41. package/src/browser/rango-state.ts +53 -13
  42. package/src/browser/react/Link.tsx +81 -9
  43. package/src/browser/react/NavigationProvider.tsx +89 -14
  44. package/src/browser/react/context.ts +7 -2
  45. package/src/browser/react/use-handle.ts +9 -58
  46. package/src/browser/react/use-navigation.ts +22 -2
  47. package/src/browser/react/use-params.ts +11 -1
  48. package/src/browser/react/use-router.ts +29 -9
  49. package/src/browser/rsc-router.tsx +168 -65
  50. package/src/browser/scroll-restoration.ts +41 -42
  51. package/src/browser/segment-reconciler.ts +36 -9
  52. package/src/browser/server-action-bridge.ts +8 -6
  53. package/src/browser/types.ts +49 -5
  54. package/src/build/generate-manifest.ts +6 -6
  55. package/src/build/generate-route-types.ts +3 -0
  56. package/src/build/route-trie.ts +50 -24
  57. package/src/build/route-types/include-resolution.ts +8 -1
  58. package/src/build/route-types/router-processing.ts +223 -74
  59. package/src/build/route-types/scan-filter.ts +8 -1
  60. package/src/cache/cache-runtime.ts +15 -11
  61. package/src/cache/cache-scope.ts +48 -7
  62. package/src/cache/cf/cf-cache-store.ts +455 -15
  63. package/src/cache/cf/index.ts +5 -1
  64. package/src/cache/document-cache.ts +17 -7
  65. package/src/cache/index.ts +1 -0
  66. package/src/cache/taint.ts +55 -0
  67. package/src/client.tsx +84 -230
  68. package/src/context-var.ts +72 -2
  69. package/src/debug.ts +2 -2
  70. package/src/handle.ts +40 -0
  71. package/src/index.rsc.ts +6 -1
  72. package/src/index.ts +49 -6
  73. package/src/outlet-context.ts +1 -1
  74. package/src/prerender/store.ts +5 -4
  75. package/src/prerender.ts +138 -77
  76. package/src/response-utils.ts +28 -0
  77. package/src/reverse.ts +27 -2
  78. package/src/route-definition/dsl-helpers.ts +240 -40
  79. package/src/route-definition/helpers-types.ts +67 -19
  80. package/src/route-definition/index.ts +3 -0
  81. package/src/route-definition/redirect.ts +11 -3
  82. package/src/route-definition/resolve-handler-use.ts +155 -0
  83. package/src/route-map-builder.ts +7 -1
  84. package/src/route-types.ts +18 -0
  85. package/src/router/content-negotiation.ts +100 -1
  86. package/src/router/find-match.ts +4 -2
  87. package/src/router/handler-context.ts +101 -25
  88. package/src/router/intercept-resolution.ts +11 -4
  89. package/src/router/lazy-includes.ts +10 -7
  90. package/src/router/loader-resolution.ts +159 -21
  91. package/src/router/logging.ts +5 -2
  92. package/src/router/manifest.ts +31 -16
  93. package/src/router/match-api.ts +127 -192
  94. package/src/router/match-middleware/background-revalidation.ts +30 -2
  95. package/src/router/match-middleware/cache-lookup.ts +94 -17
  96. package/src/router/match-middleware/cache-store.ts +53 -10
  97. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  98. package/src/router/match-middleware/segment-resolution.ts +61 -5
  99. package/src/router/match-result.ts +104 -10
  100. package/src/router/metrics.ts +6 -1
  101. package/src/router/middleware-types.ts +8 -30
  102. package/src/router/middleware.ts +36 -10
  103. package/src/router/navigation-snapshot.ts +182 -0
  104. package/src/router/pattern-matching.ts +60 -9
  105. package/src/router/prerender-match.ts +110 -10
  106. package/src/router/preview-match.ts +30 -102
  107. package/src/router/request-classification.ts +310 -0
  108. package/src/router/route-snapshot.ts +245 -0
  109. package/src/router/router-context.ts +6 -1
  110. package/src/router/router-interfaces.ts +36 -4
  111. package/src/router/router-options.ts +37 -11
  112. package/src/router/segment-resolution/fresh.ts +198 -20
  113. package/src/router/segment-resolution/helpers.ts +29 -24
  114. package/src/router/segment-resolution/loader-cache.ts +1 -0
  115. package/src/router/segment-resolution/revalidation.ts +438 -300
  116. package/src/router/segment-wrappers.ts +2 -0
  117. package/src/router/trie-matching.ts +10 -4
  118. package/src/router/types.ts +1 -0
  119. package/src/router/url-params.ts +49 -0
  120. package/src/router.ts +60 -8
  121. package/src/rsc/handler.ts +478 -374
  122. package/src/rsc/helpers.ts +69 -41
  123. package/src/rsc/loader-fetch.ts +23 -3
  124. package/src/rsc/manifest-init.ts +5 -1
  125. package/src/rsc/progressive-enhancement.ts +16 -2
  126. package/src/rsc/response-route-handler.ts +14 -1
  127. package/src/rsc/rsc-rendering.ts +19 -1
  128. package/src/rsc/server-action.ts +10 -0
  129. package/src/rsc/ssr-setup.ts +2 -2
  130. package/src/rsc/types.ts +9 -1
  131. package/src/segment-content-promise.ts +67 -0
  132. package/src/segment-loader-promise.ts +122 -0
  133. package/src/segment-system.tsx +109 -23
  134. package/src/server/context.ts +166 -17
  135. package/src/server/handle-store.ts +19 -0
  136. package/src/server/loader-registry.ts +9 -8
  137. package/src/server/request-context.ts +194 -60
  138. package/src/ssr/index.tsx +4 -0
  139. package/src/static-handler.ts +18 -6
  140. package/src/types/cache-types.ts +4 -4
  141. package/src/types/handler-context.ts +137 -65
  142. package/src/types/loader-types.ts +41 -15
  143. package/src/types/request-scope.ts +126 -0
  144. package/src/types/route-entry.ts +19 -1
  145. package/src/types/segments.ts +2 -0
  146. package/src/urls/include-helper.ts +24 -14
  147. package/src/urls/path-helper-types.ts +39 -6
  148. package/src/urls/path-helper.ts +48 -13
  149. package/src/urls/pattern-types.ts +12 -0
  150. package/src/urls/response-types.ts +18 -16
  151. package/src/use-loader.tsx +77 -5
  152. package/src/vite/debug.ts +55 -0
  153. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  154. package/src/vite/discovery/discover-routers.ts +5 -1
  155. package/src/vite/discovery/prerender-collection.ts +128 -74
  156. package/src/vite/discovery/state.ts +13 -6
  157. package/src/vite/index.ts +4 -0
  158. package/src/vite/plugin-types.ts +51 -79
  159. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  160. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  161. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  162. package/src/vite/plugins/expose-action-id.ts +1 -3
  163. package/src/vite/plugins/expose-id-utils.ts +12 -0
  164. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  165. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  166. package/src/vite/plugins/performance-tracks.ts +86 -0
  167. package/src/vite/plugins/refresh-cmd.ts +88 -26
  168. package/src/vite/plugins/version-plugin.ts +13 -1
  169. package/src/vite/rango.ts +204 -217
  170. package/src/vite/router-discovery.ts +335 -64
  171. package/src/vite/utils/banner.ts +4 -4
  172. package/src/vite/utils/package-resolution.ts +41 -1
  173. package/src/vite/utils/prerender-utils.ts +37 -5
  174. package/src/vite/utils/shared-utils.ts +3 -2
@@ -3,6 +3,7 @@
3
3
  import { useContext, useMemo } from "react";
4
4
  import { NavigationStoreContext } from "./context.js";
5
5
  import { prefetchDirect } from "../prefetch/fetch.js";
6
+ import { getAppVersion } from "../app-version.js";
6
7
  import type { RouterInstance, RouterNavigateOptions } from "../types.js";
7
8
 
8
9
  /**
@@ -12,6 +13,10 @@ import type { RouterInstance, RouterNavigateOptions } from "../types.js";
12
13
  * useRouter() do not re-render on navigation state changes.
13
14
  * For reactive navigation state, use useNavigation() instead.
14
15
  *
16
+ * Methods read `basename` from the live context on each call so that
17
+ * cross-app navigation (app-switch) sees the current app's basename
18
+ * rather than the one captured at mount time.
19
+ *
15
20
  * @example
16
21
  * ```tsx
17
22
  * const router = useRouter();
@@ -28,15 +33,26 @@ export function useRouter(): RouterInstance {
28
33
  throw new Error("useRouter must be used within NavigationProvider");
29
34
  }
30
35
 
31
- // Stable reference: ctx is itself stable (NavigationProvider memoizes with [])
32
- return useMemo<RouterInstance>(
33
- () => ({
36
+ // Stable reference: ctx itself is stable, and reads on each method call
37
+ // pick up live basename values from the context (backed by a live ref
38
+ // in NavigationProvider), so app-switch transitions are reflected without
39
+ // recreating this object.
40
+ return useMemo<RouterInstance>(() => {
41
+ /** Prefix a root-relative path with basename if not already prefixed. */
42
+ function withBasename(url: string): string {
43
+ const bn = ctx!.basename;
44
+ if (!bn || !url.startsWith("/") || url.startsWith(bn + "/") || url === bn)
45
+ return url;
46
+ return url === "/" ? bn : bn + url;
47
+ }
48
+
49
+ return {
34
50
  push(url: string, options?: RouterNavigateOptions): Promise<void> {
35
- return ctx.navigate(url, { ...options, replace: false });
51
+ return ctx.navigate(withBasename(url), { ...options, replace: false });
36
52
  },
37
53
 
38
54
  replace(url: string, options?: RouterNavigateOptions): Promise<void> {
39
- return ctx.navigate(url, { ...options, replace: true });
55
+ return ctx.navigate(withBasename(url), { ...options, replace: true });
40
56
  },
41
57
 
42
58
  refresh(): Promise<void> {
@@ -46,7 +62,12 @@ export function useRouter(): RouterInstance {
46
62
  prefetch(url: string): void {
47
63
  const segmentState = ctx.store?.getSegmentState();
48
64
  if (segmentState) {
49
- prefetchDirect(url, segmentState.currentSegmentIds, ctx.version);
65
+ prefetchDirect(
66
+ withBasename(url),
67
+ segmentState.currentSegmentIds,
68
+ getAppVersion(),
69
+ ctx.store?.getRouterId?.(),
70
+ );
50
71
  }
51
72
  },
52
73
 
@@ -57,7 +78,6 @@ export function useRouter(): RouterInstance {
57
78
  forward(): void {
58
79
  window.history.forward();
59
80
  },
60
- }),
61
- [],
62
- );
81
+ };
82
+ }, []);
63
83
  }
@@ -23,10 +23,12 @@ import type { EventController } from "./event-controller.js";
23
23
  import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
24
24
  import { initRangoState } from "./rango-state.js";
25
25
  import { initPrefetchCache } from "./prefetch/cache.js";
26
+ import { setAppVersion } from "./app-version.js";
26
27
  import {
27
28
  isInterceptSegment,
28
29
  splitInterceptSegments,
29
30
  } from "./intercept-utils.js";
31
+ import { createAppShellRef } from "./app-shell.js";
30
32
 
31
33
  // Vite HMR types are provided by vite/client
32
34
 
@@ -113,6 +115,13 @@ export interface BrowserAppContext {
113
115
  warmupEnabled?: boolean;
114
116
  /** App version for prefetch version mismatch detection */
115
117
  version?: string;
118
+ /**
119
+ * Live app-shell ref. Cross-app navigations replace its contents so the
120
+ * NavigationProvider and renderSegments pick up the target app's
121
+ * rootLayout, basename, and version without consumer rerenders. Theme,
122
+ * warmup, and prefetch TTL are document-lifetime (see AppShell).
123
+ */
124
+ appShellRef?: import("./app-shell.js").AppShellRef;
116
125
  }
117
126
 
118
127
  // Module-level state for the initialized app
@@ -139,7 +148,6 @@ export async function initBrowserApp(
139
148
  initialTheme,
140
149
  } = options;
141
150
 
142
- // Load initial payload from SSR-injected __FLIGHT_DATA__
143
151
  const initialPayload =
144
152
  await deps.createFromReadableStream<RscPayload>(rscStream);
145
153
 
@@ -164,6 +172,12 @@ export async function initBrowserApp(
164
172
  ...(storeOptions?.cacheSize && { cacheSize: storeOptions.cacheSize }),
165
173
  });
166
174
 
175
+ // Seed router identity from the initial SSR payload so the first
176
+ // cross-app SPA navigation can detect the app switch.
177
+ if (initialPayload.metadata?.routerId) {
178
+ store.setRouterId?.(initialPayload.metadata.routerId);
179
+ }
180
+
167
181
  // Create event controller for reactive state management
168
182
  const eventController = createEventController({
169
183
  initialLocation: new URL(window.location.href),
@@ -198,13 +212,24 @@ export async function initBrowserApp(
198
212
  // Create composable utilities
199
213
  const client = createNavigationClient(deps);
200
214
 
201
- // Extract rootLayout and version from metadata for browser-side re-renders
202
- const rootLayout = initialPayload.metadata?.rootLayout;
215
+ // Capture the per-router app-shell so cross-app navigations can replace
216
+ // it atomically. rootLayout, basename, and version live here and are
217
+ // read through the ref at call time rather than closed over. Theme,
218
+ // warmup, and prefetch TTL are deliberately excluded — they are
219
+ // document-lifetime and stay stable across smooth cross-app transitions.
203
220
  const version = initialPayload.metadata?.version;
221
+ const appShellRef = createAppShellRef({
222
+ routerId: initialPayload.metadata?.routerId,
223
+ rootLayout: initialPayload.metadata?.rootLayout,
224
+ basename: initialPayload.metadata?.basename,
225
+ version,
226
+ });
204
227
 
205
228
  // Initialize the localStorage state key for cache invalidation.
206
- // Uses the build version so a new deploy automatically busts all cached prefetches.
207
- initRangoState(version ?? "0");
229
+ // The build version busts cached prefetches on deploy; the routerId
230
+ // namespaces the key so sibling apps on the same origin don't collide.
231
+ initRangoState(version ?? "0", initialPayload.metadata?.routerId);
232
+ setAppVersion(version);
208
233
 
209
234
  // Initialize the in-memory prefetch cache TTL from server config.
210
235
  // A value of 0 disables the cache; undefined falls back to the module default.
@@ -213,11 +238,17 @@ export async function initBrowserApp(
213
238
  initPrefetchCache(prefetchCacheTTL);
214
239
  }
215
240
 
216
- // Create a bound renderSegments that includes rootLayout
241
+ // Create a bound renderSegments that reads rootLayout through the shell
242
+ // ref. On app switch the ref is updated before the tree re-renders, so
243
+ // the new app's Document (rootLayout) replaces the previous one.
217
244
  const renderSegments = (
218
245
  segments: ResolvedSegment[],
219
246
  options?: RenderSegmentsOptions,
220
- ) => baseRenderSegments(segments, { ...options, rootLayout });
247
+ ) =>
248
+ baseRenderSegments(segments, {
249
+ ...options,
250
+ rootLayout: appShellRef.get().rootLayout,
251
+ });
221
252
 
222
253
  // Lazy reference for navigation bridge — the action bridge is created first
223
254
  // but may need to trigger SPA navigation for action redirects.
@@ -231,7 +262,6 @@ export async function initBrowserApp(
231
262
  deps,
232
263
  onUpdate: (update) => store.emitUpdate(update),
233
264
  renderSegments,
234
- version,
235
265
  onNavigate: (url, options) => {
236
266
  if (!navigateFn) {
237
267
  window.location.href = url;
@@ -249,7 +279,8 @@ export async function initBrowserApp(
249
279
  client,
250
280
  onUpdate: (update) => store.emitUpdate(update),
251
281
  renderSegments,
252
- version,
282
+ version: version,
283
+ appShellRef,
253
284
  });
254
285
 
255
286
  // Connect action redirect → navigation bridge (now that both are initialized)
@@ -263,71 +294,139 @@ export async function initBrowserApp(
263
294
  // Build initial tree with rootLayout
264
295
  const initialTree = renderSegments(initialPayload.metadata!.segments);
265
296
 
266
- // Setup HMR
297
+ // Setup HMR with debounce — burst saves (format-on-save, rapid edits)
298
+ // fire many rsc:update events in quick succession. Without debouncing,
299
+ // each event triggers a fetchPartial() which on slow routes can pile up
300
+ // and overwhelm the worker (cross-request promise issues, 500s).
267
301
  if (import.meta.hot) {
268
- 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,
302
+ let hmrTimer: ReturnType<typeof setTimeout> | null = null;
303
+ let hmrAbort: AbortController | null = null;
304
+
305
+ import.meta.hot.on("rsc:update", () => {
306
+ // Cancel any pending debounce timer
307
+ if (hmrTimer !== null) {
308
+ clearTimeout(hmrTimer);
309
+ }
310
+
311
+ // Abort any in-flight HMR fetch so it doesn't race with the next one
312
+ if (hmrAbort) {
313
+ hmrAbort.abort();
314
+ hmrAbort = null;
315
+ }
316
+
317
+ // Debounce: wait 200ms of quiet before fetching
318
+ hmrTimer = setTimeout(async () => {
319
+ hmrTimer = null;
320
+
321
+ // Don't interrupt an active user navigation — startNavigation()
322
+ // would abort it and refetch the old URL (window.location.href
323
+ // hasn't updated yet). The user's navigation will pick up the
324
+ // new server code when it completes. isNavigating covers the
325
+ // full lifecycle (fetching + streaming, before commit) without
326
+ // blocking on server actions.
327
+ if (eventController.getState().isNavigating) {
328
+ console.log("[RSCRouter] HMR: Skipping — navigation in progress");
329
+ return;
330
+ }
331
+
332
+ console.log("[RSCRouter] HMR: Server update, refetching RSC");
333
+
334
+ const abort = new AbortController();
335
+ hmrAbort = abort;
336
+
337
+ const handle = eventController.startNavigation(window.location.href, {
338
+ replace: true,
285
339
  });
340
+ const streamingToken = handle.startStreaming();
341
+
342
+ const interceptSourceUrl = store.getInterceptSourceUrl();
343
+
344
+ try {
345
+ const { payload, streamComplete } = await client.fetchPartial({
346
+ targetUrl: window.location.href,
347
+ segmentIds: [],
348
+ previousUrl: store.getSegmentState().currentUrl,
349
+ interceptSourceUrl: interceptSourceUrl || undefined,
350
+ routerId: store.getRouterId?.(),
351
+ hmr: true,
352
+ signal: abort.signal,
353
+ });
286
354
 
287
- if (payload.metadata?.isPartial) {
288
- const segments = payload.metadata.segments || [];
289
- const matched = payload.metadata.matched || [];
355
+ if (abort.signal.aborted) return;
290
356
 
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);
357
+ // If the server returned a non-RSC response (404, 500 without
358
+ // error boundary), the payload won't have valid metadata.
359
+ // Reload to recover rather than leaving the page stale.
360
+ if (!payload.metadata) {
361
+ throw new Error("HMR refetch returned invalid payload");
362
+ }
295
363
 
296
- // Sync store intercept state with what the server returned
297
- if (!responseIsIntercept && interceptSourceUrl) {
298
- store.setInterceptSourceUrl(null);
364
+ // Update version BEFORE rebuilding state so that
365
+ // clearHistoryCache() runs first, then the fresh segment
366
+ // cache entry we create below survives.
367
+ const newVersion = payload.metadata.version;
368
+ if (newVersion && newVersion !== version) {
369
+ console.log(
370
+ "[RSCRouter] HMR: version changed",
371
+ version,
372
+ "→",
373
+ newVersion,
374
+ "clearing caches",
375
+ );
376
+ navigationBridge.updateVersion(newVersion);
299
377
  }
300
378
 
301
- store.setSegmentIds(matched);
302
- store.setCurrentUrl(window.location.href);
379
+ if (payload.metadata?.isPartial) {
380
+ const segments = payload.metadata.segments || [];
381
+ const matched = payload.metadata.matched || [];
382
+
383
+ // Derive intercept state from the returned payload, not the
384
+ // pre-fetch store snapshot. If the HMR edit removed intercept
385
+ // behavior, the response won't contain intercept segments.
386
+ const responseIsIntercept = segments.some(isInterceptSegment);
387
+
388
+ // Sync store intercept state with what the server returned
389
+ if (!responseIsIntercept && interceptSourceUrl) {
390
+ store.setInterceptSourceUrl(null);
391
+ }
392
+
393
+ store.setSegmentIds(matched);
394
+ store.setCurrentUrl(window.location.href);
395
+
396
+ const historyKey = generateHistoryKey(window.location.href, {
397
+ intercept: responseIsIntercept,
398
+ });
399
+ store.setHistoryKey(historyKey);
400
+ const currentHandleData = eventController.getHandleState().data;
401
+ store.cacheSegmentsForHistory(
402
+ historyKey,
403
+ segments,
404
+ currentHandleData,
405
+ );
406
+
407
+ const { main, intercept } = splitInterceptSegments(segments);
408
+ store.emitUpdate({
409
+ root: renderSegments(main, {
410
+ interceptSegments: intercept.length > 0 ? intercept : undefined,
411
+ }),
412
+ metadata: payload.metadata,
413
+ });
414
+ }
303
415
 
304
- 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
- });
416
+ await streamComplete;
417
+ handle.complete(new URL(window.location.href));
418
+ console.log("[RSCRouter] HMR: RSC stream complete");
419
+ } catch (err) {
420
+ if (abort.signal.aborted) return;
421
+ console.warn("[RSCRouter] HMR: Refetch failed, reloading page", err);
422
+ window.location.reload();
423
+ return;
424
+ } finally {
425
+ if (hmrAbort === abort) hmrAbort = null;
426
+ streamingToken.end();
427
+ handle[Symbol.dispose]();
322
428
  }
323
-
324
- await streamComplete;
325
- handle.complete(new URL(window.location.href));
326
- console.log("[RSCRouter] HMR: RSC stream complete");
327
- } finally {
328
- streamingToken.end();
329
- handle[Symbol.dispose]();
330
- }
429
+ }, 200);
331
430
  });
332
431
  }
333
432
 
@@ -342,6 +441,7 @@ export async function initBrowserApp(
342
441
  initialTheme: effectiveInitialTheme,
343
442
  warmupEnabled: initialPayload.metadata?.warmupEnabled ?? true,
344
443
  version,
444
+ appShellRef,
345
445
  };
346
446
  browserAppContext = context;
347
447
 
@@ -407,6 +507,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
407
507
  initialTheme,
408
508
  warmupEnabled,
409
509
  version,
510
+ appShellRef,
410
511
  } = getBrowserAppContext();
411
512
 
412
513
  // Signal that the React tree has hydrated. useEffect only fires after
@@ -426,6 +527,8 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
426
527
  initialTheme={initialTheme}
427
528
  warmupEnabled={warmupEnabled}
428
529
  version={version}
530
+ basename={initialPayload.metadata?.basename}
531
+ appShellRef={appShellRef}
429
532
  />
430
533
  );
431
534
  }
@@ -10,6 +10,15 @@
10
10
 
11
11
  import { debugLog } from "./logging.js";
12
12
 
13
+ /**
14
+ * Defers a callback to the next animation frame.
15
+ * Falls back to setTimeout(0) in environments without requestAnimationFrame.
16
+ */
17
+ const deferToNextPaint: (fn: () => void) => void =
18
+ typeof requestAnimationFrame === "function"
19
+ ? requestAnimationFrame
20
+ : (fn) => setTimeout(fn, 0);
21
+
13
22
  const SCROLL_STORAGE_KEY = "rsc-router-scroll-positions";
14
23
 
15
24
  /**
@@ -264,51 +273,35 @@ export function restoreScrollPosition(options?: {
264
273
  return false;
265
274
  }
266
275
 
267
- // 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,7 @@ import {
6
6
  } from "./merge-segment-loaders.js";
7
7
  import { assertSegmentStructure } from "./segment-structure-assert.js";
8
8
  import { splitInterceptSegments } from "./intercept-utils.js";
9
+ import { debugLog } from "./logging.js";
9
10
 
10
11
  /**
11
12
  * Determines the merging behavior for segment reconciliation.
@@ -85,14 +86,29 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
85
86
  const cachedSegments = new Map<string, ResolvedSegment>();
86
87
  input.cachedSegments.forEach((s) => cachedSegments.set(s.id, s));
87
88
 
89
+ const diffSet = new Set(diff);
90
+ debugLog(
91
+ `[reconcile] actor=${actor}, matched=${matched.length}, diff=${diff.length}`,
92
+ );
93
+ debugLog(
94
+ `[reconcile] server segments: ${[...serverSegments.keys()].join(", ")}`,
95
+ );
96
+ debugLog(
97
+ `[reconcile] cached segments: ${[...cachedSegments.keys()].join(", ")}`,
98
+ );
99
+
88
100
  const segments = matched
89
101
  .map((segId: string) => {
90
102
  const fromServer = serverSegments.get(segId);
91
103
  const fromCache = cachedSegments.get(segId);
92
104
 
93
105
  if (fromServer) {
106
+ const inDiff = diffSet.has(segId);
94
107
  // Merge partial loader data when server returns fewer loaders than cached
95
108
  if (shouldMergeLoaders && needsLoaderMerge(fromServer, fromCache)) {
109
+ debugLog(
110
+ `[reconcile] ${segId}: MERGE loaders (server partial, ${inDiff ? "in diff" : "not in diff"})`,
111
+ );
96
112
  return mergeSegmentLoaders(fromServer, fromCache);
97
113
  }
98
114
 
@@ -143,8 +159,14 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
143
159
  // above fails to preserve a value it should have.
144
160
  assertSegmentStructure(fromCache, merged, context);
145
161
 
162
+ debugLog(
163
+ `[reconcile] ${segId}: SERVER+CACHE merge (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, component=${fromServer.component === null ? "null→cached" : "server"})`,
164
+ );
146
165
  return merged;
147
166
  }
167
+ debugLog(
168
+ `[reconcile] ${segId}: SERVER only (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, no cache entry)`,
169
+ );
148
170
  return fromServer;
149
171
  }
150
172
 
@@ -158,15 +180,20 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
158
180
  return fromCache;
159
181
  }
160
182
 
161
- // 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
- }
169
-
183
+ debugLog(
184
+ `[reconcile] ${segId}: CACHE only (not from server, type=${fromCache.type}, component=${fromCache.component != null ? "yes" : "null"})`,
185
+ );
186
+
187
+ // Return the cached segment as-is, regardless of actor. We used to clear
188
+ // truthy `loading` here to prevent a stale Suspense fallback from
189
+ // committing against cached content, but that swapped the render tree
190
+ // from the LoaderBoundary branch to the plain OutletProvider branch
191
+ // inside renderSegments, causing React to unmount the entire chain
192
+ // (LoaderBoundary > Suspense > LoaderResolver > RouteContentWrapper >
193
+ // Suspender) every time the user opened an intercept or navigated back
194
+ // to a cached page. The flicker is now prevented by renderSegments'
195
+ // promise memoization keeping React's use() in "known fulfilled" state,
196
+ // so preserving `loading` keeps the element tree stable.
170
197
  return fromCache;
171
198
  })
172
199
  .filter(Boolean) as ResolvedSegment[];