@rangojs/router 0.0.0-experimental.b9cb8739 → 0.0.0-experimental.c873df95

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 (64) hide show
  1. package/dist/bin/rango.js +8 -3
  2. package/dist/vite/index.js +139 -200
  3. package/package.json +1 -1
  4. package/skills/caching/SKILL.md +37 -4
  5. package/skills/parallel/SKILL.md +59 -0
  6. package/src/browser/event-controller.ts +5 -0
  7. package/src/browser/navigation-bridge.ts +1 -3
  8. package/src/browser/navigation-client.ts +60 -27
  9. package/src/browser/navigation-transaction.ts +11 -9
  10. package/src/browser/partial-update.ts +50 -9
  11. package/src/browser/prefetch/cache.ts +57 -5
  12. package/src/browser/prefetch/fetch.ts +30 -21
  13. package/src/browser/prefetch/queue.ts +53 -13
  14. package/src/browser/react/Link.tsx +9 -1
  15. package/src/browser/react/NavigationProvider.tsx +27 -0
  16. package/src/browser/rsc-router.tsx +109 -57
  17. package/src/browser/scroll-restoration.ts +26 -9
  18. package/src/browser/segment-reconciler.ts +6 -1
  19. package/src/browser/types.ts +9 -0
  20. package/src/build/route-types/router-processing.ts +12 -2
  21. package/src/cache/cache-runtime.ts +15 -11
  22. package/src/cache/cache-scope.ts +2 -2
  23. package/src/cache/cf/cf-cache-store.ts +453 -11
  24. package/src/cache/cf/index.ts +5 -1
  25. package/src/cache/document-cache.ts +17 -7
  26. package/src/cache/index.ts +1 -0
  27. package/src/debug.ts +2 -2
  28. package/src/route-definition/dsl-helpers.ts +32 -7
  29. package/src/route-definition/redirect.ts +2 -2
  30. package/src/route-map-builder.ts +7 -1
  31. package/src/router/find-match.ts +4 -2
  32. package/src/router/intercept-resolution.ts +2 -0
  33. package/src/router/lazy-includes.ts +4 -1
  34. package/src/router/logging.ts +5 -2
  35. package/src/router/manifest.ts +9 -3
  36. package/src/router/match-middleware/background-revalidation.ts +18 -1
  37. package/src/router/match-middleware/cache-lookup.ts +59 -9
  38. package/src/router/match-middleware/cache-store.ts +32 -6
  39. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  40. package/src/router/match-middleware/segment-resolution.ts +8 -5
  41. package/src/router/match-result.ts +25 -15
  42. package/src/router/metrics.ts +6 -1
  43. package/src/router/middleware.ts +2 -1
  44. package/src/router/router-context.ts +5 -1
  45. package/src/router/segment-resolution/fresh.ts +122 -15
  46. package/src/router/segment-resolution/loader-cache.ts +1 -0
  47. package/src/router/segment-resolution/revalidation.ts +347 -290
  48. package/src/router/segment-wrappers.ts +2 -0
  49. package/src/router.ts +5 -1
  50. package/src/segment-system.tsx +140 -4
  51. package/src/server/context.ts +90 -13
  52. package/src/server/request-context.ts +10 -4
  53. package/src/ssr/index.tsx +1 -0
  54. package/src/types/handler-context.ts +103 -17
  55. package/src/types/route-entry.ts +7 -0
  56. package/src/types/segments.ts +2 -0
  57. package/src/urls/path-helper.ts +1 -1
  58. package/src/vite/discovery/state.ts +0 -2
  59. package/src/vite/plugin-types.ts +0 -83
  60. package/src/vite/plugins/expose-action-id.ts +1 -3
  61. package/src/vite/plugins/version-plugin.ts +13 -1
  62. package/src/vite/rango.ts +144 -209
  63. package/src/vite/router-discovery.ts +0 -8
  64. package/src/vite/utils/banner.ts +3 -3
@@ -6,12 +6,16 @@
6
6
  * real navigation so the server returns a proper diff. The Response is fully
7
7
  * buffered and stored in an in-memory cache for instant consumption on
8
8
  * subsequent navigation.
9
+ *
10
+ * In-flight promises are tracked in the cache so that navigation can reuse
11
+ * a prefetch that is still downloading instead of starting a duplicate request.
9
12
  */
10
13
 
11
14
  import {
12
15
  buildPrefetchKey,
13
16
  hasPrefetch,
14
17
  markPrefetchInflight,
18
+ setInflightPromise,
15
19
  storePrefetch,
16
20
  clearPrefetchInflight,
17
21
  currentGeneration,
@@ -51,19 +55,20 @@ function buildPrefetchUrl(
51
55
  }
52
56
 
53
57
  /**
54
- * Core prefetch fetch logic. Fetches the response, fully buffers the body,
55
- * and stores it in the in-memory cache. Returns a Promise and accepts an
56
- * optional AbortSignal for cancellation by the prefetch queue.
58
+ * Core prefetch fetch logic. Fetches the response, tees the body, and stores
59
+ * one branch in the in-memory cache. The returned Promise resolves to the
60
+ * sibling navigation branch (or null on failure) so navigation can safely
61
+ * reuse an in-flight prefetch via consumeInflightPrefetch().
57
62
  */
58
63
  function executePrefetchFetch(
59
64
  key: string,
60
65
  fetchUrl: string,
61
66
  signal?: AbortSignal,
62
- ): Promise<void> {
67
+ ): Promise<Response | null> {
63
68
  const gen = currentGeneration();
64
69
  markPrefetchInflight(key);
65
70
 
66
- return fetch(fetchUrl, {
71
+ const promise: Promise<Response | null> = fetch(fetchUrl, {
67
72
  priority: "low" as RequestPriority,
68
73
  signal,
69
74
  headers: {
@@ -72,26 +77,27 @@ function executePrefetchFetch(
72
77
  "X-Rango-Prefetch": "1",
73
78
  },
74
79
  })
75
- .then(async (response) => {
76
- if (!response.ok) return;
77
- // Fully buffer the response body so the cached Response is
78
- // self-contained and doesn't depend on the network connection.
79
- // This eliminates the race condition where the user clicks before
80
- // the response body has been fully downloaded.
81
- const buffer = await response.arrayBuffer();
82
- const cachedResponse = new Response(buffer, {
80
+ .then((response) => {
81
+ if (!response.ok) return null;
82
+ // Don't buffer with arrayBuffer() that blocks until the entire
83
+ // body downloads, defeating streaming for slow loaders.
84
+ // Tee the body: one branch for navigation, one for cache storage.
85
+ const [navStream, cacheStream] = response.body!.tee();
86
+ const responseInit = {
83
87
  headers: response.headers,
84
88
  status: response.status,
85
89
  statusText: response.statusText,
86
- });
87
- storePrefetch(key, cachedResponse, gen);
88
- })
89
- .catch(() => {
90
- // Silently ignore prefetch failures (including abort)
90
+ };
91
+ storePrefetch(key, new Response(cacheStream, responseInit), gen);
92
+ return new Response(navStream, responseInit);
91
93
  })
94
+ .catch(() => null)
92
95
  .finally(() => {
93
96
  clearPrefetchInflight(key);
94
97
  });
98
+
99
+ setInflightPromise(key, promise);
100
+ return promise;
95
101
  }
96
102
 
97
103
  /**
@@ -128,8 +134,11 @@ export function prefetchQueued(
128
134
  const key = buildPrefetchKey(window.location.href, targetUrl);
129
135
  if (hasPrefetch(key)) return key;
130
136
  const fetchUrlStr = targetUrl.toString();
131
- enqueuePrefetch(key, (signal) =>
132
- executePrefetchFetch(key, fetchUrlStr, signal),
133
- );
137
+ enqueuePrefetch(key, (signal) => {
138
+ // Re-check at execution time: a hover-triggered prefetchDirect may
139
+ // have started or completed this key while the item sat in the queue.
140
+ if (hasPrefetch(key)) return Promise.resolve();
141
+ return executePrefetchFetch(key, fetchUrlStr, signal).then(() => {});
142
+ });
134
143
  return key;
135
144
  }
@@ -5,12 +5,22 @@
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 is deferred to the next animation frame so prefetch network activity
9
+ * never blocks paint. This applies to both the initial batch and subsequent
10
+ * batches — every drain cycle yields to the browser first.
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
 
12
17
  const MAX_CONCURRENT = 2;
13
18
 
19
+ const deferToNextPaint: (fn: () => void) => void =
20
+ typeof requestAnimationFrame === "function"
21
+ ? requestAnimationFrame
22
+ : (fn) => setTimeout(fn, 0);
23
+
14
24
  let active = 0;
15
25
  const queue: Array<{
16
26
  key: string;
@@ -19,6 +29,7 @@ const queue: Array<{
19
29
  const queued = new Set<string>();
20
30
  const executing = new Set<string>();
21
31
  let abortController: AbortController | null = null;
32
+ let drainScheduled = false;
22
33
 
23
34
  function startExecution(
24
35
  key: string,
@@ -34,6 +45,21 @@ function startExecution(
34
45
  if (executing.delete(key)) {
35
46
  active--;
36
47
  }
48
+ scheduleDrain();
49
+ });
50
+ }
51
+
52
+ /**
53
+ * Schedule a drain on the next animation frame.
54
+ * Coalesces multiple drain requests into a single rAF callback so
55
+ * batch completion doesn't schedule redundant frames.
56
+ */
57
+ function scheduleDrain(): void {
58
+ if (drainScheduled) return;
59
+ if (active >= MAX_CONCURRENT || queue.length === 0) return;
60
+ drainScheduled = true;
61
+ deferToNextPaint(() => {
62
+ drainScheduled = false;
37
63
  drain();
38
64
  });
39
65
  }
@@ -48,8 +74,8 @@ function drain(): void {
48
74
 
49
75
  /**
50
76
  * Enqueue a prefetch for concurrency-limited execution.
51
- * If below the concurrency limit, executes immediately.
52
- * Otherwise queues for later execution.
77
+ * Execution is always deferred to the next animation frame to avoid
78
+ * blocking paint, even when below the concurrency limit.
53
79
  * Deduplicates by key — items already queued or executing are skipped.
54
80
  *
55
81
  * The executor receives an AbortSignal that is aborted when
@@ -61,20 +87,33 @@ export function enqueuePrefetch(
61
87
  ): void {
62
88
  if (queued.has(key) || executing.has(key)) return;
63
89
 
64
- if (active < MAX_CONCURRENT) {
65
- startExecution(key, execute);
66
- } else {
67
- queued.add(key);
68
- queue.push({ key, execute });
69
- }
90
+ queued.add(key);
91
+ queue.push({ key, execute });
92
+ scheduleDrain();
70
93
  }
71
94
 
72
95
  /**
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.
96
+ * Cancel queued prefetches. Executing prefetches are left running so
97
+ * navigation can reuse their in-flight responses (checked via
98
+ * consumeInflightPrefetch in the prefetch cache). With MAX_CONCURRENT=2
99
+ * and priority: "low", in-flight prefetches don't meaningfully compete
100
+ * with navigation fetches under HTTP/2 multiplexing.
101
+ *
102
+ * Called when a navigation starts via the NavigationProvider's
103
+ * event controller subscription.
76
104
  */
77
105
  export function cancelAllPrefetches(): void {
106
+ queue.length = 0;
107
+ queued.clear();
108
+ drainScheduled = false;
109
+ }
110
+
111
+ /**
112
+ * Hard-cancel everything including in-flight prefetches.
113
+ * Used by clearPrefetchCache (server action invalidation) where
114
+ * in-flight responses would be stale.
115
+ */
116
+ export function abortAllPrefetches(): void {
78
117
  abortController?.abort();
79
118
  abortController = null;
80
119
 
@@ -85,4 +124,5 @@ export function cancelAllPrefetches(): void {
85
124
  // so active settles at 0 without underflow.
86
125
  executing.clear();
87
126
  active = 0;
127
+ drainScheduled = false;
88
128
  }
@@ -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
@@ -301,9 +304,33 @@ export function NavigationProvider({
301
304
  return unsub;
302
305
  }, [eventController]);
303
306
 
307
+ // Pending scroll action to apply after React commits
308
+ const pendingScrollRef = useRef<NavigationUpdate["scroll"]>(undefined);
309
+
310
+ // Apply scroll after React commits the new content to the DOM
311
+ useLayoutEffect(() => {
312
+ const scrollAction = pendingScrollRef.current;
313
+ if (!scrollAction) return;
314
+ pendingScrollRef.current = undefined;
315
+
316
+ if (scrollAction.enabled === false) return;
317
+
318
+ handleNavigationEnd({
319
+ restore: scrollAction.restore,
320
+ scroll: scrollAction.enabled,
321
+ isStreaming: scrollAction.isStreaming,
322
+ });
323
+ });
324
+
304
325
  // Subscribe to UI updates (for re-rendering the tree)
305
326
  useEffect(() => {
306
327
  const unsubscribe = store.onUpdate((update) => {
328
+ // Capture scroll intent — it will be applied in useLayoutEffect
329
+ // after React commits this state update to the DOM.
330
+ // Always assign (even undefined) to clear stale scroll from prior navigations,
331
+ // so server actions or error updates don't accidentally replay old scroll.
332
+ pendingScrollRef.current = update.scroll;
333
+
307
334
  setPayload({
308
335
  root: update.root,
309
336
  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
  /**
@@ -285,9 +294,13 @@ export function restoreScrollPosition(options?: {
285
294
  return true;
286
295
  }
287
296
 
288
- // Not streaming — scroll immediately
289
- window.scrollTo(0, savedY);
290
- debugLog("[Scroll] Restored position:", savedY, "for key:", key);
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
+ });
291
304
  return true;
292
305
  }
293
306
 
@@ -362,13 +375,17 @@ export function handleNavigationEnd(options: {
362
375
  // Fall through to hash or top if no saved position
363
376
  }
364
377
 
365
- // Try hash scrolling first
366
- if (scrollToHash()) {
367
- return;
368
- }
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
+ }
369
385
 
370
- // Default: scroll to top
371
- scrollToTop();
386
+ // Default: scroll to top
387
+ scrollToTop();
388
+ });
372
389
  }
373
390
 
374
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
  }
@@ -215,6 +215,15 @@ export interface SegmentState {
215
215
  export interface NavigationUpdate {
216
216
  root: ReactNode | Promise<ReactNode>;
217
217
  metadata: RscMetadata;
218
+ /** Scroll behavior to apply after React commits this update */
219
+ scroll?: {
220
+ /** For back/forward: restore saved position */
221
+ restore?: boolean;
222
+ /** Set to false to disable scrolling entirely */
223
+ enabled?: boolean;
224
+ /** Function to check if streaming is in progress */
225
+ isStreaming?: () => boolean;
226
+ };
218
227
  }
219
228
 
220
229
  /**
@@ -45,7 +45,9 @@ function isRoutableSourceFile(name: string): boolean {
45
45
  name.endsWith(".tsx") ||
46
46
  name.endsWith(".js") ||
47
47
  name.endsWith(".jsx")) &&
48
- !name.includes(".gen.")
48
+ !name.includes(".gen.") &&
49
+ !name.includes(".test.") &&
50
+ !name.includes(".spec.")
49
51
  );
50
52
  }
51
53
 
@@ -70,7 +72,15 @@ function findRouterFilesRecursive(
70
72
  for (const entry of entries) {
71
73
  const fullPath = join(dir, entry.name);
72
74
  if (entry.isDirectory()) {
73
- if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
75
+ if (
76
+ entry.name === "node_modules" ||
77
+ entry.name === "dist" ||
78
+ entry.name === "coverage" ||
79
+ entry.name === "__tests__" ||
80
+ entry.name === "__mocks__" ||
81
+ entry.name.startsWith(".")
82
+ )
83
+ continue;
74
84
  childDirs.push(fullPath);
75
85
  continue;
76
86
  }
@@ -214,11 +214,21 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
214
214
  bgStopCapture = c.stop;
215
215
  }
216
216
 
217
- // Stamp tainted args and RequestContext so request-scoped
218
- // reads (cookies, headers) and side effects (ctx.set, etc.)
219
- // throw inside background revalidation, same as the miss path.
220
- // Uses ref-counted stamp/unstamp so overlapping executions
221
- // sharing the same ctx don't clear each other's guards.
217
+ // Stamp tainted ARGS only not requestCtx. The args stamp guards
218
+ // direct ctx method calls (ctx.set, ctx.header, ctx.onResponse, etc.)
219
+ // which is sufficient for correctness.
220
+ //
221
+ // We intentionally skip stamping requestCtx here because:
222
+ // 1. runBackground starts the async task synchronously (before the
223
+ // first await), so stampCacheExec would pollute the shared
224
+ // requestCtx while the foreground pipeline is still running.
225
+ // This causes assertNotInsideCacheExec to fire when cache-store
226
+ // later calls requestCtx.onResponse().
227
+ // 2. requestCtx methods are closure-bound to the original ctx, so
228
+ // neither Object.create() nor a proxy can isolate the stamp.
229
+ // 3. The foreground miss path already stamps requestCtx and catches
230
+ // cookies()/headers() misuse on first execution. The background
231
+ // re-runs the same function with the same request.
222
232
  const bgTaintedArgs: unknown[] = [];
223
233
  for (const arg of args) {
224
234
  if (isTainted(arg)) {
@@ -226,9 +236,6 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
226
236
  bgTaintedArgs.push(arg);
227
237
  }
228
238
  }
229
- if (requestCtx) {
230
- stampCacheExec(requestCtx as object);
231
- }
232
239
 
233
240
  try {
234
241
  const freshResult = await fn.apply(this, args);
@@ -249,9 +256,6 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
249
256
  for (const arg of bgTaintedArgs) {
250
257
  unstampCacheExec(arg as object);
251
258
  }
252
- if (requestCtx) {
253
- unstampCacheExec(requestCtx as object);
254
- }
255
259
  // Restore original handle store
256
260
  if (originalHandleStore && requestCtx) {
257
261
  requestCtx._handleStore = originalHandleStore;
@@ -73,7 +73,7 @@ function getDefaultRouteCacheKey(
73
73
  isIntercept?: boolean,
74
74
  ): string {
75
75
  const ctx = getRequestContext();
76
- const isPartial = ctx?.url.searchParams.has("_rsc_partial") ?? false;
76
+ const isPartial = ctx?.originalUrl?.searchParams.has("_rsc_partial") ?? false;
77
77
  const searchParams = ctx?.url.searchParams;
78
78
  const host = ctx?.url.host ?? "localhost";
79
79
 
@@ -326,7 +326,7 @@ export class CacheScope {
326
326
  const key = await this.resolveKey(pathname, params, isIntercept);
327
327
 
328
328
  // Check if this is a partial request (navigation) vs document request
329
- const isPartial = requestCtx.url.searchParams.has("_rsc_partial");
329
+ const isPartial = requestCtx.originalUrl.searchParams.has("_rsc_partial");
330
330
 
331
331
  requestCtx.waitUntil(async () => {
332
332
  await handleStore.settled;