@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
@@ -9,6 +9,8 @@ import { AuditedOutletProvider, OutletProvider } from "./outlet-provider.js";
9
9
  import { withOptimisticCommitNone } from "./browser/optimistic-commit.js";
10
10
  import { MountContextProvider } from "./browser/react/mount-context.js";
11
11
  import type { ResolvedSegment, RootLayoutProps } from "./types.js";
12
+ import { isRenderableLoading } from "./types/boundaries.js";
13
+ import { isPlaceholder } from "./types/segments.js";
12
14
  import { decodeLoaderResults } from "./decode-loader-results.js";
13
15
  import { LoaderRedirect } from "./loader-redirect.js";
14
16
  import { invariant } from "./errors.js";
@@ -23,6 +25,7 @@ import { RootErrorBoundary } from "./root-error-boundary.js";
23
25
  import { INTERNAL_RANGO_DEBUG } from "./internal-debug.js";
24
26
  import { INTERNAL_RANGO_SUSPENSE_AUDIT } from "./internal-suspense-audit.js";
25
27
  import { getBoundaryContent } from "./segment-boundary-content.js";
28
+ import { asStream } from "./browser/defer.js";
26
29
  import {
27
30
  buildLoaderPromise,
28
31
  getMemoizedLoaderPromise,
@@ -61,15 +64,6 @@ function segDebugLog(msg: string, details?: Record<string, unknown>): void {
61
64
  const ReactViewTransition: any =
62
65
  "ViewTransition" in React ? (React as any).ViewTransition : null;
63
66
 
64
- // A loading skeleton is renderable only when it is a real ReactNode value.
65
- // `false` is treated as "not renderable" here. This is the three-term gate;
66
- // the distinct two-term gate at the LoaderBoundary site deliberately treats
67
- // `false` as "create a boundary without a RouteContentWrapper"
68
- // (tree-structure.md), so it must NOT use this helper.
69
- function isRenderableLoading(loading: ReactNode): boolean {
70
- return loading !== undefined && loading !== null && loading !== false;
71
- }
72
-
73
67
  // Exported for unit testing the no-parallel fast path (D6); internal otherwise.
74
68
  export function restoreParallelLoaderMarkers(
75
69
  segments: ResolvedSegment[],
@@ -264,32 +258,61 @@ function wrapDefaultOutletContent(
264
258
  * reader sits above every boundary, so the throw is a Fizz shell error (500).
265
259
  * `settledFallback` is that node; the caller plants it and, for a redirect,
266
260
  * replaces the whole page (an ancestor reading the loader would still throw).
261
+ *
262
+ * `settle` is the forceAwait/action lane of a loader set that holds a
263
+ * deferred loader (`prefetch: false`, see hasDeferred): every other loader is
264
+ * settled like a flagged one, so the commit stays whole for them, and the
265
+ * deferred one keeps its hole for the read site to suspend on. A settled
266
+ * error with no boundary is left to the read site there, where the aggregate
267
+ * lane it replaces would have thrown it.
267
268
  */
268
- async function buildLoaderStreams(loaders: ResolvedSegment[]): Promise<{
269
+ async function buildLoaderStreams(
270
+ loaders: ResolvedSegment[],
271
+ settle?: boolean,
272
+ ): Promise<{
269
273
  streams: Record<string, unknown>;
270
274
  awaitedIds: string[] | undefined;
271
275
  settledFallback: ReactNode | undefined;
272
276
  }> {
273
277
  const streams: Record<string, unknown> = {};
274
278
  let awaitedIds: string[] | undefined;
275
- let awaitedValues: unknown[] | undefined;
279
+ let settledIds: string[] | undefined;
280
+ let settledValues: unknown[] | undefined;
276
281
  for (const l of loaders) {
277
- if (l.awaitBeforeFlush) {
282
+ if (l.awaitBeforeFlush || (settle && !isPlaceholder(l))) {
278
283
  const value = await l.loaderData;
279
284
  streams[l.loaderId!] = value;
280
- (awaitedIds ??= []).push(l.loaderId!);
281
- (awaitedValues ??= []).push(value);
285
+ if (l.awaitBeforeFlush) (awaitedIds ??= []).push(l.loaderId!);
286
+ (settledIds ??= []).push(l.loaderId!);
287
+ (settledValues ??= []).push(value);
282
288
  } else {
283
289
  streams[l.loaderId!] = l.loaderData;
284
290
  }
285
291
  }
286
- const settledFallback = awaitedIds
287
- ? (decodeLoaderResults(awaitedValues!, awaitedIds).errorFallback ??
288
- undefined)
289
- : undefined;
292
+ let settledFallback: ReactNode | undefined;
293
+ if (settledIds) {
294
+ try {
295
+ settledFallback =
296
+ decodeLoaderResults(settledValues!, settledIds).errorFallback ??
297
+ undefined;
298
+ } catch (error) {
299
+ if (!settle) throw error;
300
+ }
301
+ }
290
302
  return { streams, awaitedIds, settledFallback };
291
303
  }
292
304
 
305
+ /**
306
+ * A loader set that holds a deferred loader (`prefetch: false`): its
307
+ * `loaderData` is a hole (browser/defer.ts) the browser resolves when the
308
+ * fill request lands (browser/partial-update.ts fillHoles), so no lane may
309
+ * await the set as a whole. The
310
+ * tree build would wait for a request that is only sent once it returns.
311
+ */
312
+ function hasDeferred(loaders: ResolvedSegment[]): boolean {
313
+ return loaders.some(isPlaceholder);
314
+ }
315
+
293
316
  function isLoaderRedirectNode(node: ReactNode): boolean {
294
317
  return isValidElement(node) && node.type === LoaderRedirect;
295
318
  }
@@ -354,6 +377,11 @@ export async function renderSegments(
354
377
  rootLayout: RootLayout,
355
378
  transitionGatedOff,
356
379
  } = options || {};
380
+ // forceAwait and action lanes settle loader data before the commit, except
381
+ // a set that holds a deferred loader (hasDeferred).
382
+ const settleLoaders = !!(forceAwait || isAction);
383
+ const settleAll = (loaders: ResolvedSegment[]): boolean =>
384
+ settleLoaders && !hasDeferred(loaders);
357
385
 
358
386
  const segDebug = INTERNAL_RANGO_DEBUG;
359
387
  const segDebugStart = segDebug ? performance.now() : 0;
@@ -484,6 +512,8 @@ export async function renderSegments(
484
512
  (loader) => loader.loaderId && loader.loaderData !== undefined,
485
513
  );
486
514
 
515
+ // A deferred unit's component is a hole (browser/defer.ts), never a
516
+ // Promise: the awaits below pass it through.
487
517
  let resolvedComponent = component;
488
518
  if (isAction && component instanceof Promise) {
489
519
  const componentAwaitStart = segDebug ? performance.now() : 0;
@@ -540,7 +570,11 @@ export async function renderSegments(
540
570
  componentTypeof: typeof c,
541
571
  });
542
572
  }
543
- nodeContent = registerLazyRef(resolvedComponent);
573
+ // A hole here is read by React itself (browser/defer.ts asStream).
574
+ const streamed = asStream(resolvedComponent) as ReactNode;
575
+ nodeContent = isPlaceholder(node.segment)
576
+ ? streamed
577
+ : registerLazyRef(streamed);
544
578
  }
545
579
 
546
580
  // Wrap with <ViewTransition> if transition config exists (React 19.3+ / experimental).
@@ -614,7 +648,7 @@ export async function renderSegments(
614
648
  // for content-suspending routes (segment-loader-promise.ts).
615
649
  let boundaryLoaderStreams: Record<string, unknown> | undefined;
616
650
  let boundaryAwaitedLoaderIds: string[] | undefined;
617
- if (forceAwait || isAction) {
651
+ if (settleAll(loaderEntries)) {
618
652
  const awaitStart = segDebug ? performance.now() : 0;
619
653
  boundaryLoaderData =
620
654
  loaderDataPromise instanceof Promise
@@ -632,7 +666,7 @@ export async function renderSegments(
632
666
  streams: boundaryLoaderStreams,
633
667
  awaitedIds: boundaryAwaitedLoaderIds,
634
668
  settledFallback,
635
- } = await buildLoaderStreams(loaderEntries));
669
+ } = await buildLoaderStreams(loaderEntries, settleLoaders));
636
670
  if (settledFallback !== undefined) {
637
671
  if (isLoaderRedirectNode(settledFallback)) {
638
672
  content = settledFallback;
@@ -698,7 +732,7 @@ export async function renderSegments(
698
732
  let errorFallback: ReactNode = null;
699
733
  let loaderStreams: Record<string, unknown> | undefined;
700
734
  let awaitedLoaderIds: string[] | undefined;
701
- if (forceAwait || isAction) {
735
+ if (settleAll(layoutLoaders)) {
702
736
  const layoutAwaitStart = segDebug ? performance.now() : 0;
703
737
  const resolvedData = await buildLoaderPromise(layoutLoaders);
704
738
  if (segDebug) {
@@ -726,7 +760,7 @@ export async function renderSegments(
726
760
  streams: loaderStreams,
727
761
  awaitedIds: awaitedLoaderIds,
728
762
  settledFallback,
729
- } = await buildLoaderStreams(layoutLoaders));
763
+ } = await buildLoaderStreams(layoutLoaders, settleLoaders));
730
764
  if (settledFallback !== undefined) {
731
765
  if (isLoaderRedirectNode(settledFallback)) {
732
766
  content = settledFallback;
@@ -780,9 +814,9 @@ export async function renderSegments(
780
814
  // loader paid the pre-flush await, settle the aggregate so
781
815
  // LoaderResolver decodes the array without a Flight-chunk use().
782
816
  const settleParallelAggregate =
783
- forceAwait ||
784
- isAction ||
785
- ownedLoaders.every((l) => l.awaitBeforeFlush === true);
817
+ (settleLoaders ||
818
+ ownedLoaders.every((l) => l.awaitBeforeFlush === true)) &&
819
+ !hasDeferred(ownedLoaders);
786
820
  if (settleParallelAggregate) {
787
821
  const parallelAwaitStart = segDebug ? performance.now() : 0;
788
822
  p.loaderDataPromise =
@@ -117,6 +117,13 @@ export type EntryPropDatas = {
117
117
  */
118
118
  export type EntryPropRender = {
119
119
  loading?: ReactNode | false;
120
+ /**
121
+ * loading(fallback, { prefetch: false }) with a renderable fallback: a
122
+ * prefetch defers what the fallback covers while the entry's segment is
123
+ * new to the client (router/segment-resolution/prefetch-deferral.ts). Set
124
+ * on both DSL evaluations, whatever `ssr` did to `loading`.
125
+ */
126
+ loadingPrefetch?: false;
120
127
  transition?: TransitionConfig;
121
128
  };
122
129
 
@@ -150,6 +157,12 @@ export type LoaderEntry = {
150
157
  * navigation evaluation never has it.
151
158
  */
152
159
  bake?: true;
160
+ /**
161
+ * loader(Def, { prefetch: false }): a prefetch defers this registration
162
+ * while its segment is new to the client
163
+ * (router/segment-resolution/prefetch-deferral.ts).
164
+ */
165
+ prefetch?: false;
153
166
  };
154
167
 
155
168
  /**
@@ -867,9 +880,14 @@ export function track(label: string, depth?: number): () => void {
867
880
  * nested RangoContext.run() calls in Vite's module runner.
868
881
  */
869
882
  const LOADER_SCOPE_KEY = Symbol.for("rangojs-router:loader-scope");
870
- const loaderScopeALS: AsyncLocalStorage<{ active: true }> = ((
871
- globalThis as any
872
- )[LOADER_SCOPE_KEY] ??= new AsyncLocalStorage<{ active: true }>());
883
+ interface LoaderScope {
884
+ active: true;
885
+ /** $$id of the DSL loader this scope resolves (runInsideLoaderScope). */
886
+ loaderId?: string;
887
+ }
888
+ const loaderScopeALS: AsyncLocalStorage<LoaderScope> = ((globalThis as any)[
889
+ LOADER_SCOPE_KEY
890
+ ] ??= new AsyncLocalStorage<LoaderScope>());
873
891
 
874
892
  // Purity-only scope: marks that a loader FUNCTION BODY is executing, regardless
875
893
  // of how the loader was invoked (DSL via runInsideLoaderScope, or handler-
@@ -1429,8 +1447,24 @@ export function assertCachedHeaderWriteAllowed(
1429
1447
  * are bypassed because loaders are always fresh (never cached) and
1430
1448
  * their side effects (setCookie, header, etc.) are safe.
1431
1449
  */
1432
- export function runInsideLoaderScope<T>(fn: () => T): T {
1433
- return loaderScopeALS.run({ active: true }, fn);
1450
+ export function runInsideLoaderScope<T>(fn: () => T, loaderId?: string): T {
1451
+ return loaderScopeALS.run({ active: true, loaderId }, fn);
1452
+ }
1453
+
1454
+ /**
1455
+ * The loader whose run a handle push here belongs to, for the browser's
1456
+ * merge of a partial response (handles/pushed-by.ts): the DSL loader
1457
+ * this scope resolves, else the outermost loader body around the push (a
1458
+ * loader a handler reads with ctx.use). Undefined for a handler's own push.
1459
+ */
1460
+ export function getHandlePushOwner(): string | undefined {
1461
+ const dsl = loaderScopeALS.getStore()?.loaderId;
1462
+ if (dsl !== undefined) return dsl;
1463
+ let outermost: string | undefined;
1464
+ for (let s = loaderBodyScopeALS.getStore(); s; s = s.parent) {
1465
+ if (s.loaderId !== undefined) outermost = s.loaderId;
1466
+ }
1467
+ return outermost;
1434
1468
  }
1435
1469
 
1436
1470
  /**
@@ -1,5 +1,10 @@
1
- import { findEnclosingLoaderBody, isInsideLoaderScope } from "./context.js";
1
+ import {
2
+ findEnclosingLoaderBody,
3
+ getHandlePushOwner,
4
+ isInsideLoaderScope,
5
+ } from "./context.js";
2
6
  import { holdsThenable } from "../router/segment-resolution/mask-nested.js";
7
+ import { pushedByKey } from "../handles/pushed-by.js";
3
8
 
4
9
  /**
5
10
  * Handle data structure: handleName -> segmentId -> entries[]
@@ -75,6 +80,12 @@ interface SlotTag {
75
80
  restoredOf?: string;
76
81
  /** A restored slot a dropped settled push of its loader stood in for. */
77
82
  matched?: true;
83
+ /**
84
+ * The loader whose run made the push, for the browser (pushed-by.ts): the
85
+ * DSL loader or loader body it is made in (getHandlePushOwner), else the
86
+ * loader a copy stands for. Unset for a handler's push.
87
+ */
88
+ by?: string;
78
89
  }
79
90
 
80
91
  // Shared by every plain DSL-loader push; tags are never mutated in place.
@@ -111,6 +122,24 @@ function cloneHandleData(data: HandleData): HandleData {
111
122
  return clone;
112
123
  }
113
124
 
125
+ // SlotTag.by of each value, index-aligned; undefined when no value has one.
126
+ function pushedByOf(
127
+ values: unknown[],
128
+ tags: (SlotTag | undefined)[] | undefined,
129
+ keep?: (i: number) => boolean,
130
+ ): (string | null)[] | undefined {
131
+ if (!tags) return undefined;
132
+ const by: (string | null)[] = [];
133
+ let any = false;
134
+ for (let i = 0; i < values.length; i++) {
135
+ if (keep && !keep(i)) continue;
136
+ const run = tags[i]?.by ?? null;
137
+ if (run !== null) any = true;
138
+ by.push(run);
139
+ }
140
+ return any ? by : undefined;
141
+ }
142
+
114
143
  /**
115
144
  * HandleStore tracks pending handler promises and stores handle data.
116
145
  *
@@ -297,7 +326,8 @@ export interface HandleStore {
297
326
  * nav/action payloads, whose consumer applies each yield progressively) or
298
327
  * "settled" (the document lane, whose consumers drain to completion in
299
328
  * blocking positions and must not wait on loader bodies).
300
- * Each yield contains the full accumulated state (not just the delta).
329
+ * Each yield contains the full accumulated state (not just the delta),
330
+ * with the pushedBy records of its buckets (handles/pushed-by.ts).
301
331
  * Safe for concurrent consumers (per-consumer version cursor).
302
332
  * The "settled" lane of a frozen store (freezeDocumentSnapshot) yields the
303
333
  * frozen state once, at once, and ends. That yield is the store's own
@@ -331,6 +361,17 @@ export interface HandleStore {
331
361
  excludeLoaderPushes?: boolean,
332
362
  ): Record<string, unknown[]>;
333
363
 
364
+ /**
365
+ * The loader whose run pushed each value in getDataForSegment(segmentId,
366
+ * excludeLoaderPushes), index-aligned per handle (null: the handler's).
367
+ * Handles without such a value are omitted; undefined when none has one.
368
+ * The browser's pushedBy record (handles/pushed-by.ts).
369
+ */
370
+ getPushedBy(
371
+ segmentId: string,
372
+ excludeLoaderPushes?: boolean,
373
+ ): Record<string, (string | null)[]> | undefined;
374
+
334
375
  /**
335
376
  * The push `owner` of each value in getDataForSegment(segmentId, true),
336
377
  * index-aligned per handle (null: no owner). Handles without an owned
@@ -379,6 +420,14 @@ export function createHandleStore(): HandleStore {
379
420
  // (not value identity) so primitive values are covered too.
380
421
  // replaySegmentData installs fresh arrays, so restored values are untagged.
381
422
  const slotTags = new WeakMap<unknown[], (SlotTag | undefined)[]>();
423
+ // Frozen tags of plain pushes made by a run (SlotTag.by), one per loader.
424
+ const pushedBySlots = new Map<string, SlotTag>();
425
+ const pushedBySlot = (loader: true | undefined, by: string): SlotTag => {
426
+ const key = loader ? `L${by}` : `H${by}`;
427
+ let tag = pushedBySlots.get(key);
428
+ if (!tag) pushedBySlots.set(key, (tag = Object.freeze({ loader, by })));
429
+ return tag;
430
+ };
382
431
  // Copies standing in for a loader that has not pushed live yet: loaderId ->
383
432
  // the arrays holding them. Kept apart by kind: only a placeholder gives way
384
433
  // to a run that ends without a push, or to a cached unit's delivery.
@@ -602,6 +651,25 @@ export function createHandleStore(): HandleStore {
602
651
  }
603
652
  }
604
653
 
654
+ // What a stream yields: a copy of the data, with each bucket's pushedBy
655
+ // record (pushed-by.ts) for the browser's merge.
656
+ function snapshot(): HandleData {
657
+ const out: HandleData = {};
658
+ for (const handleName in data) {
659
+ const buckets: Record<string, unknown[]> = {};
660
+ let pushedBy: Record<string, (string | null)[]> | undefined;
661
+ for (const segmentId in data[handleName]) {
662
+ const values = data[handleName][segmentId];
663
+ buckets[segmentId] = [...values];
664
+ const by = pushedByOf(values, slotTags.get(values));
665
+ if (by) (pushedBy ??= {})[segmentId] = by;
666
+ }
667
+ out[handleName] = buckets;
668
+ if (pushedBy) out[pushedByKey(handleName)] = pushedBy;
669
+ }
670
+ return out;
671
+ }
672
+
605
673
  // Settlement barriers: `settled` (handler lane) resolves when sealed AND
606
674
  // handler inflight === 0. `fullySettled` additionally waits for the
607
675
  // auxiliary (loader) lane. seal() signals "no more track() calls"; each
@@ -812,6 +880,12 @@ export function createHandleStore(): HandleStore {
812
880
  at = restoredInsertIndex(values, restoredBy);
813
881
  if (replayOf === undefined) liveOf = restoredBy;
814
882
  }
883
+ const by =
884
+ getHandlePushOwner() ??
885
+ replayOf ??
886
+ restoredOf ??
887
+ placeholderOf ??
888
+ owner;
815
889
  insertSlot(
816
890
  values,
817
891
  at,
@@ -829,10 +903,13 @@ export function createHandleStore(): HandleStore {
829
903
  liveOf,
830
904
  under,
831
905
  owner,
906
+ by,
832
907
  }
833
- : loader
834
- ? LOADER_SLOT
835
- : undefined,
908
+ : by !== undefined
909
+ ? pushedBySlot(loader, by)
910
+ : loader
911
+ ? LOADER_SLOT
912
+ : undefined,
836
913
  );
837
914
  if (replayOf !== undefined) {
838
915
  addPending(pendingReplays, replayOf, values);
@@ -958,7 +1035,7 @@ export function createHandleStore(): HandleStore {
958
1035
  if (this.onError) this.onError(error);
959
1036
  else console.error(error);
960
1037
  }
961
- documentSnapshot = { version, data: cloneHandleData(data) };
1038
+ documentSnapshot = { version, data: snapshot() };
962
1039
  },
963
1040
 
964
1041
  async *stream(
@@ -1005,7 +1082,7 @@ export function createHandleStore(): HandleStore {
1005
1082
  while (true) {
1006
1083
  if (version > seen && Object.keys(data).length > 0) {
1007
1084
  seen = version;
1008
- yield cloneHandleData(data);
1085
+ yield snapshot();
1009
1086
  }
1010
1087
  if (done) break;
1011
1088
  await waitForEmission(seen, () => done);
@@ -1043,7 +1120,7 @@ export function createHandleStore(): HandleStore {
1043
1120
  while (true) {
1044
1121
  if (version > seen) {
1045
1122
  seen = version;
1046
- yield cloneHandleData(data);
1123
+ yield snapshot();
1047
1124
  }
1048
1125
  if (done) break;
1049
1126
  await waitForEmission(seen, () => done);
@@ -1069,6 +1146,25 @@ export function createHandleStore(): HandleStore {
1069
1146
  return result;
1070
1147
  },
1071
1148
 
1149
+ getPushedBy(
1150
+ segmentId: string,
1151
+ excludeLoaderPushes?: boolean,
1152
+ ): Record<string, (string | null)[]> | undefined {
1153
+ let result: Record<string, (string | null)[]> | undefined;
1154
+ for (const handleName in data) {
1155
+ const values = data[handleName][segmentId];
1156
+ if (!values) continue;
1157
+ const tags = slotTags.get(values);
1158
+ const by = pushedByOf(
1159
+ values,
1160
+ tags,
1161
+ excludeLoaderPushes ? (i) => !tags?.[i]?.loader : undefined,
1162
+ );
1163
+ if (by) (result ??= {})[handleName] = by;
1164
+ }
1165
+ return result;
1166
+ },
1167
+
1072
1168
  getRecordOwners(
1073
1169
  segmentId: string,
1074
1170
  ): Record<string, (string | null)[]> | undefined {
@@ -78,6 +78,7 @@ import {
78
78
  readGuardedTheme,
79
79
  } from "./cookie-store.js";
80
80
  import { requestHeaders } from "./request-headers.js";
81
+ import { requestKind } from "../router/segment-resolution/prefetch-deferral.js";
81
82
  import {
82
83
  createReverseFunction,
83
84
  stripInternalParams,
@@ -713,6 +714,21 @@ export interface RequestContext<
713
714
  */
714
715
  _payloadVisitorTheme?: boolean;
715
716
 
717
+ /**
718
+ * @internal Set by the partial render when its payload carries deferred
719
+ * units (`prefetch: false`): what one page's prefetch left for the fill.
720
+ * The document cache does not store that response, whatever Cache-Control
721
+ * it ends up with (docs/design/prefetch-false.md, R7).
722
+ */
723
+ _payloadDeferred?: boolean;
724
+
725
+ /**
726
+ * @internal What the request says it is: a `<Link>` prefetch or a fill
727
+ * (`prefetch: false`), read once at creation
728
+ * (segment-resolution/prefetch-deferral.ts requestKind).
729
+ */
730
+ _requestKind?: "prefetch" | "fill";
731
+
716
732
  /**
717
733
  * Attach location state entries to the current response.
718
734
  *
@@ -1012,6 +1028,8 @@ export type PublicRequestContext<
1012
1028
  | "_readTheme"
1013
1029
  | "_documentCacheRender"
1014
1030
  | "_payloadVisitorTheme"
1031
+ | "_payloadDeferred"
1032
+ | "_requestKind"
1015
1033
  | "_locationState"
1016
1034
  | "_routeName"
1017
1035
  | "_prevRouteKey"
@@ -1445,12 +1463,14 @@ export function createRequestContext<TEnv>(
1445
1463
  };
1446
1464
 
1447
1465
  const cleanUrl = stripInternalParams(url);
1466
+ const originalUrl = new URL(request.url);
1448
1467
 
1449
1468
  const ctx: RequestContext<TEnv> = {
1450
1469
  env,
1451
1470
  request,
1452
1471
  url: cleanUrl,
1453
- originalUrl: new URL(request.url),
1472
+ originalUrl,
1473
+ _requestKind: requestKind(request, originalUrl),
1454
1474
  pathname: url.pathname,
1455
1475
  searchParams: cleanUrl.searchParams,
1456
1476
  _variables: variables,
package/src/server.ts CHANGED
@@ -58,3 +58,7 @@ export {
58
58
  setClientUrlProjection,
59
59
  type ClientUrlProjection,
60
60
  } from "./client-urls/server-projection.js";
61
+
62
+ // Hoisted clientUrls() revalidate() predicates: the generated
63
+ // `<file>?rango-revalidate` modules register them in the RSC realm.
64
+ export { registerClientUrlRevalidators } from "./client-urls/revalidate-registry.js";
@@ -12,8 +12,7 @@
12
12
  * Post the result with `serveShellRequest(router, url, { form })`.
13
13
  */
14
14
 
15
- import * as RSDServer from "@vitejs/plugin-rsc/vendor/react-server-dom/server.edge";
16
- import { registerServerModule } from "./internal/server-modules.js";
15
+ import { registerTestAction } from "./internal/test-action.js";
17
16
 
18
17
  /** Options for {@link createActionForm}. */
19
18
  export interface CreateActionFormOptions {
@@ -41,15 +40,7 @@ export function createActionForm(
41
40
  options: CreateActionFormOptions,
42
41
  ): FormData {
43
42
  const { id, useActionState = true, fields = {} } = options;
44
- const [moduleId, name] = id.split("#");
45
- if (!moduleId || !name) {
46
- throw new Error(
47
- `createActionForm: id must be "<module id>#<export name>", received ${JSON.stringify(id)}`,
48
- );
49
- }
50
- registerServerModule(moduleId, {
51
- [name]: RSDServer.registerServerReference(action, moduleId, name),
52
- });
43
+ registerTestAction(action, id, "createActionForm");
53
44
 
54
45
  const form = new FormData();
55
46
  if (useActionState) {
@@ -3,6 +3,7 @@
3
3
  // returned from `createPageHelpers(expect)`; the rest are standalone.
4
4
 
5
5
  import type { ConsoleMessage, Expect, Locator, Page } from "@playwright/test";
6
+ import { PREFETCH_HEADER } from "../../prefetch-markers.js";
6
7
 
7
8
  // ============================================================================
8
9
  // Standalone helpers (need only a page/locator)
@@ -170,7 +171,8 @@ export async function measureTime<T>(
170
171
  export function isPrefetchRequest(req: {
171
172
  headers: () => Record<string, string>;
172
173
  }): boolean {
173
- return !!req.headers()["x-rango-prefetch"];
174
+ // Playwright lowercases header names.
175
+ return !!req.headers()[PREFETCH_HEADER.toLowerCase()];
174
176
  }
175
177
 
176
178
  // Module-level matcher + handler so blockPrefetch/unblockPrefetch share
@@ -64,6 +64,7 @@ export {
64
64
  resetShellTestState,
65
65
  } from "./serve-shell-request.js";
66
66
  export type {
67
+ ServeShellRequestAction,
67
68
  ServeShellRequestOptions,
68
69
  ServeShellRequestResult,
69
70
  ShellRequestHandles,
@@ -26,7 +26,7 @@
26
26
  * condition and would throw if pulled into this barrel.
27
27
  *
28
28
  * Layers:
29
- * - Unit: runMiddleware, runLoader, runClientRevalidate
29
+ * - Unit: runMiddleware, runLoader, runRevalidate, runTransitionWhen
30
30
  * - Integration: dispatch (request -> Response)
31
31
  * - Cross-cut: assertCacheStatus, assertShellStatus, assertGeneratedRoutesMatch
32
32
  * - Component: see @rangojs/router/testing/dom (renderRoute)
@@ -55,8 +55,11 @@ export type {
55
55
  RunTransitionWhenResult,
56
56
  } from "./run-transition-when.js";
57
57
 
58
- export { runClientRevalidate } from "./run-client-revalidate.js";
59
- export type { RunClientRevalidateOptions } from "./run-client-revalidate.js";
58
+ export { runRevalidate } from "./run-revalidate.js";
59
+ export type {
60
+ RunRevalidateAction,
61
+ RunRevalidateOptions,
62
+ } from "./run-revalidate.js";
60
63
 
61
64
  export { dispatch } from "./dispatch.js";
62
65
  export type { DispatchOptions } from "./dispatch.js";
@@ -1,5 +1,20 @@
1
1
  import { resolveActionRefId } from "../../router/is-action.js";
2
2
 
3
+ /** An imported server action, as a testing primitive's `action` option takes it. */
4
+ export type ImportedAction = (...args: never[]) => unknown;
5
+
6
+ /**
7
+ * Normalizes a testing primitive's `action` option: a bare imported action or
8
+ * actionId string becomes `{ ref }`; the object form passes through.
9
+ */
10
+ export function normalizeTestAction<
11
+ T extends { ref?: ImportedAction | string },
12
+ >(action: ImportedAction | string | T | undefined): T | undefined {
13
+ return typeof action === "function" || typeof action === "string"
14
+ ? ({ ref: action } as T)
15
+ : action;
16
+ }
17
+
3
18
  /**
4
19
  * The id a testing primitive's `action` option names: a single imported
5
20
  * server action (its `$id ?? $$id`, as the action boundary derives it) or a
@@ -7,7 +22,7 @@ import { resolveActionRefId } from "../../router/is-action.js";
7
22
  * primitive.
8
23
  */
9
24
  export function resolveTestActionId(
10
- action: ((...args: never[]) => unknown) | string | undefined,
25
+ action: ImportedAction | string | undefined,
11
26
  primitive: string,
12
27
  ): string | undefined {
13
28
  if (action === undefined) return undefined;