@rangojs/router 0.0.0-experimental.39 → 0.0.0-experimental.3b1deca8

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 (89) hide show
  1. package/dist/bin/rango.js +8 -3
  2. package/dist/vite/index.js +292 -204
  3. package/package.json +1 -1
  4. package/skills/cache-guide/SKILL.md +32 -0
  5. package/skills/caching/SKILL.md +45 -4
  6. package/skills/loader/SKILL.md +53 -43
  7. package/skills/parallel/SKILL.md +126 -0
  8. package/skills/route/SKILL.md +31 -0
  9. package/skills/router-setup/SKILL.md +52 -2
  10. package/skills/typesafety/SKILL.md +10 -0
  11. package/src/browser/debug-channel.ts +93 -0
  12. package/src/browser/event-controller.ts +5 -0
  13. package/src/browser/navigation-bridge.ts +1 -5
  14. package/src/browser/navigation-client.ts +84 -27
  15. package/src/browser/navigation-transaction.ts +11 -9
  16. package/src/browser/partial-update.ts +50 -9
  17. package/src/browser/prefetch/cache.ts +57 -5
  18. package/src/browser/prefetch/fetch.ts +30 -21
  19. package/src/browser/prefetch/queue.ts +92 -20
  20. package/src/browser/prefetch/resource-ready.ts +77 -0
  21. package/src/browser/react/Link.tsx +9 -1
  22. package/src/browser/react/NavigationProvider.tsx +32 -3
  23. package/src/browser/rsc-router.tsx +109 -57
  24. package/src/browser/scroll-restoration.ts +31 -34
  25. package/src/browser/segment-reconciler.ts +6 -1
  26. package/src/browser/server-action-bridge.ts +12 -0
  27. package/src/browser/types.ts +17 -1
  28. package/src/build/route-types/router-processing.ts +12 -2
  29. package/src/cache/cache-runtime.ts +15 -11
  30. package/src/cache/cache-scope.ts +48 -7
  31. package/src/cache/cf/cf-cache-store.ts +453 -11
  32. package/src/cache/cf/index.ts +5 -1
  33. package/src/cache/document-cache.ts +17 -7
  34. package/src/cache/index.ts +1 -0
  35. package/src/cache/taint.ts +55 -0
  36. package/src/context-var.ts +72 -2
  37. package/src/debug.ts +2 -2
  38. package/src/deps/browser.ts +1 -0
  39. package/src/route-definition/dsl-helpers.ts +32 -7
  40. package/src/route-definition/helpers-types.ts +6 -5
  41. package/src/route-definition/redirect.ts +2 -2
  42. package/src/route-map-builder.ts +7 -1
  43. package/src/router/find-match.ts +4 -2
  44. package/src/router/handler-context.ts +31 -8
  45. package/src/router/intercept-resolution.ts +2 -0
  46. package/src/router/lazy-includes.ts +4 -1
  47. package/src/router/loader-resolution.ts +7 -1
  48. package/src/router/logging.ts +5 -2
  49. package/src/router/manifest.ts +9 -3
  50. package/src/router/match-middleware/background-revalidation.ts +30 -2
  51. package/src/router/match-middleware/cache-lookup.ts +66 -9
  52. package/src/router/match-middleware/cache-store.ts +53 -10
  53. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  54. package/src/router/match-middleware/segment-resolution.ts +8 -5
  55. package/src/router/match-result.ts +22 -6
  56. package/src/router/metrics.ts +6 -1
  57. package/src/router/middleware-types.ts +6 -2
  58. package/src/router/middleware.ts +4 -3
  59. package/src/router/router-context.ts +6 -1
  60. package/src/router/segment-resolution/fresh.ts +130 -17
  61. package/src/router/segment-resolution/helpers.ts +29 -24
  62. package/src/router/segment-resolution/loader-cache.ts +1 -0
  63. package/src/router/segment-resolution/revalidation.ts +352 -290
  64. package/src/router/segment-wrappers.ts +2 -0
  65. package/src/router/types.ts +1 -0
  66. package/src/router.ts +6 -1
  67. package/src/rsc/handler.ts +28 -2
  68. package/src/rsc/loader-fetch.ts +7 -2
  69. package/src/rsc/progressive-enhancement.ts +4 -1
  70. package/src/rsc/rsc-rendering.ts +4 -1
  71. package/src/rsc/server-action.ts +2 -0
  72. package/src/rsc/types.ts +7 -1
  73. package/src/segment-system.tsx +140 -4
  74. package/src/server/context.ts +102 -13
  75. package/src/server/request-context.ts +59 -12
  76. package/src/ssr/index.tsx +1 -0
  77. package/src/types/handler-context.ts +120 -22
  78. package/src/types/loader-types.ts +4 -4
  79. package/src/types/route-entry.ts +7 -0
  80. package/src/types/segments.ts +2 -0
  81. package/src/urls/path-helper.ts +1 -1
  82. package/src/vite/discovery/state.ts +0 -2
  83. package/src/vite/plugin-types.ts +0 -83
  84. package/src/vite/plugins/expose-action-id.ts +1 -3
  85. package/src/vite/plugins/performance-tracks.ts +235 -0
  86. package/src/vite/plugins/version-plugin.ts +13 -1
  87. package/src/vite/rango.ts +148 -209
  88. package/src/vite/router-discovery.ts +0 -8
  89. package/src/vite/utils/banner.ts +3 -3
@@ -5,11 +5,19 @@
5
5
  * Hover prefetches bypass this queue — they fire directly for immediate response
6
6
  * to user intent.
7
7
  *
8
- * All queued/executing prefetches share a single AbortController so they can
9
- * be cancelled in bulk when a navigation starts.
8
+ * Draining waits for an idle main-thread moment and for viewport images to
9
+ * finish loading, so prefetch fetch() calls never compete with critical
10
+ * resources for the browser's connection pool.
11
+ *
12
+ * When a navigation starts, queued prefetches are cancelled but executing ones
13
+ * are left running. Navigation can reuse their in-flight responses via the
14
+ * prefetch cache's inflight promise map, avoiding duplicate requests.
10
15
  */
11
16
 
17
+ import { wait, waitForIdle, waitForViewportImages } from "./resource-ready.js";
18
+
12
19
  const MAX_CONCURRENT = 2;
20
+ const IMAGE_WAIT_TIMEOUT = 2000;
13
21
 
14
22
  let active = 0;
15
23
  const queue: Array<{
@@ -18,7 +26,9 @@ const queue: Array<{
18
26
  }> = [];
19
27
  const queued = new Set<string>();
20
28
  const executing = new Set<string>();
21
- let abortController: AbortController | null = null;
29
+ const abortControllers = new Map<string, AbortController>();
30
+ let drainScheduled = false;
31
+ let drainGeneration = 0;
22
32
 
23
33
  function startExecution(
24
34
  key: string,
@@ -26,18 +36,49 @@ function startExecution(
26
36
  ): void {
27
37
  active++;
28
38
  executing.add(key);
29
- abortController ??= new AbortController();
30
- execute(abortController.signal).finally(() => {
39
+ const ac = new AbortController();
40
+ abortControllers.set(key, ac);
41
+ execute(ac.signal).finally(() => {
42
+ abortControllers.delete(key);
31
43
  // Only decrement if this key wasn't already cleared by cancelAllPrefetches.
32
44
  // Without this guard, cancelled tasks' .finally() would underflow active
33
45
  // below zero, breaking the MAX_CONCURRENT guarantee.
34
46
  if (executing.delete(key)) {
35
47
  active--;
36
48
  }
37
- drain();
49
+ scheduleDrain();
38
50
  });
39
51
  }
40
52
 
53
+ /**
54
+ * Schedule a drain after the browser is idle and viewport images are loaded.
55
+ * Coalesces multiple drain requests into a single deferred callback so
56
+ * batch completion doesn't schedule redundant waits.
57
+ *
58
+ * The two-step wait ensures prefetch fetch() calls don't compete with
59
+ * images for the browser's connection pool:
60
+ * 1. waitForIdle — yield until the main thread has a quiet moment
61
+ * 2. waitForViewportImages OR 2s timeout — yield until visible images
62
+ * finish loading, but don't let slow/broken images block indefinitely
63
+ */
64
+ function scheduleDrain(): void {
65
+ if (drainScheduled) return;
66
+ if (active >= MAX_CONCURRENT || queue.length === 0) return;
67
+ drainScheduled = true;
68
+ const gen = drainGeneration;
69
+ waitForIdle()
70
+ .then(() =>
71
+ Promise.race([waitForViewportImages(), wait(IMAGE_WAIT_TIMEOUT)]),
72
+ )
73
+ .then(() => {
74
+ drainScheduled = false;
75
+ // Stale drain: a cancel/abort happened while we were waiting.
76
+ // A fresh scheduleDrain will be called by whatever enqueues next.
77
+ if (gen !== drainGeneration) return;
78
+ if (queue.length > 0) drain();
79
+ });
80
+ }
81
+
41
82
  function drain(): void {
42
83
  while (active < MAX_CONCURRENT && queue.length > 0) {
43
84
  const item = queue.shift()!;
@@ -48,9 +89,10 @@ function drain(): void {
48
89
 
49
90
  /**
50
91
  * Enqueue a prefetch for concurrency-limited execution.
51
- * If below the concurrency limit, executes immediately.
52
- * Otherwise queues for later execution.
53
- * Deduplicates by key — items already queued or executing are skipped.
92
+ * Execution is deferred until the browser is idle and viewport images
93
+ * have finished loading, so prefetches never compete with critical
94
+ * resources. Deduplicates by key — items already queued or executing
95
+ * are skipped.
54
96
  *
55
97
  * The executor receives an AbortSignal that is aborted when
56
98
  * cancelAllPrefetches() is called (e.g. on navigation start).
@@ -61,22 +103,50 @@ export function enqueuePrefetch(
61
103
  ): void {
62
104
  if (queued.has(key) || executing.has(key)) return;
63
105
 
64
- if (active < MAX_CONCURRENT) {
65
- startExecution(key, execute);
66
- } else {
67
- queued.add(key);
68
- queue.push({ key, execute });
106
+ queued.add(key);
107
+ queue.push({ key, execute });
108
+ scheduleDrain();
109
+ }
110
+
111
+ /**
112
+ * Cancel queued prefetches and abort in-flight ones that don't match
113
+ * the current navigation target. If `keepUrl` is provided, the
114
+ * executing prefetch whose key contains that URL is kept alive so
115
+ * navigation can reuse its response via consumeInflightPrefetch.
116
+ *
117
+ * Called when a navigation starts via the NavigationProvider's
118
+ * event controller subscription.
119
+ */
120
+ export function cancelAllPrefetches(keepUrl?: string | null): void {
121
+ queue.length = 0;
122
+ queued.clear();
123
+ drainScheduled = false;
124
+ drainGeneration++;
125
+
126
+ // Abort in-flight prefetches that aren't for the navigation target.
127
+ // Keys use format "sourceHref\0targetPathname+search" — match the
128
+ // target portion (after \0) against keepUrl.
129
+ for (const [key, ac] of abortControllers) {
130
+ const target = key.split("\0")[1];
131
+ if (keepUrl && target && keepUrl.startsWith(target)) continue;
132
+ ac.abort();
133
+ abortControllers.delete(key);
134
+ if (executing.delete(key)) {
135
+ active--;
136
+ }
69
137
  }
70
138
  }
71
139
 
72
140
  /**
73
- * Cancel all in-flight and queued prefetches.
74
- * Called when a navigation starts speculative prefetches should not
75
- * compete with navigation fetches for connection slots.
141
+ * Hard-cancel everything including in-flight prefetches.
142
+ * Used by clearPrefetchCache (server action invalidation) where
143
+ * in-flight responses would be stale.
76
144
  */
77
- export function cancelAllPrefetches(): void {
78
- abortController?.abort();
79
- abortController = null;
145
+ export function abortAllPrefetches(): void {
146
+ for (const ac of abortControllers.values()) {
147
+ ac.abort();
148
+ }
149
+ abortControllers.clear();
80
150
 
81
151
  queue.length = 0;
82
152
  queued.clear();
@@ -85,4 +155,6 @@ export function cancelAllPrefetches(): void {
85
155
  // so active settles at 0 without underflow.
86
156
  executing.clear();
87
157
  active = 0;
158
+ drainScheduled = false;
159
+ drainGeneration++;
88
160
  }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Resource Readiness
3
+ *
4
+ * Utilities to defer speculative prefetches until critical resources
5
+ * (viewport images) have finished loading. Prevents prefetch fetch()
6
+ * calls from competing with images for the browser's connection pool.
7
+ */
8
+
9
+ /**
10
+ * Resolve when all in-viewport images have finished loading.
11
+ * Returns immediately if no images are pending.
12
+ *
13
+ * Only checks images that exist at call time — does not observe
14
+ * dynamically added images. For SPA navigations where new images
15
+ * appear after render, call this after the navigation settles.
16
+ */
17
+ export function waitForViewportImages(): Promise<void> {
18
+ if (typeof document === "undefined") return Promise.resolve();
19
+
20
+ const pending = Array.from(document.querySelectorAll("img")).filter((img) => {
21
+ if (img.complete) return false;
22
+ const rect = img.getBoundingClientRect();
23
+ return (
24
+ rect.bottom > 0 &&
25
+ rect.right > 0 &&
26
+ rect.top < window.innerHeight &&
27
+ rect.left < window.innerWidth
28
+ );
29
+ });
30
+
31
+ if (pending.length === 0) return Promise.resolve();
32
+
33
+ return new Promise((resolve) => {
34
+ const settled = new Set<HTMLImageElement>();
35
+
36
+ const settle = (img: HTMLImageElement) => {
37
+ if (settled.has(img)) return;
38
+ settled.add(img);
39
+ if (settled.size >= pending.length) resolve();
40
+ };
41
+
42
+ for (const img of pending) {
43
+ img.addEventListener("load", () => settle(img), { once: true });
44
+ img.addEventListener("error", () => settle(img), { once: true });
45
+ // Re-check: image may have completed between the initial filter
46
+ // and listener attachment. settle() is idempotent per image, so
47
+ // a queued load event firing afterward is harmless.
48
+ if (img.complete) settle(img);
49
+ }
50
+ });
51
+ }
52
+
53
+ /**
54
+ * Resolve after the given number of milliseconds.
55
+ */
56
+ export function wait(ms: number): Promise<void> {
57
+ return new Promise((resolve) => setTimeout(resolve, ms));
58
+ }
59
+
60
+ /**
61
+ * Resolve when the browser has an idle main-thread moment.
62
+ * Uses requestIdleCallback where available, falls back to setTimeout.
63
+ *
64
+ * This is a scheduling hint, not an asset-loaded detector — combine
65
+ * with waitForViewportImages() for full resource readiness.
66
+ */
67
+ export function waitForIdle(timeout = 200): Promise<void> {
68
+ if (typeof window !== "undefined" && "requestIdleCallback" in window) {
69
+ return new Promise((resolve) => {
70
+ window.requestIdleCallback(() => resolve(), { timeout });
71
+ });
72
+ }
73
+
74
+ return new Promise((resolve) => {
75
+ setTimeout(resolve, 0);
76
+ });
77
+ }
@@ -279,7 +279,15 @@ export const Link: ForwardRefExoticComponent<
279
279
  );
280
280
 
281
281
  const handleMouseEnter = useCallback(() => {
282
- if (resolvedStrategy === "hover" && !isExternal && ctx?.store) {
282
+ if (
283
+ (resolvedStrategy === "hover" || resolvedStrategy === "viewport") &&
284
+ !isExternal &&
285
+ ctx?.store
286
+ ) {
287
+ // For "hover", this is the primary prefetch trigger.
288
+ // For "viewport", this upgrades/prioritizes a potentially queued
289
+ // prefetch — prefetchDirect bypasses the queue, and hasPrefetch
290
+ // deduplicates if the viewport prefetch already completed.
283
291
  const segmentState = ctx.store.getSegmentState();
284
292
  prefetchDirect(to, segmentState.currentSegmentIds, ctx.version);
285
293
  }
@@ -3,8 +3,10 @@
3
3
  import React, {
4
4
  useState,
5
5
  useEffect,
6
+ useLayoutEffect,
6
7
  useCallback,
7
8
  useMemo,
9
+ useRef,
8
10
  use,
9
11
  type ReactNode,
10
12
  } from "react";
@@ -25,6 +27,7 @@ import { ThemeProvider } from "../../theme/ThemeProvider.js";
25
27
  import { NonceContext } from "./nonce-context.js";
26
28
  import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
27
29
  import { cancelAllPrefetches } from "../prefetch/queue.js";
30
+ import { handleNavigationEnd } from "../scroll-restoration.js";
28
31
 
29
32
  /**
30
33
  * Process handles from an async generator, updating the event controller
@@ -286,24 +289,50 @@ export function NavigationProvider({
286
289
  };
287
290
  }, [warmupEnabled]);
288
291
 
289
- // Cancel speculative prefetches when navigation starts.
290
- // Viewport/render prefetches should not compete with navigation fetches.
292
+ // Cancel non-matching prefetches when navigation starts.
293
+ // Frees connections so the navigation fetch isn't competing with
294
+ // speculative prefetches. The prefetch matching the navigation target
295
+ // is kept alive so it can be reused via consumeInflightPrefetch.
291
296
  useEffect(() => {
292
297
  let wasIdle = true;
293
298
  const unsub = eventController.subscribe(() => {
294
299
  const state = eventController.getState();
295
300
  const isIdle = state.state === "idle" && !state.isStreaming;
296
301
  if (wasIdle && !isIdle) {
297
- cancelAllPrefetches();
302
+ cancelAllPrefetches(state.pendingUrl);
298
303
  }
299
304
  wasIdle = isIdle;
300
305
  });
301
306
  return unsub;
302
307
  }, [eventController]);
303
308
 
309
+ // Pending scroll action to apply after React commits
310
+ const pendingScrollRef = useRef<NavigationUpdate["scroll"]>(undefined);
311
+
312
+ // Apply scroll after React commits the new content to the DOM
313
+ useLayoutEffect(() => {
314
+ const scrollAction = pendingScrollRef.current;
315
+ if (!scrollAction) return;
316
+ pendingScrollRef.current = undefined;
317
+
318
+ if (scrollAction.enabled === false) return;
319
+
320
+ handleNavigationEnd({
321
+ restore: scrollAction.restore,
322
+ scroll: scrollAction.enabled,
323
+ isStreaming: scrollAction.isStreaming,
324
+ });
325
+ });
326
+
304
327
  // Subscribe to UI updates (for re-rendering the tree)
305
328
  useEffect(() => {
306
329
  const unsubscribe = store.onUpdate((update) => {
330
+ // Capture scroll intent — it will be applied in useLayoutEffect
331
+ // after React commits this state update to the DOM.
332
+ // Always assign (even undefined) to clear stale scroll from prior navigations,
333
+ // so server actions or error updates don't accidentally replay old scroll.
334
+ pendingScrollRef.current = update.scroll;
335
+
307
336
  setPayload({
308
337
  root: update.root,
309
338
  metadata: update.metadata,
@@ -263,71 +263,123 @@ export async function initBrowserApp(
263
263
  // Build initial tree with rootLayout
264
264
  const initialTree = renderSegments(initialPayload.metadata!.segments);
265
265
 
266
- // Setup HMR
266
+ // Setup HMR with debounce — burst saves (format-on-save, rapid edits)
267
+ // fire many rsc:update events in quick succession. Without debouncing,
268
+ // each event triggers a fetchPartial() which on slow routes can pile up
269
+ // and overwhelm the worker (cross-request promise issues, 500s).
267
270
  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,
285
- });
271
+ let hmrTimer: ReturnType<typeof setTimeout> | null = null;
272
+ let hmrAbort: AbortController | null = null;
286
273
 
287
- if (payload.metadata?.isPartial) {
288
- const segments = payload.metadata.segments || [];
289
- const matched = payload.metadata.matched || [];
274
+ import.meta.hot.on("rsc:update", () => {
275
+ // Cancel any pending debounce timer
276
+ if (hmrTimer !== null) {
277
+ clearTimeout(hmrTimer);
278
+ }
290
279
 
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);
280
+ // Abort any in-flight HMR fetch so it doesn't race with the next one
281
+ if (hmrAbort) {
282
+ hmrAbort.abort();
283
+ hmrAbort = null;
284
+ }
295
285
 
296
- // Sync store intercept state with what the server returned
297
- if (!responseIsIntercept && interceptSourceUrl) {
298
- store.setInterceptSourceUrl(null);
299
- }
286
+ // Debounce: wait 200ms of quiet before fetching
287
+ hmrTimer = setTimeout(async () => {
288
+ hmrTimer = null;
289
+
290
+ // Don't interrupt an active user navigation — startNavigation()
291
+ // would abort it and refetch the old URL (window.location.href
292
+ // hasn't updated yet). The user's navigation will pick up the
293
+ // new server code when it completes. isNavigating covers the
294
+ // full lifecycle (fetching + streaming, before commit) without
295
+ // blocking on server actions.
296
+ if (eventController.getState().isNavigating) {
297
+ console.log("[RSCRouter] HMR: Skipping — navigation in progress");
298
+ return;
299
+ }
300
300
 
301
- store.setSegmentIds(matched);
302
- store.setCurrentUrl(window.location.href);
301
+ console.log("[RSCRouter] HMR: Server update, refetching RSC");
303
302
 
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,
303
+ const abort = new AbortController();
304
+ hmrAbort = abort;
305
+
306
+ const handle = eventController.startNavigation(window.location.href, {
307
+ replace: true,
308
+ });
309
+ const streamingToken = handle.startStreaming();
310
+
311
+ const interceptSourceUrl = store.getInterceptSourceUrl();
312
+
313
+ try {
314
+ const { payload, streamComplete } = await client.fetchPartial({
315
+ targetUrl: window.location.href,
316
+ segmentIds: [],
317
+ previousUrl: store.getSegmentState().currentUrl,
318
+ interceptSourceUrl: interceptSourceUrl || undefined,
319
+ hmr: true,
320
+ signal: abort.signal,
321
321
  });
322
- }
323
322
 
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
- }
323
+ if (abort.signal.aborted) return;
324
+
325
+ // If the server returned a non-RSC response (404, 500 without
326
+ // error boundary), the payload won't have valid metadata.
327
+ // Reload to recover rather than leaving the page stale.
328
+ if (!payload.metadata) {
329
+ throw new Error("HMR refetch returned invalid payload");
330
+ }
331
+
332
+ if (payload.metadata?.isPartial) {
333
+ const segments = payload.metadata.segments || [];
334
+ const matched = payload.metadata.matched || [];
335
+
336
+ // Derive intercept state from the returned payload, not the
337
+ // pre-fetch store snapshot. If the HMR edit removed intercept
338
+ // behavior, the response won't contain intercept segments.
339
+ const responseIsIntercept = segments.some(isInterceptSegment);
340
+
341
+ // Sync store intercept state with what the server returned
342
+ if (!responseIsIntercept && interceptSourceUrl) {
343
+ store.setInterceptSourceUrl(null);
344
+ }
345
+
346
+ store.setSegmentIds(matched);
347
+ store.setCurrentUrl(window.location.href);
348
+
349
+ const historyKey = generateHistoryKey(window.location.href, {
350
+ intercept: responseIsIntercept,
351
+ });
352
+ store.setHistoryKey(historyKey);
353
+ const currentHandleData = eventController.getHandleState().data;
354
+ store.cacheSegmentsForHistory(
355
+ historyKey,
356
+ segments,
357
+ currentHandleData,
358
+ );
359
+
360
+ const { main, intercept } = splitInterceptSegments(segments);
361
+ store.emitUpdate({
362
+ root: renderSegments(main, {
363
+ interceptSegments: intercept.length > 0 ? intercept : undefined,
364
+ }),
365
+ metadata: payload.metadata,
366
+ });
367
+ }
368
+
369
+ await streamComplete;
370
+ handle.complete(new URL(window.location.href));
371
+ console.log("[RSCRouter] HMR: RSC stream complete");
372
+ } catch (err) {
373
+ if (abort.signal.aborted) return;
374
+ console.warn("[RSCRouter] HMR: Refetch failed, reloading page", err);
375
+ window.location.reload();
376
+ return;
377
+ } finally {
378
+ if (hmrAbort === abort) hmrAbort = null;
379
+ streamingToken.end();
380
+ handle[Symbol.dispose]();
381
+ }
382
+ }, 200);
331
383
  });
332
384
  }
333
385
 
@@ -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 until we can scroll to the target position.
282
- // This covers both streaming (content arriving incrementally) and
283
- // React's batched startTransition rendering (DOM updates are async
284
- // even for cached navigations with no streaming).
285
- if (options?.retryIfStreaming) {
276
+ // If streaming, poll until streaming ends then scroll to saved position
277
+ if (options?.retryIfStreaming && options?.isStreaming?.()) {
286
278
  const startTime = Date.now();
287
279
 
288
280
  pendingPollInterval = setInterval(() => {
289
- // Stop if we've exceeded the timeout
290
281
  if (Date.now() - startTime > SCROLL_POLL_TIMEOUT_MS) {
291
282
  debugLog("[Scroll] Polling timeout, giving up");
292
283
  cancelScrollRestorationPolling();
293
284
  return;
294
285
  }
295
286
 
296
- // Check if we can now scroll to the target position
297
- const currentMaxScrollY =
298
- document.documentElement.scrollHeight - window.innerHeight;
299
- if (savedY <= currentMaxScrollY) {
287
+ if (!options.isStreaming?.()) {
300
288
  window.scrollTo(0, savedY);
301
- debugLog("[Scroll] Poll restored position:", savedY);
289
+ debugLog("[Scroll] Restored after streaming:", savedY);
302
290
  cancelScrollRestorationPolling();
303
291
  }
304
292
  }, SCROLL_POLL_INTERVAL_MS);
305
293
 
306
- // Return true to prevent handleNavigationEnd from falling through
307
- // to scrollToTop(). The polling will handle the final scroll.
308
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
  /**
@@ -382,13 +375,17 @@ export function handleNavigationEnd(options: {
382
375
  // Fall through to hash or top if no saved position
383
376
  }
384
377
 
385
- // Try hash scrolling first
386
- if (scrollToHash()) {
387
- return;
388
- }
378
+ // Defer hash and scroll-to-top to after React paints the new content,
379
+ // so the user doesn't see the current page jump before the new route appears.
380
+ deferToNextPaint(() => {
381
+ // Try hash scrolling first
382
+ if (scrollToHash()) {
383
+ return;
384
+ }
389
385
 
390
- // Default: scroll to top
391
- scrollToTop();
386
+ // Default: scroll to top
387
+ scrollToTop();
388
+ });
392
389
  }
393
390
 
394
391
  /**
@@ -160,8 +160,13 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
160
160
 
161
161
  // For non-action actors: cached segments the server decided not to re-render.
162
162
  // - Preserve loading=false (suppressed boundary) to maintain tree structure
163
- // - Clear truthy loading (active skeleton) to prevent suspense on cached content
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
164
166
  if (actor !== "action") {
167
+ if (fromCache.type === "parallel" && fromCache.loading !== undefined) {
168
+ return fromCache;
169
+ }
165
170
  if (fromCache.loading !== undefined && fromCache.loading !== false) {
166
171
  return { ...fromCache, loading: undefined };
167
172
  }