@rangojs/router 0.22.1 → 0.23.0

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 (179) hide show
  1. package/dist/bin/rango.js +8 -3
  2. package/dist/testing/vitest.js +31 -1
  3. package/dist/types/browser/defer.d.ts +48 -0
  4. package/dist/types/browser/document-revealed.d.ts +17 -0
  5. package/dist/types/browser/event-controller.d.ts +7 -1
  6. package/dist/types/browser/navigation-transaction.d.ts +21 -7
  7. package/dist/types/browser/partial-update.d.ts +34 -5
  8. package/dist/types/browser/react/NavigationProvider.d.ts +13 -3
  9. package/dist/types/browser/react/filter-segment-order.d.ts +7 -0
  10. package/dist/types/browser/response-adapter.d.ts +7 -0
  11. package/dist/types/browser/types.d.ts +19 -9
  12. package/dist/types/browser/validate-redirect-origin.d.ts +11 -0
  13. package/dist/types/build/generate-route-types.d.ts +1 -1
  14. package/dist/types/build/route-types/router-processing.d.ts +1 -1
  15. package/dist/types/build/route-types/scan-filter.d.ts +16 -2
  16. package/dist/types/cache/cf/cf-cache-store.d.ts +16 -1
  17. package/dist/types/cache/cf/cf-cache-types.d.ts +23 -2
  18. package/dist/types/client-urls/client-urls.d.ts +1 -1
  19. package/dist/types/client-urls/hoisted-revalidate.d.ts +13 -0
  20. package/dist/types/client-urls/navigation.d.ts +0 -37
  21. package/dist/types/client-urls/revalidate-registry.d.ts +4 -0
  22. package/dist/types/client-urls/server-projection.d.ts +29 -1
  23. package/dist/types/client-urls/types.d.ts +34 -69
  24. package/dist/types/client.d.ts +0 -1
  25. package/dist/types/errors.d.ts +5 -0
  26. package/dist/types/handles/deferred-resolution.d.ts +2 -1
  27. package/dist/types/handles/pushed-by.d.ts +23 -0
  28. package/dist/types/loader-store.d.ts +8 -0
  29. package/dist/types/prefetch-markers.d.ts +11 -0
  30. package/dist/types/route-definition/helpers-types.d.ts +27 -0
  31. package/dist/types/router/is-action.d.ts +2 -1
  32. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -2
  33. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +241 -0
  34. package/dist/types/server/context.d.ts +21 -1
  35. package/dist/types/server/handle-store.d.ts +9 -1
  36. package/dist/types/server/request-context.d.ts +14 -1
  37. package/dist/types/server.d.ts +1 -0
  38. package/dist/types/testing/flight.entry.d.ts +1 -1
  39. package/dist/types/testing/index.d.ts +3 -3
  40. package/dist/types/testing/internal/action-id.d.ts +10 -1
  41. package/dist/types/testing/internal/client-urls-projection.d.ts +16 -0
  42. package/dist/types/testing/internal/test-action.d.ts +8 -0
  43. package/dist/types/testing/run-loader.d.ts +2 -1
  44. package/dist/types/testing/run-revalidate.d.ts +74 -0
  45. package/dist/types/testing/run-transition-when.d.ts +2 -3
  46. package/dist/types/testing/serve-shell-request.d.ts +38 -0
  47. package/dist/types/testing/vitest.d.ts +11 -0
  48. package/dist/types/types/boundaries.d.ts +9 -0
  49. package/dist/types/types/handler-context.d.ts +14 -0
  50. package/dist/types/types/loader-types.d.ts +30 -0
  51. package/dist/types/types/segments.d.ts +21 -4
  52. package/dist/types/urls/path-helper-types.d.ts +25 -4
  53. package/dist/types/urls/type-extraction.d.ts +6 -2
  54. package/dist/types/vite/discovery/discover-routers.d.ts +9 -0
  55. package/dist/types/vite/discovery/state.d.ts +11 -4
  56. package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +54 -0
  57. package/dist/types/vite/plugins/hoist/literal.d.ts +83 -0
  58. package/dist/types/vite/plugins/hoist/scope.d.ts +40 -0
  59. package/dist/types/vite/plugins/hoist/source.d.ts +41 -0
  60. package/dist/types/vite/plugins/transition-when-hoist.d.ts +8 -11
  61. package/dist/types/vite/router-discovery.d.ts +10 -0
  62. package/dist/vite/index.js +842 -308
  63. package/package.json +1 -1
  64. package/skills/caching/SKILL.md +42 -5
  65. package/skills/catalog.json +2 -2
  66. package/skills/client-urls/SKILL.md +113 -24
  67. package/skills/cloudflare/SKILL.md +14 -9
  68. package/skills/hooks/SKILL.md +11 -3
  69. package/skills/links/SKILL.md +24 -0
  70. package/skills/loader/SKILL.md +192 -1
  71. package/skills/migrate-nextjs/SKILL.md +2 -2
  72. package/skills/migrate-react-router/SKILL.md +10 -7
  73. package/skills/migrate-react-router/data-and-actions.md +1 -1
  74. package/skills/ppr/SKILL.md +13 -0
  75. package/skills/prerender/SKILL.md +22 -8
  76. package/skills/rango/SKILL.md +12 -6
  77. package/skills/route/SKILL.md +139 -0
  78. package/skills/testing/SKILL.md +3 -2
  79. package/skills/testing/client-components.md +1 -1
  80. package/skills/testing/predicates.md +67 -32
  81. package/skills/testing/serve-shell-request.md +88 -18
  82. package/skills/testing/setup.md +2 -2
  83. package/skills/typesafety/route-types.md +2 -2
  84. package/skills/view-transitions/SKILL.md +26 -0
  85. package/src/browser/defer.ts +82 -0
  86. package/src/browser/document-revealed.ts +52 -0
  87. package/src/browser/event-controller.ts +43 -32
  88. package/src/browser/navigation-bridge.ts +64 -14
  89. package/src/browser/navigation-client.ts +48 -36
  90. package/src/browser/navigation-store.ts +13 -0
  91. package/src/browser/navigation-transaction.ts +25 -8
  92. package/src/browser/partial-update.ts +528 -135
  93. package/src/browser/prefetch/fetch.ts +2 -1
  94. package/src/browser/react/NavigationProvider.tsx +47 -28
  95. package/src/browser/react/filter-segment-order.ts +13 -2
  96. package/src/browser/response-adapter.ts +20 -0
  97. package/src/browser/rsc-router.tsx +17 -45
  98. package/src/browser/server-action-bridge.ts +19 -63
  99. package/src/browser/types.ts +19 -9
  100. package/src/browser/validate-redirect-origin.ts +16 -0
  101. package/src/build/generate-route-types.ts +1 -0
  102. package/src/build/route-types/router-processing.ts +3 -6
  103. package/src/build/route-types/scan-filter.ts +37 -10
  104. package/src/cache/cf/cf-cache-store.ts +59 -21
  105. package/src/cache/cf/cf-cache-types.ts +24 -2
  106. package/src/cache/document-cache.ts +22 -1
  107. package/src/client-urls/client-urls.ts +22 -6
  108. package/src/client-urls/hoisted-revalidate.ts +25 -0
  109. package/src/client-urls/navigation.ts +0 -96
  110. package/src/client-urls/revalidate-registry.ts +30 -0
  111. package/src/client-urls/server-projection.ts +151 -65
  112. package/src/client-urls/types.ts +39 -71
  113. package/src/client.tsx +9 -5
  114. package/src/errors.ts +15 -0
  115. package/src/handles/deferred-resolution.ts +28 -5
  116. package/src/handles/pushed-by.ts +122 -0
  117. package/src/loader-store.ts +18 -7
  118. package/src/prefetch-markers.ts +13 -0
  119. package/src/route-content-wrapper.tsx +18 -4
  120. package/src/route-definition/dsl-helpers.ts +27 -4
  121. package/src/route-definition/helpers-types.ts +27 -1
  122. package/src/router/intercept-resolution.ts +6 -4
  123. package/src/router/is-action.ts +2 -1
  124. package/src/router/loader-resolution.ts +83 -4
  125. package/src/router/match-api.ts +36 -1
  126. package/src/router/match-middleware/background-revalidation.ts +11 -1
  127. package/src/router/match-middleware/cache-lookup.ts +29 -6
  128. package/src/router/match-middleware/cache-store.ts +17 -1
  129. package/src/router/match-result.ts +14 -2
  130. package/src/router/navigation-snapshot.ts +2 -1
  131. package/src/router/segment-resolution/fresh.ts +22 -18
  132. package/src/router/segment-resolution/loader-cache.ts +2 -1
  133. package/src/router/segment-resolution/loader-mask.ts +5 -3
  134. package/src/router/segment-resolution/prefetch-deferral.ts +543 -0
  135. package/src/router/segment-resolution/revalidation.ts +169 -60
  136. package/src/rsc/rsc-rendering.ts +50 -7
  137. package/src/rsc/shell-capture.ts +2 -1
  138. package/src/segment-loader-promise.ts +23 -20
  139. package/src/segment-system.tsx +60 -26
  140. package/src/server/context.ts +39 -5
  141. package/src/server/handle-store.ts +104 -8
  142. package/src/server/request-context.ts +21 -1
  143. package/src/server.ts +4 -0
  144. package/src/testing/action-form.ts +2 -11
  145. package/src/testing/e2e/page-helpers.ts +3 -1
  146. package/src/testing/flight.entry.ts +1 -0
  147. package/src/testing/index.ts +6 -3
  148. package/src/testing/internal/action-id.ts +16 -1
  149. package/src/testing/internal/client-urls-projection.ts +70 -0
  150. package/src/testing/internal/test-action.ts +25 -0
  151. package/src/testing/run-loader.ts +2 -1
  152. package/src/testing/run-revalidate.ts +142 -0
  153. package/src/testing/run-transition-when.ts +7 -8
  154. package/src/testing/serve-shell-request.ts +116 -6
  155. package/src/testing/vitest.ts +67 -1
  156. package/src/types/boundaries.ts +12 -0
  157. package/src/types/handler-context.ts +14 -0
  158. package/src/types/loader-types.ts +30 -0
  159. package/src/types/segments.ts +26 -4
  160. package/src/urls/path-helper-types.ts +27 -5
  161. package/src/urls/type-extraction.ts +30 -10
  162. package/src/use-loader.tsx +18 -13
  163. package/src/vite/discovery/client-urls-projection.ts +20 -21
  164. package/src/vite/discovery/discover-routers.ts +94 -26
  165. package/src/vite/discovery/state.ts +20 -4
  166. package/src/vite/discovery/virtual-module-codegen.ts +22 -17
  167. package/src/vite/plugins/client-urls-revalidate-hoist.ts +564 -0
  168. package/src/vite/plugins/hoist/literal.ts +417 -0
  169. package/src/vite/plugins/hoist/scope.ts +370 -0
  170. package/src/vite/plugins/hoist/source.ts +84 -0
  171. package/src/vite/plugins/transition-when-hoist.ts +113 -779
  172. package/src/vite/rango.ts +2 -0
  173. package/src/vite/router-discovery.ts +93 -32
  174. package/dist/types/client-urls/revalidate-chain.d.ts +0 -33
  175. package/dist/types/client-urls/revalidation-protocol.d.ts +0 -25
  176. package/dist/types/testing/run-client-revalidate.d.ts +0 -43
  177. package/src/client-urls/revalidate-chain.ts +0 -83
  178. package/src/client-urls/revalidation-protocol.ts +0 -56
  179. package/src/testing/run-client-revalidate.ts +0 -92
@@ -35,6 +35,7 @@ import { shouldPrefetch } from "./policy.js";
35
35
  import { debugLog, IS_BROWSER_DEBUG } from "../logging.js";
36
36
  import { teeWithCompletion, isForeignRouterId } from "../response-adapter.js";
37
37
  import { SEGMENT_FRAGMENT_CAPABILITY_HEADER } from "../../segment-fragments.js";
38
+ import { PREFETCH_HEADER } from "../../prefetch-markers.js";
38
39
  import type { RscPayload } from "../types.js";
39
40
 
40
41
  /**
@@ -263,7 +264,7 @@ function executePrefetchFetch(
263
264
  headers: {
264
265
  "X-Rango-State": rangoState,
265
266
  "X-RSC-Router-Client-Path": window.location.href,
266
- "X-Rango-Prefetch": "1",
267
+ [PREFETCH_HEADER]: "1",
267
268
  ...(isFragmentPassthroughEnabled() && {
268
269
  [SEGMENT_FRAGMENT_CAPABILITY_HEADER]: "1",
269
270
  }),
@@ -22,6 +22,7 @@ import type {
22
22
  NavigationUpdate,
23
23
  NavigateOptionsInternal,
24
24
  NavigationBridge,
25
+ RscMetadata,
25
26
  } from "../types.js";
26
27
  import type { EventController } from "../event-controller.js";
27
28
  import { RootErrorBoundary } from "../../root-error-boundary.js";
@@ -211,6 +212,30 @@ async function processHandles(
211
212
  }
212
213
  }
213
214
 
215
+ /**
216
+ * Read the handle stream of `metadata` into the event controller and the
217
+ * entry on screen. The provider's own call for every payload it is handed,
218
+ * and the one write for a response that hands React nothing (a fill,
219
+ * browser/partial-update.ts fillHoles).
220
+ */
221
+ export function consumeHandles(
222
+ metadata: RscMetadata,
223
+ eventController: EventController,
224
+ store: NavigationStore,
225
+ ): void {
226
+ processHandles(metadata.handles!, {
227
+ eventController,
228
+ store,
229
+ matched: metadata.matched,
230
+ isPartial: metadata.isPartial,
231
+ resolvedIds: metadata.resolvedIds,
232
+ // Now: by the time the stream ends the user may be elsewhere.
233
+ historyKey: store.getHistoryKey(),
234
+ }).catch((err) =>
235
+ console.error("[NavigationProvider] Error consuming handles:", err),
236
+ );
237
+ }
238
+
214
239
  /**
215
240
  * Props for NavigationProvider
216
241
  */
@@ -288,8 +313,11 @@ export interface NavigationProviderProps {
288
313
  * `settled` resolves once the stream has ended and React has revealed every
289
314
  * boundary. Until then location state is `undefined`, as SSR rendered it;
290
315
  * then one transition gives the entry's state and changes
291
- * HydrationBarrierContext, and the store is released after that commit. A
292
- * navigation committed earlier releases the store before its update.
316
+ * HydrationBarrierContext, and the store is released after that commit
317
+ * (handle data lands in that release notification). A navigation committed
318
+ * earlier releases the store before its update, for the whole hold when the
319
+ * navigation is held: an old-page boundary hydrating meanwhile reads
320
+ * the destination's values and can mismatch.
293
321
  * Absent for a provider mounted on the client.
294
322
  */
295
323
  hydration?: { settled: Promise<void> };
@@ -368,6 +396,11 @@ export function NavigationProvider({
368
396
  }
369
397
  const shellRef = appShellRef ?? fallbackShellRef.current;
370
398
 
399
+ // Fixed for the document: read once on mount.
400
+ const [origin] = useState(
401
+ () => (eventController.getState().location as URL | undefined)?.origin,
402
+ );
403
+
371
404
  const contextValue = useMemo<NavigationStoreContextValue>(() => {
372
405
  const value = {
373
406
  store,
@@ -375,8 +408,7 @@ export function NavigationProvider({
375
408
  navigate,
376
409
  refresh,
377
410
  defaultPrefetch: initialPayload.metadata.defaultPrefetch,
378
- // Fixed for the document: read once, with the stable value.
379
- origin: (eventController.getState().location as URL | undefined)?.origin,
411
+ origin,
380
412
  } as NavigationStoreContextValue;
381
413
  Object.defineProperty(value, "basename", {
382
414
  configurable: true,
@@ -456,7 +488,7 @@ export function NavigationProvider({
456
488
  return unsub;
457
489
  }, [eventController]);
458
490
 
459
- // Pending scroll action to apply after React commits
491
+ // The last navigation's scroll decision, until React commits its update.
460
492
  const pendingScrollRef = useRef<NavigationUpdate["scroll"]>(undefined);
461
493
 
462
494
  // Apply scroll after React commits the new content to the DOM
@@ -478,11 +510,14 @@ export function NavigationProvider({
478
510
  useEffect(() => {
479
511
  const unsubscribe = store.onUpdate((update) => {
480
512
  if (process.env.NODE_ENV !== "production") auditTreeUpdate(update.root);
481
- // Capture scroll intent — it will be applied in useLayoutEffect
482
- // after React commits this state update to the DOM.
483
- // Always assign (even undefined) to clear stale scroll from prior navigations,
484
- // so server actions or error updates don't accidentally replay old scroll.
485
- pendingScrollRef.current = update.scroll;
513
+ // Scroll belongs to the navigation transaction: only an update that
514
+ // carries one's decision sets the pending action, and the layout effect
515
+ // above consumes it once, after the commit that follows. An update with
516
+ // no decision (server action, error, prefetch: false fill) leaves a
517
+ // pending one alone. React can hold a navigation's commit, and whatever
518
+ // lands meanwhile must not cost the navigation its scroll. Nothing
519
+ // stale is replayed: every update commits, and that commit consumes.
520
+ if (update.scroll) pendingScrollRef.current = update.scroll;
486
521
 
487
522
  // Committed before the document finished hydrating: end the window
488
523
  // first, so the page this update mounts reads the live store. A
@@ -494,10 +529,7 @@ export function NavigationProvider({
494
529
  eventController.releaseHydrationLock();
495
530
  }
496
531
 
497
- setPayload({
498
- root: update.root,
499
- metadata: update.metadata,
500
- });
532
+ setPayload({ root: update.root, metadata: update.metadata });
501
533
  // The state of the entry this payload renders, as its commit recorded
502
534
  // it: one update, so one lane, so one React commit with the tree.
503
535
  setLocationState(eventController.getLocationState());
@@ -511,20 +543,7 @@ export function NavigationProvider({
511
543
 
512
544
  // Update handle data progressively as it streams in
513
545
  if (update.metadata.handles) {
514
- // Capture historyKey now - by the time async processing completes,
515
- // the user might have navigated elsewhere
516
- const historyKey = store.getHistoryKey();
517
-
518
- processHandles(update.metadata.handles, {
519
- eventController,
520
- store,
521
- matched: update.metadata.matched,
522
- isPartial: update.metadata.isPartial,
523
- resolvedIds: update.metadata.resolvedIds,
524
- historyKey,
525
- }).catch((err) =>
526
- console.error("[NavigationProvider] Error consuming handles:", err),
527
- );
546
+ consumeHandles(update.metadata, eventController, store);
528
547
  } else if (update.metadata.matched) {
529
548
  // cachedHandleData present -> full restore (back/forward); absent ->
530
549
  // partial cleanup of segments no longer matched.
@@ -1,3 +1,14 @@
1
+ /**
2
+ * A loader sub-id carries a `D${index}.` marker ("M0L0D1.user"). Non-global,
3
+ * so `.test` and `.replace` are stateless.
4
+ */
5
+ export const LOADER_SEGMENT_ID: RegExp = /D\d+\./;
6
+
7
+ /** True for a loader sub-id ("M0L0D1.user"), false for a layout/route/slot id. */
8
+ export function isLoaderSegmentId(id: string): boolean {
9
+ return LOADER_SEGMENT_ID.test(id);
10
+ }
11
+
1
12
  /**
2
13
  * Build the handle-collection segment order from a raw `matched` list.
3
14
  *
@@ -24,7 +35,7 @@ export function filterSegmentOrder(matched: string[]): string[] {
24
35
  const nonSlotSet = new Set<string>();
25
36
 
26
37
  for (const id of matched) {
27
- if (/D\d+\./.test(id)) continue;
38
+ if (isLoaderSegmentId(id)) continue;
28
39
  const slotIdx = id.indexOf(".@");
29
40
  if (slotIdx >= 0) {
30
41
  const parent = id.slice(0, slotIdx);
@@ -66,5 +77,5 @@ export function filterSegmentOrder(matched: string[]): string[] {
66
77
  * and React reports a hydration mismatch.
67
78
  */
68
79
  export function filterRouteSegmentIds(matched: string[]): string[] {
69
- return matched.filter((id) => !id.includes(".@") && !/D\d+\./.test(id));
80
+ return matched.filter((id) => !id.includes(".@") && !isLoaderSegmentId(id));
70
81
  }
@@ -26,6 +26,26 @@ export function extractRscHeaderUrl(
26
26
  return url ? { url } : "blocked";
27
27
  }
28
28
 
29
+ /**
30
+ * Run `request` with a signal that `signal` aborts until the request settles,
31
+ * and only until then. A response that has arrived streams to its end:
32
+ * aborting a Flight body mid-read rejects every chunk a mounted tree may be
33
+ * reading. Aborted, the request rejects with `signal.reason`.
34
+ */
35
+ export function abortUntilResponse<T>(
36
+ signal: AbortSignal | undefined,
37
+ request: (signal: AbortSignal | undefined) => Promise<T>,
38
+ ): Promise<T> {
39
+ if (!signal) return request(undefined);
40
+ const controller = new AbortController();
41
+ const cancel = (): void => controller.abort(signal.reason);
42
+ if (signal.aborted) cancel();
43
+ else signal.addEventListener("abort", cancel, { once: true });
44
+ return request(controller.signal).finally(() =>
45
+ signal.removeEventListener("abort", cancel),
46
+ );
47
+ }
48
+
29
49
  /**
30
50
  * Empty 200 response that won't choke Flight parsing.
31
51
  * Used when a header URL is blocked by origin validation.
@@ -11,6 +11,7 @@ import {
11
11
  import { createEventController } from "./event-controller.js";
12
12
  import { resolveDeferredHandleValues } from "../handles/deferred-resolution.js";
13
13
  import { validateRedirectOrigin } from "./validate-redirect-origin.js";
14
+ import { awaitDocumentRevealed } from "./document-revealed.js";
14
15
  import { createNavigationClient } from "./navigation-client.js";
15
16
  import { createServerActionBridge } from "./server-action-bridge.js";
16
17
  import { createNavigationBridge } from "./navigation-bridge.js";
@@ -331,14 +332,23 @@ export async function initBrowserApp(
331
332
  // deferred values here exactly like the nav lane's processHandles
332
333
  // does before applying.
333
334
  const lateData = await resolveDeferredHandleValues(rawLateData);
334
- bootLog("handles: late push applied", {
335
+ // Live store only while the document's page is on screen: a full
336
+ // replace with its `matched` would overwrite another page's handles.
337
+ const pageKeys = store.getPageHistoryKeys(initialHistoryKey);
338
+ const onScreen = pageKeys.includes(store.getHistoryKey());
339
+ bootLog("handles: late push", {
335
340
  segments: Object.keys(lateData),
341
+ onScreen,
336
342
  });
337
- eventController.setHandleData(
338
- lateData,
339
- initialPayload.metadata?.matched,
340
- );
341
- store.updateCacheHandleData(initialHistoryKey, lateData);
343
+ if (onScreen) {
344
+ eventController.setHandleData(
345
+ lateData,
346
+ initialPayload.metadata?.matched,
347
+ );
348
+ }
349
+ for (const key of pageKeys) {
350
+ store.updateCacheHandleData(key, lateData);
351
+ }
342
352
  }
343
353
  } catch (err) {
344
354
  console.error("[rsc-router] Error consuming late handles:", err);
@@ -641,28 +651,7 @@ export async function initBrowserApp(
641
651
  linkInterceptionEnabled: linkInterception,
642
652
  version,
643
653
  appShellRef,
644
- documentRevealed: new Promise<void>((resolve) => {
645
- // Parsing ends with the stream, but React reveals a streamed boundary
646
- // in batches up to 300 ms later (Fizz's $RC marks it `$~` and reveals
647
- // it from a timer), so the boundary is still pending at
648
- // DOMContentLoaded. A transition then would make React client-render
649
- // it over its server HTML.
650
- const start = (): void => {
651
- // A reveal rewrites the same comment's data, or React removes the
652
- // node; a boundary nested in revealed content joins the document
653
- // only then, so resolve only once a full walk finds none.
654
- let pending = unrevealedBoundaries();
655
- const check = (): void => {
656
- pending = pending.filter(isUnrevealed);
657
- if (pending.length === 0) pending = unrevealedBoundaries();
658
- if (pending.length > 0) requestAnimationFrame(check);
659
- else resolve();
660
- };
661
- check();
662
- };
663
- if (document.readyState !== "loading") start();
664
- else document.addEventListener("DOMContentLoaded", start, { once: true });
665
- }),
654
+ documentRevealed: awaitDocumentRevealed(),
666
655
  };
667
656
  browserAppContext = context;
668
657
 
@@ -670,23 +659,6 @@ export async function initBrowserApp(
670
659
  return context;
671
660
  }
672
661
 
673
- /**
674
- * A Suspense boundary of the document React has not revealed yet: Fizz marks
675
- * one `$?` while its content streams and `$~` once its reveal is queued.
676
- */
677
- function isUnrevealed(node: Comment): boolean {
678
- return node.isConnected && (node.data === "$?" || node.data === "$~");
679
- }
680
-
681
- function unrevealedBoundaries(): Comment[] {
682
- const found: Comment[] = [];
683
- const walker = document.createTreeWalker(document, NodeFilter.SHOW_COMMENT);
684
- for (let node = walker.nextNode(); node; node = walker.nextNode()) {
685
- if (isUnrevealed(node as Comment)) found.push(node as Comment);
686
- }
687
- return found;
688
- }
689
-
690
662
  // Once-flag so the hydration-commit boot log fires a single time (StrictMode
691
663
  // re-runs the root effect; the second flush is not a second hydration).
692
664
  let hydrationCommitLogged = false;
@@ -26,8 +26,8 @@ import {
26
26
  startBrowserTransaction,
27
27
  } from "./logging.js";
28
28
  import {
29
+ validatePayloadRedirect,
29
30
  validateRedirectOrigin,
30
- validateExternalRedirect,
31
31
  } from "./validate-redirect-origin.js";
32
32
  import {
33
33
  extractRscHeaderUrl,
@@ -40,13 +40,11 @@ import { mergeLocationState } from "./history-state.js";
40
40
  import { stampLocationState } from "./react/location-state-shared.js";
41
41
  import { classifyActionOutcome } from "./action-coordinator.js";
42
42
  import { getAppVersion } from "./app-version.js";
43
- import { collectClientRevalidationDecisions } from "../client-urls/navigation.js";
44
43
  import {
45
44
  decideCommitGatedOff,
46
45
  findActionFormData,
47
46
  type TransitionWhenActionInput,
48
47
  } from "./transition-when.js";
49
- import { CLIENT_REVALIDATION_HEADER } from "../client-urls/revalidation-protocol.js";
50
48
  import { auditTreeCause } from "../suspense-audit.js";
51
49
 
52
50
  // Polyfill Symbol.dispose/asyncDispose for Safari and older browsers
@@ -335,39 +333,12 @@ export function createServerActionBridge(
335
333
  const onHandleAbort = () => fetchAbort.abort();
336
334
  handle.signal.addEventListener("abort", onHandleAbort, { once: true });
337
335
 
338
- // Client-run per-loader revalidation for the action follow-up render:
339
- // run the held clientUrls route's revalidate() predicates with the
340
- // action context and ship their decisions. Fails soft to null (locked
341
- // server defaults).
342
- let clientRevalidation: string | null = null;
343
- try {
344
- const actionPageUrl = new URL(
345
- segmentState.currentUrl,
346
- window.location.origin,
347
- );
348
- clientRevalidation = collectClientRevalidationDecisions({
349
- currentUrl: actionPageUrl,
350
- nextUrl: actionPageUrl,
351
- // Decisions ride the action POST itself — the server evaluates it
352
- // with actionContext, so the locked default is the action default.
353
- actionRequest: true,
354
- isAction: true,
355
- actionId: id,
356
- stale: false,
357
- });
358
- } catch {
359
- clientRevalidation = null;
360
- }
361
-
362
336
  // Send action request with stream tracking
363
337
  const responsePromise = fetch(url, {
364
338
  method: "POST",
365
339
  headers: {
366
340
  "rsc-action": id,
367
341
  "X-RSC-Router-Client-Path": segmentState.currentUrl,
368
- ...(clientRevalidation && {
369
- [CLIENT_REVALIDATION_HEADER]: clientRevalidation,
370
- }),
371
342
  ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
372
343
  ...(interceptSourceUrl && {
373
344
  "X-RSC-Router-Intercept-Source": interceptSourceUrl,
@@ -518,41 +489,26 @@ export function createServerActionBridge(
518
489
  // Check handle.signal.aborted to avoid redirecting from a stale action
519
490
  // when the user has already navigated away.
520
491
  if (metadata?.redirect && !handle.signal.aborted) {
521
- // Explicit off-host redirect (redirect(url, { external: true })):
522
- // hard-navigate, but still scheme-validate (http/https only). external
523
- // waives the same-origin check, NOT scheme safety, so a forged payload
524
- // carrying a javascript:/data: URL cannot script via location.assign.
525
- if (metadata.redirect.external) {
526
- const externalUrl = validateExternalRedirect(
527
- metadata.redirect.url,
528
- window.location.origin,
492
+ // An explicit off-host redirect is a hard navigation, still
493
+ // scheme-validated: a forged payload carrying a javascript:/data: URL
494
+ // cannot script via location.assign (validatePayloadRedirect).
495
+ const { redirect } = metadata;
496
+ const target = validatePayloadRedirect(redirect);
497
+ handle.complete(returnValue?.data);
498
+ if (!target) {
499
+ log(
500
+ redirect.external
501
+ ? "blocked external action redirect payload"
502
+ : "blocked action redirect payload",
503
+ { url: redirect.url },
529
504
  );
530
- if (!externalUrl) {
531
- log("blocked external action redirect payload", {
532
- url: metadata.redirect.url,
533
- });
534
- handle.complete(returnValue?.data);
535
- return returnValue?.data;
536
- }
537
- log("external action redirect", { url: externalUrl });
538
- handle.complete(returnValue?.data);
539
- window.location.assign(externalUrl);
540
- return returnValue?.data;
505
+ } else if (redirect.external) {
506
+ log("external action redirect", { url: target });
507
+ window.location.assign(target);
508
+ } else {
509
+ log("action redirect", { url: target });
510
+ await dispatchRedirect(target, metadata.locationState);
541
511
  }
542
- const redirectUrl = validateRedirectOrigin(
543
- metadata.redirect.url,
544
- window.location.origin,
545
- );
546
- if (!redirectUrl) {
547
- log("blocked action redirect payload", {
548
- url: metadata.redirect.url,
549
- });
550
- handle.complete(returnValue?.data);
551
- return returnValue?.data;
552
- }
553
- log("action redirect", { url: redirectUrl });
554
- handle.complete(returnValue?.data);
555
- await dispatchRedirect(redirectUrl, metadata.locationState);
556
512
  return returnValue?.data;
557
513
  }
558
514
 
@@ -308,7 +308,12 @@ export interface HistoryEntryMemory {
308
308
  export interface NavigationUpdate {
309
309
  root: ReactNode | Promise<ReactNode>;
310
310
  metadata: RscMetadata;
311
- /** Scroll behavior to apply after React commits this update */
311
+ /**
312
+ * The scroll decision of the navigation transaction this update commits,
313
+ * applied after React commits it. Absent on every update that is not a
314
+ * navigation's (server action, error, `prefetch: false` fill): such an
315
+ * update says nothing about scroll, and leaves a pending decision alone.
316
+ */
312
317
  scroll?: {
313
318
  /** For back/forward: restore saved position */
314
319
  restore?: boolean;
@@ -637,6 +642,12 @@ export interface NavigationStore {
637
642
  stale?: boolean,
638
643
  handlesPending?: boolean,
639
644
  ): void;
645
+ /**
646
+ * The history keys whose entries show the page `historyKey`'s entry shows:
647
+ * that entry and the copies a shallow navigation made of it, which share its
648
+ * segments array. `[historyKey]` when it has no entry.
649
+ */
650
+ getPageHistoryKeys(historyKey: string): string[];
640
651
  markHistoryCacheStale(): void;
641
652
  markCacheAsStaleAndBroadcast(): void;
642
653
  clearHistoryCache(): void;
@@ -671,13 +682,6 @@ export interface FetchPartialOptions {
671
682
  signal?: AbortSignal;
672
683
  /** If true, this is a stale cache revalidation request - server should force revalidators */
673
684
  staleRevalidation?: boolean;
674
- /**
675
- * Encoded client-run per-loader revalidation decisions
676
- * (clientUrls revalidate() predicates executed in the browser); sent as
677
- * X-Rango-Client-Reval and honored only by materialized client-urls loader
678
- * stubs. Null/absent = locked server defaults.
679
- */
680
- clientRevalidation?: string | null;
681
685
  interceptSourceUrl?: string;
682
686
  /** RSC version for cache invalidation detection */
683
687
  version?: string;
@@ -685,6 +689,12 @@ export interface FetchPartialOptions {
685
689
  routerId?: string;
686
690
  /** If true, this is an HMR refetch - server should invalidate manifest cache */
687
691
  hmr?: boolean;
692
+ /**
693
+ * The fill request of an adopted prefetch that carried deferred segments
694
+ * (`prefetch: false`): marked `_rsc_fill`, never answered from or written
695
+ * to a prefetch cache or the HTTP cache, and it cancels no prefetch.
696
+ */
697
+ fill?: boolean;
688
698
  }
689
699
 
690
700
  /**
@@ -785,7 +795,7 @@ export type { ResolvedSegment };
785
795
 
786
796
  /**
787
797
  * Token for tracking an active stream.
788
- * Call end() when the stream completes.
798
+ * Call end() when the stream completes. Only the first call counts.
789
799
  */
790
800
  export interface StreamingToken {
791
801
  end(): void;
@@ -54,3 +54,19 @@ export function validateExternalRedirect(
54
54
  }
55
55
  return resolved;
56
56
  }
57
+
58
+ /**
59
+ * The target of a redirect a Flight payload carries (`metadata.redirect`), or
60
+ * null when the client refuses it. An explicit off-host one
61
+ * (`redirect(url, { external: true })`) is scheme-validated only:
62
+ * `external` waives the same-origin check the app opted out of, not scheme
63
+ * safety. Every other one must stay on this origin.
64
+ */
65
+ export function validatePayloadRedirect(redirect: {
66
+ url: string;
67
+ external?: boolean;
68
+ }): string | null {
69
+ return redirect.external
70
+ ? validateExternalRedirect(redirect.url, window.location.origin)
71
+ : validateRedirectOrigin(redirect.url, window.location.origin);
72
+ }
@@ -13,6 +13,7 @@ export {
13
13
  type ScanFilter,
14
14
  createScanFilter,
15
15
  findTsFiles,
16
+ isInSkippedScanDir,
16
17
  } from "./route-types/scan-filter.js";
17
18
  export {
18
19
  writePerModuleRouteTypes,
@@ -14,7 +14,7 @@ import {
14
14
  } from "node:path";
15
15
  import ts from "typescript";
16
16
  import { generateRouteTypesSource } from "./codegen.js";
17
- import type { ScanFilter } from "./scan-filter.js";
17
+ import { isUniversallySkippedScanDir, type ScanFilter } from "./scan-filter.js";
18
18
  import { firstCodeMatchIndex } from "./source-scan.js";
19
19
  import {
20
20
  resolveImportedVariable,
@@ -58,12 +58,9 @@ function isRoutableSourceFile(name: string): boolean {
58
58
 
59
59
  function isExcludedScanDir(name: string): boolean {
60
60
  return (
61
- name === "node_modules" ||
62
- name === "dist" ||
63
- name === "coverage" ||
61
+ isUniversallySkippedScanDir(name) ||
64
62
  name === "__tests__" ||
65
- name === "__mocks__" ||
66
- name.startsWith(".")
63
+ name === "__mocks__"
67
64
  );
68
65
  }
69
66
 
@@ -1,4 +1,4 @@
1
- import { join, relative } from "node:path";
1
+ import { isAbsolute, join, relative } from "node:path";
2
2
  import { readdirSync } from "node:fs";
3
3
  // @ts-ignore -- picomatch ships no .d.ts; types are trivial
4
4
  import picomatch from "picomatch";
@@ -44,8 +44,40 @@ export function createScanFilter(
44
44
  }
45
45
 
46
46
  /**
47
- * Recursively find .ts/.tsx files under a directory, skipping node_modules
48
- * and .gen. files.
47
+ * Directory names no discovery scan enters: findTsFiles() and the router scans
48
+ * (findRouterFiles/findHostRouterFiles, router-processing.ts) all build their
49
+ * skip rule on this, so a file below one is never discovered. Dot-directories
50
+ * cover a nested git worktree, a tool cache, or a test's working copy of
51
+ * another app.
52
+ */
53
+ export function isUniversallySkippedScanDir(name: string): boolean {
54
+ return (
55
+ name === "node_modules" ||
56
+ name === "dist" ||
57
+ name === "coverage" ||
58
+ name.startsWith(".")
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Whether a file under `root` sits in a directory every discovery scan skips
64
+ * (isUniversallySkippedScanDir). The dev route-file watcher uses it to ignore
65
+ * files discovery would never find. A path outside `root` is false.
66
+ */
67
+ export function isInSkippedScanDir(
68
+ root: string,
69
+ absolutePath: string,
70
+ ): boolean {
71
+ const rel = relative(root, absolutePath);
72
+ if (isAbsolute(rel)) return false;
73
+ const dirs = rel.split(/[\\/]/).slice(0, -1);
74
+ if (dirs[0] === "..") return false;
75
+ return dirs.some(isUniversallySkippedScanDir);
76
+ }
77
+
78
+ /**
79
+ * Recursively find .ts/.tsx files under a directory, skipping the directories
80
+ * isUniversallySkippedScanDir() names, `build`, and .gen. files.
49
81
  */
50
82
  export function findTsFiles(dir: string, filter?: ScanFilter): string[] {
51
83
  const results: string[] = [];
@@ -61,14 +93,9 @@ export function findTsFiles(dir: string, filter?: ScanFilter): string[] {
61
93
  for (const entry of entries) {
62
94
  const fullPath = join(dir, entry.name);
63
95
  if (entry.isDirectory()) {
64
- if (
65
- entry.name === "node_modules" ||
66
- entry.name.startsWith(".") ||
67
- entry.name === "dist" ||
68
- entry.name === "build" ||
69
- entry.name === "coverage"
70
- )
96
+ if (isUniversallySkippedScanDir(entry.name) || entry.name === "build") {
71
97
  continue;
98
+ }
72
99
  results.push(...findTsFiles(fullPath, filter));
73
100
  } else if (
74
101
  (entry.name.endsWith(".ts") ||