@rangojs/router 0.5.2 → 0.7.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 (178) hide show
  1. package/dist/bin/rango.js +343 -125
  2. package/dist/types/browser/react/use-router.d.ts +10 -3
  3. package/dist/types/browser/react/use-search-params.d.ts +57 -10
  4. package/dist/types/browser/types.d.ts +22 -0
  5. package/dist/types/build/merge-full-manifests.d.ts +3 -0
  6. package/dist/types/build/route-trie.d.ts +4 -73
  7. package/dist/types/build/route-types/per-module-writer.d.ts +6 -4
  8. package/dist/types/build/route-types/router-processing.d.ts +2 -3
  9. package/dist/types/cache/cache-exec-scope.d.ts +31 -0
  10. package/dist/types/cache/taint.d.ts +12 -6
  11. package/dist/types/client-urls/client-root.d.ts +38 -0
  12. package/dist/types/client-urls/client-urls.d.ts +5 -0
  13. package/dist/types/client-urls/navigation.d.ts +38 -0
  14. package/dist/types/client-urls/revalidation-protocol.d.ts +25 -0
  15. package/dist/types/client-urls/server-projection.d.ts +71 -0
  16. package/dist/types/client-urls/types.d.ts +147 -0
  17. package/dist/types/client.d.ts +12 -4
  18. package/dist/types/client.rsc.d.ts +4 -1
  19. package/dist/types/decode-loader-results.d.ts +37 -0
  20. package/dist/types/errors.d.ts +1 -0
  21. package/dist/types/index.d.ts +1 -1
  22. package/dist/types/loader-redirect.d.ts +27 -0
  23. package/dist/types/outlet-context.d.ts +12 -0
  24. package/dist/types/outlet-provider.d.ts +3 -1
  25. package/dist/types/redirect-origin.d.ts +4 -0
  26. package/dist/types/route-content-wrapper.d.ts +42 -1
  27. package/dist/types/route-definition/helpers-types.d.ts +13 -2
  28. package/dist/types/router/error-handling.d.ts +35 -1
  29. package/dist/types/router/intercept-resolution.d.ts +12 -0
  30. package/dist/types/router/loader-resolution.d.ts +24 -2
  31. package/dist/types/router/revalidation.d.ts +7 -0
  32. package/dist/types/router/route-trie-builder.d.ts +77 -0
  33. package/dist/types/router/router-interfaces.d.ts +20 -0
  34. package/dist/types/router/segment-resolution/helpers.d.ts +1 -1
  35. package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -9
  36. package/dist/types/router/trie-matching.d.ts +1 -1
  37. package/dist/types/rsc/manifest-init.d.ts +5 -5
  38. package/dist/types/rsc/shell-capture.d.ts +9 -0
  39. package/dist/types/rsc/shell-serve.d.ts +11 -0
  40. package/dist/types/rsc/types.d.ts +30 -0
  41. package/dist/types/segment-system.d.ts +2 -0
  42. package/dist/types/server/context.d.ts +10 -0
  43. package/dist/types/server/handle-store.d.ts +34 -3
  44. package/dist/types/server/request-context.d.ts +11 -1
  45. package/dist/types/server.d.ts +1 -0
  46. package/dist/types/ssr/index.d.ts +22 -0
  47. package/dist/types/ssr/ssr-root.d.ts +10 -0
  48. package/dist/types/testing/dom.entry.d.ts +1 -1
  49. package/dist/types/testing/render-route.d.ts +16 -6
  50. package/dist/types/testing/run-loader.d.ts +9 -0
  51. package/dist/types/types/boundaries.d.ts +22 -0
  52. package/dist/types/types/index.d.ts +1 -1
  53. package/dist/types/types/loader-types.d.ts +57 -5
  54. package/dist/types/types/segments.d.ts +7 -0
  55. package/dist/types/urls/path-helper-types.d.ts +13 -4
  56. package/dist/types/vite/discovery/client-urls-projection.d.ts +53 -0
  57. package/dist/types/vite/discovery/discover-routers.d.ts +1 -1
  58. package/dist/types/vite/discovery/state.d.ts +8 -1
  59. package/dist/types/vite/plugins/client-ref-dedup.d.ts +0 -11
  60. package/dist/vite/index.js +6159 -2959
  61. package/package.json +6 -5
  62. package/skills/breadcrumbs/SKILL.md +39 -9
  63. package/skills/catalog.json +7 -1
  64. package/skills/client-urls/SKILL.md +338 -0
  65. package/skills/comparison/references/framework-comparison.md +23 -9
  66. package/skills/hooks/SKILL.md +2 -2
  67. package/skills/hooks/data.md +11 -2
  68. package/skills/hooks/handle-and-actions.md +7 -0
  69. package/skills/hooks/outlets.md +26 -5
  70. package/skills/hooks/urls.md +40 -3
  71. package/skills/loader/SKILL.md +132 -20
  72. package/skills/migrate-nextjs/SKILL.md +70 -10
  73. package/skills/migrate-react-router/SKILL.md +49 -13
  74. package/skills/migrate-react-router/component-migration.md +18 -13
  75. package/skills/migrate-react-router/data-and-actions.md +14 -3
  76. package/skills/migrate-react-router/route-mapping.md +15 -2
  77. package/skills/parallel/SKILL.md +32 -1
  78. package/skills/ppr/SKILL.md +16 -6
  79. package/skills/prerender/SKILL.md +8 -4
  80. package/skills/rango/SKILL.md +21 -17
  81. package/skills/react-compiler/SKILL.md +3 -3
  82. package/skills/route/SKILL.md +5 -2
  83. package/skills/router-setup/SKILL.md +16 -2
  84. package/skills/scripts/SKILL.md +16 -6
  85. package/skills/shell-manifest/SKILL.md +16 -7
  86. package/skills/testing/SKILL.md +2 -2
  87. package/skills/testing/client-components.md +6 -0
  88. package/skills/testing/handles.md +30 -8
  89. package/skills/testing/loader.md +51 -49
  90. package/skills/testing/middleware.md +1 -1
  91. package/skills/theme/SKILL.md +8 -5
  92. package/src/bin/rango.ts +7 -3
  93. package/src/browser/navigation-bridge.ts +6 -0
  94. package/src/browser/navigation-client.ts +5 -0
  95. package/src/browser/partial-update.ts +65 -13
  96. package/src/browser/react/use-router.ts +40 -11
  97. package/src/browser/react/use-search-params.ts +140 -17
  98. package/src/browser/rsc-router.tsx +59 -0
  99. package/src/browser/server-action-bridge.ts +26 -0
  100. package/src/browser/types.ts +22 -0
  101. package/src/build/merge-full-manifests.ts +161 -0
  102. package/src/build/route-trie.ts +9 -332
  103. package/src/build/route-types/include-resolution.ts +66 -11
  104. package/src/build/route-types/per-module-writer.ts +11 -6
  105. package/src/build/route-types/router-processing.ts +184 -153
  106. package/src/build/runtime-discovery.ts +23 -12
  107. package/src/cache/cache-exec-scope.ts +47 -0
  108. package/src/cache/cache-runtime.ts +24 -25
  109. package/src/cache/taint.ts +28 -9
  110. package/src/client-urls/client-root.tsx +168 -0
  111. package/src/client-urls/client-urls.ts +776 -0
  112. package/src/client-urls/navigation.ts +237 -0
  113. package/src/client-urls/revalidation-protocol.ts +56 -0
  114. package/src/client-urls/server-projection.ts +670 -0
  115. package/src/client-urls/types.ts +201 -0
  116. package/src/client.rsc.tsx +12 -0
  117. package/src/client.tsx +49 -6
  118. package/src/decode-loader-results.ts +113 -0
  119. package/src/errors.ts +14 -0
  120. package/src/handles/deferred-resolution.ts +14 -7
  121. package/src/index.ts +1 -0
  122. package/src/loader-redirect.tsx +64 -0
  123. package/src/outlet-context.ts +12 -0
  124. package/src/outlet-provider.tsx +15 -1
  125. package/src/redirect-origin.ts +29 -0
  126. package/src/route-content-wrapper.tsx +96 -3
  127. package/src/route-definition/dsl-helpers.ts +28 -3
  128. package/src/route-definition/helpers-types.ts +13 -0
  129. package/src/route-definition/redirect.ts +17 -18
  130. package/src/router/error-handling.ts +65 -11
  131. package/src/router/intercept-resolution.ts +29 -0
  132. package/src/router/loader-resolution.ts +261 -28
  133. package/src/router/match-result.ts +7 -0
  134. package/src/router/revalidation.ts +24 -11
  135. package/src/router/route-trie-builder.ts +334 -0
  136. package/src/router/router-interfaces.ts +38 -0
  137. package/src/router/segment-resolution/fresh.ts +55 -2
  138. package/src/router/segment-resolution/helpers.ts +9 -11
  139. package/src/router/segment-resolution/loader-cache.ts +34 -23
  140. package/src/router/segment-resolution/loader-mask.ts +9 -9
  141. package/src/router/segment-resolution/revalidation.ts +23 -2
  142. package/src/router/trie-matching.ts +3 -3
  143. package/src/router.ts +46 -1
  144. package/src/rsc/full-payload.ts +6 -0
  145. package/src/rsc/handler.ts +10 -7
  146. package/src/rsc/loader-fetch.ts +2 -2
  147. package/src/rsc/manifest-init.ts +28 -9
  148. package/src/rsc/rsc-rendering.ts +15 -1
  149. package/src/rsc/shell-capture.ts +12 -0
  150. package/src/rsc/shell-serve.ts +15 -2
  151. package/src/rsc/ssr-setup.ts +10 -1
  152. package/src/rsc/types.ts +31 -2
  153. package/src/segment-system.tsx +83 -26
  154. package/src/server/context.ts +10 -0
  155. package/src/server/cookie-store.ts +19 -19
  156. package/src/server/handle-store.ts +185 -48
  157. package/src/server/request-context.ts +30 -6
  158. package/src/server.ts +7 -0
  159. package/src/ssr/index.tsx +37 -2
  160. package/src/ssr/ssr-root.tsx +29 -2
  161. package/src/testing/dom.entry.ts +1 -1
  162. package/src/testing/render-route.tsx +22 -8
  163. package/src/testing/run-loader.ts +51 -13
  164. package/src/types/boundaries.ts +19 -0
  165. package/src/types/index.ts +1 -0
  166. package/src/types/loader-types.ts +60 -5
  167. package/src/types/segments.ts +7 -0
  168. package/src/urls/include-helper.ts +22 -4
  169. package/src/urls/path-helper-types.ts +17 -1
  170. package/src/use-loader.tsx +67 -6
  171. package/src/vite/discovery/client-urls-projection.ts +322 -0
  172. package/src/vite/discovery/discover-routers.ts +43 -17
  173. package/src/vite/discovery/state.ts +11 -1
  174. package/src/vite/discovery/virtual-module-codegen.ts +20 -0
  175. package/src/vite/plugins/client-ref-dedup.ts +281 -19
  176. package/src/vite/plugins/expose-action-id.ts +45 -23
  177. package/src/vite/plugins/virtual-entries.ts +12 -3
  178. package/src/vite/router-discovery.ts +163 -12
@@ -618,6 +618,17 @@ export interface RequestContext<
618
618
  */
619
619
  _handlerLoaderDeps?: Set<string>;
620
620
 
621
+ /**
622
+ * @internal Loader IDs ($$id) whose entries carry `awaitBeforeFlush`
623
+ * (loader(Def, { stream: "navigation" })): segment resolution awaits these
624
+ * before returning, so the render barrier cannot resolve until they settle.
625
+ * rendered() checks this set to fail fast — a flagged loader awaiting the
626
+ * barrier is a guaranteed cycle, not a race. Registered by resolveLoaders
627
+ * (fresh.ts) before loader kickoff; only ever populated on document renders
628
+ * (the flag is stamped per-isSSR at DSL evaluation).
629
+ */
630
+ _awaitBeforeFlushLoaderIds?: Set<string>;
631
+
621
632
  /**
622
633
  * @internal Cached HandleData snapshot built at barrier resolution time.
623
634
  * Avoids rebuilding the snapshot on every loader ctx.use(handle) call.
@@ -765,6 +776,7 @@ export type PublicRequestContext<
765
776
  | "_treeHasStreaming"
766
777
  | "_renderBarrierWaiters"
767
778
  | "_handlerLoaderDeps"
779
+ | "_awaitBeforeFlushLoaderIds"
768
780
  | "_renderBarrierHandleSnapshot"
769
781
  | "_renderBarrierGuardClosed"
770
782
  | "_reportBackgroundError"
@@ -1384,6 +1396,7 @@ export function wireRenderBarrier(
1384
1396
  ctx._renderBarrierHandleSnapshot = undefined;
1385
1397
  ctx._renderBarrierGuardClosed = undefined;
1386
1398
  ctx._handlerLoaderDeps = undefined;
1399
+ ctx._awaitBeforeFlushLoaderIds = undefined;
1387
1400
  ctx._treeHasStreaming = undefined;
1388
1401
 
1389
1402
  // Lazy allocation: only create the Promise when a loader calls rendered().
@@ -1626,12 +1639,23 @@ export function createUseFunction<TEnv>(
1626
1639
  env: ctx.env as any,
1627
1640
  waitUntil: ctx.waitUntil.bind(ctx),
1628
1641
  executionContext: ctx.executionContext,
1629
- get: ctx.get as any,
1630
- use: (<TDep, TDepParams = any>(
1631
- dep: LoaderDefinition<TDep, TDepParams>,
1632
- ): Promise<TDep> => {
1633
- return ctx.use(dep);
1634
- }) as LoaderContext["use"],
1642
+ get: ((keyOrVar: any) => {
1643
+ // Handle READS need the rendered() barrier, which this lane
1644
+ // (action/dispatch-invoked loaders) does not run. Fail with guidance
1645
+ // instead of returning a misleading empty collect.
1646
+ if (isHandle(keyOrVar)) {
1647
+ throw new Error(
1648
+ `ctx.get(handle) is only available in DSL loaders after ` +
1649
+ `"await ctx.rendered()". It cannot be used from request-context ` +
1650
+ `loaders or server actions.`,
1651
+ );
1652
+ }
1653
+ return (ctx.get as any)(keyOrVar);
1654
+ }) as any,
1655
+ // Pass items through: loaders delegate to the request ctx's loader
1656
+ // executor; a handle yields the ctx's push function (write parity —
1657
+ // ctx.use(Meta)({...}) works in this lane exactly like in a handler).
1658
+ use: ((item: any) => ctx.use(item)) as LoaderContext["use"],
1635
1659
  method: "GET",
1636
1660
  body: undefined,
1637
1661
  reverse: createReverseFunction(
package/src/server.ts CHANGED
@@ -47,3 +47,10 @@ export {
47
47
 
48
48
  // Component utilities (used internally for server/client boundary checks)
49
49
  export { isClientComponent, assertClientComponent } from "./component-utils.js";
50
+
51
+ // Client URL projection registry (Vite discovery/bootstrap bridge)
52
+ export {
53
+ clearClientUrlProjections,
54
+ setClientUrlProjection,
55
+ type ClientUrlProjection,
56
+ } from "./client-urls/server-projection.js";
package/src/ssr/index.tsx CHANGED
@@ -105,6 +105,15 @@ export interface SSRRenderOptions {
105
105
  * - `"allReady"` — await `stream.allReady` before returning.
106
106
  */
107
107
  streamMode?: import("../router/router-options.js").SSRStreamMode;
108
+
109
+ /**
110
+ * The live request's query string (`?`-prefixed or empty). Seeds the SSR
111
+ * navigation store location so `useSearchParams` carries real values
112
+ * during document renders. Absent on the build-time prerender pass —
113
+ * build shells capture bare pathnames and serve search-less requests
114
+ * only (runtime captures own the search variants, seeded per key).
115
+ */
116
+ search?: string;
108
117
  }
109
118
 
110
119
  /**
@@ -347,6 +356,13 @@ interface ShellCaptureOptions {
347
356
  quiesce: Promise<void>;
348
357
  /** Upper bound on how long to wait for `quiesce`. Default SHELL_CAPTURE_MAX_WAIT_MS. */
349
358
  maxWaitMs?: number;
359
+ /**
360
+ * The SHELL KEY's search string (`?`-prefixed, sorted, cache.searchParams
361
+ * filter applied — shellSearchSeed in rsc/shell-serve.ts), seeding the SSR
362
+ * store so static-part `useSearchParams` reads bake markup consistent with
363
+ * the shell's own key. MUST equal the resume pass's seed for the same key.
364
+ */
365
+ search?: string;
350
366
  }
351
367
 
352
368
  /**
@@ -368,6 +384,13 @@ interface ShellResumeOptions {
368
384
  postponed: string | null;
369
385
  /** Nonce for CSP. */
370
386
  nonce?: string;
387
+ /**
388
+ * The SHELL KEY's search string — same derivation as the capture pass
389
+ * (shellSearchSeed). A HIT shares the capture's key, so seeding the same
390
+ * string keeps the resume tree identical to the captured tree above the
391
+ * postponed holes.
392
+ */
393
+ search?: string;
371
394
  }
372
395
 
373
396
  /**
@@ -449,7 +472,7 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
449
472
  rscStream: ReadableStream<Uint8Array>,
450
473
  options?: SSRRenderOptions,
451
474
  ): Promise<ReadableStream<Uint8Array>> {
452
- const { nonce, formState, streamMode } = options ?? {};
475
+ const { nonce, formState, streamMode, search } = options ?? {};
453
476
 
454
477
  try {
455
478
  // Tee the stream:
@@ -461,6 +484,12 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
461
484
  createFromReadableStream,
462
485
  rscStream: rscStream1,
463
486
  nonce,
487
+ // Live fizz seeds the request's RAW search. The shell capture pass
488
+ // below and its resume twin seed the SHELL KEY's search instead
489
+ // (sorted, cache.searchParams filter applied — shellSearchSeed):
490
+ // same key => same seed, so the resume tree matches the captured
491
+ // tree while static-part search reads render what the key names.
492
+ search,
464
493
  });
465
494
 
466
495
  // Get bootstrap script content
@@ -562,6 +591,9 @@ export function createShellCaptureHandler<TEnv = unknown>(
562
591
  createFromReadableStream,
563
592
  rscStream,
564
593
  onPayloadSettled: settlePayload,
594
+ // The shell key's own search: static-part search reads render what
595
+ // the key names, and the resume pass seeds the identical string.
596
+ search: opts.search,
565
597
  });
566
598
 
567
599
  // Bootstrap load raced against the deadline. A load that never resolves
@@ -735,7 +767,7 @@ export function createShellResumeHandler<TEnv = unknown>(
735
767
  rscStream: ReadableStream<Uint8Array>,
736
768
  opts: ShellResumeOptions,
737
769
  ): Promise<ReadableStream<Uint8Array>> {
738
- const { postponed, nonce } = opts;
770
+ const { postponed, nonce, search } = opts;
739
771
 
740
772
  try {
741
773
  if (postponed === null) {
@@ -765,6 +797,9 @@ export function createShellResumeHandler<TEnv = unknown>(
765
797
  createFromReadableStream,
766
798
  rscStream: rscStream1,
767
799
  nonce,
800
+ // Same seed as the capture pass for this key: the resume tree must
801
+ // match the captured tree above the holes, search reads included.
802
+ search,
768
803
  });
769
804
 
770
805
  // EAGER injection (resume-only): the stored prelude — a complete document
@@ -63,11 +63,21 @@ async function consumeAsyncGenerator(
63
63
  */
64
64
  function createSsrEventController(opts: {
65
65
  pathname: string;
66
+ /**
67
+ * Query string seeding the store location. Live fizz passes the request's
68
+ * raw search; ppr capture/resume pass the SHELL KEY's search (sorted,
69
+ * cache.searchParams filter applied) so the shell renders what its key
70
+ * names and both passes agree byte-for-byte.
71
+ */
72
+ search?: string;
66
73
  params?: Record<string, string>;
67
74
  handleData?: HandleData;
68
75
  matched?: string[];
69
76
  }): EventController {
70
- const location = new URL(opts.pathname, "http://localhost");
77
+ const location = new URL(
78
+ `${opts.pathname}${opts.search ?? ""}`,
79
+ "http://localhost",
80
+ );
71
81
  let params = opts.params ?? {};
72
82
  const rawMatched = opts.matched ?? [];
73
83
  const handleState = {
@@ -142,6 +152,16 @@ export interface SsrRootOptions {
142
152
  * they postpone below the root.
143
153
  */
144
154
  onPayloadSettled?: () => void;
155
+ /**
156
+ * The query string seeding the SSR store location (`?`-prefixed or
157
+ * empty), so `useSearchParams`/`useNavigation` carry real values during
158
+ * document renders. Live fizz passes the request's raw search. The shell
159
+ * capture and resume passes pass the SHELL KEY's search (sorted,
160
+ * cache.searchParams filter applied — search is part of shell identity):
161
+ * a HIT shares the capture's key, so both passes seed the SAME string and
162
+ * the resume tree matches the captured tree above the postponed holes.
163
+ */
164
+ search?: string;
145
165
  }
146
166
 
147
167
  /**
@@ -162,7 +182,13 @@ export interface SsrRootOptions {
162
182
  * re-running the whole segment-tree build unless the promise is memoized.
163
183
  */
164
184
  export function createSsrRootComponent(opts: SsrRootOptions): React.FC {
165
- const { createFromReadableStream, rscStream, nonce, onPayloadSettled } = opts;
185
+ const {
186
+ createFromReadableStream,
187
+ rscStream,
188
+ nonce,
189
+ onPayloadSettled,
190
+ search,
191
+ } = opts;
166
192
 
167
193
  let payload: Promise<RscPayload> | undefined;
168
194
  let handlesPromise: Promise<HandleData> | undefined;
@@ -211,6 +237,7 @@ export function createSsrRootComponent(opts: SsrRootOptions): React.FC {
211
237
  store: null as any,
212
238
  eventController: createSsrEventController({
213
239
  pathname,
240
+ search,
214
241
  params: resolved.metadata?.params,
215
242
  handleData,
216
243
  matched: resolved.metadata?.matched,
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Component-render testing: `renderRoute`, the React-Testing-Library-style stub
5
5
  * for client components that read router context (useParams / useReverse /
6
- * Outlet / useNavigation / useLoader).
6
+ * Outlet / useOutlet / useNavigation / useLoader).
7
7
  *
8
8
  * Separate from the main `@rangojs/router/testing` barrel so unit suites that
9
9
  * only test loaders, middleware, or `dispatch` never reference React, the
@@ -19,18 +19,19 @@
19
19
  * props crossing the RSC boundary), loader execution on the server,
20
20
  * middleware, or handler ordering. Those are renderServerTree / renderHandler
21
21
  * / e2e territory.
22
- * - Loader data, location state, and handle output are SEEDED directly into
23
- * client context (see the `loaders` / `locationState` / `handles` options) —
24
- * nothing is executed on the server. This exercises the read path
25
- * (useLoader / useLocationState / useHandle from context), not the run path.
22
+ * - Loader data, location state, handle output, and outlet pending state are
23
+ * SEEDED directly into client context (see the `loaders` / `locationState` /
24
+ * `handles` / `outletPending` options) — nothing is executed on the server.
25
+ * This exercises the context read path, not the run path.
26
26
  * - navigate() commits synchronously, so it does NOT drive the navigation
27
27
  * lifecycle: useNavigation().state, useLinkStatus().pending, and
28
28
  * useAction().state stay "idle". Assert pending/loading/submitting transition
29
29
  * states with renderServerTree / e2e instead (navigate() warns once if used).
30
30
  * What it DOES cover: client hooks that read NavigationProvider /
31
31
  * OutletContext — useParams, useReverse, useHref, useMount, useNavigation,
32
- * useRouter, usePathname, useSearchParams, Outlet nesting, useLoader /
33
- * useFetchLoader (seeded data), useLocationState (seeded), and useHandle (seeded).
32
+ * useRouter, usePathname, useSearchParams, Outlet/useOutlet nesting and seeded
33
+ * descendant pending state, useLoader/useFetchLoader (seeded data),
34
+ * useLocationState (seeded), and useHandle (seeded).
34
35
  * Basename-mounted apps: pass the `basename` option so useRouter().basename,
35
36
  * <Link> prefixing, and useMount/useHref resolve against the mount prefix
36
37
  * (without it they resolve at the root "/"). For an include("/shop", ...)
@@ -149,6 +150,15 @@ export interface RenderRouteOptions {
149
150
  * the read path is exercised without executing any loader.
150
151
  */
151
152
  loaderData?: Record<string, unknown>;
153
+ /**
154
+ * Descendant client-route pending state to seed into each synthetic segment's
155
+ * production OutletProvider, so `useOutlet().pending` can be tested alongside
156
+ * `useOutlet().content`. Defaults to false.
157
+ *
158
+ * This is a seeded outlet-context value only. It does not model arbitrary
159
+ * Suspense, navigation, or action pending state.
160
+ */
161
+ outletPending?: boolean;
152
162
  /**
153
163
  * Loaders to seed by REFERENCE — the robust way to test a component that calls
154
164
  * `useLoader(loader)`. A real `createLoader()` handle has an empty `$$id` in a
@@ -560,7 +570,9 @@ export async function renderRoute(
560
570
  const match = resolve(nextUrl.pathname);
561
571
  const segments = buildSegments(routes, match.params, loaderData, mount);
562
572
  const metadata = makeMetadata(nextUrl.pathname, segments, match.params);
563
- const root = await renderSegments(segments);
573
+ const root = await renderSegments(segments, {
574
+ outletPending: options.outletPending,
575
+ });
564
576
  eventController.setLocation(nextUrl);
565
577
  eventController.setParams(match.params);
566
578
  store.setCurrentUrl(nextUrl.href);
@@ -595,7 +607,9 @@ export async function renderRoute(
595
607
  ...makeMetadata(url.pathname, initialSegments, initialMatch.params),
596
608
  defaultPrefetch: options.defaultPrefetch,
597
609
  };
598
- const initialTree = await renderSegments(initialSegments);
610
+ const initialTree = await renderSegments(initialSegments, {
611
+ outletPending: options.outletPending,
612
+ });
599
613
 
600
614
  // Wrap render in an awaited async act so a tree that suspends (async loaders,
601
615
  // loading states, deferred handle entries that arrive as a Promise) settles its
@@ -42,6 +42,7 @@ import { getFetchableLoader } from "../server/fetchable-loader-store.js";
42
42
  import type { LoaderContext, LoaderDefinition } from "../types.js";
43
43
  import type { ContextVar } from "../context-var.js";
44
44
  import { isHandle, type Handle } from "../handle.js";
45
+ import { withDefer } from "../defer.js";
45
46
  import { collectHandle } from "./collect-handle.js";
46
47
  import type { ThemeConfig } from "../theme/types.js";
47
48
  import type { SegmentCacheStore } from "../cache/types.js";
@@ -74,6 +75,9 @@ export type TestLoaderContext<TEnv = any> = Omit<
74
75
  ) => string;
75
76
  get: {
76
77
  <T>(contextVar: ContextVar<T>): T | undefined;
78
+ <TData, TAccumulated = TData[]>(
79
+ handle: Handle<TData, TAccumulated>,
80
+ ): TAccumulated;
77
81
  <T = unknown>(key: string): T | undefined;
78
82
  };
79
83
  };
@@ -248,6 +252,7 @@ function runWithLoaderContext<R>(
248
252
  reqCtx: RequestContext<any>,
249
253
  opts: RunLoaderOptions,
250
254
  fn: (ctx: TestLoaderContext) => R,
255
+ pushRecorder?: Array<{ handle: Handle<any, any>; value: unknown }>,
251
256
  ): R {
252
257
  const handleSeeds = new Map<unknown, unknown>(opts.handles ?? []);
253
258
  const loaderSeeds = new Map<unknown, unknown>(opts.loaders ?? []);
@@ -275,17 +280,37 @@ function runWithLoaderContext<R>(
275
280
  env: reqCtx.env,
276
281
  waitUntil: reqCtx.waitUntil.bind(reqCtx),
277
282
  executionContext: reqCtx.executionContext,
278
- get: reqCtx.get as TestLoaderContext["get"],
283
+ get: ((keyOrVar: any) => {
284
+ // Handle READ (mirrors production's ctx.get(handle)): rendered-gated,
285
+ // seeded via the `handles` option.
286
+ if (isHandle(keyOrVar)) {
287
+ if (!renderedResolved) {
288
+ throw new Error(
289
+ `ctx.get(handle) in a loader requires "await ctx.rendered()" first. ` +
290
+ `Handle "${(keyOrVar as Handle<any, any>).$$id}" cannot be read until ` +
291
+ `the render tree has settled.`,
292
+ );
293
+ }
294
+ if (handleSeeds.has(keyOrVar)) return handleSeeds.get(keyOrVar);
295
+ return collectHandle(keyOrVar, []);
296
+ }
297
+ return (reqCtx.get as any)(keyOrVar);
298
+ }) as TestLoaderContext["get"],
279
299
  use: ((dep: LoaderDefinition<any, any> | Handle<any, any>) => {
280
- if (isHandle(dep) && !renderedResolved) {
281
- throw new Error(
282
- `ctx.use(handle) in a loader requires "await ctx.rendered()" first. ` +
283
- `Handle "${(dep as Handle<any, any>).$$id}" cannot be read until ` +
284
- `the render tree has settled.`,
285
- );
300
+ // Handle WRITE (mirrors production's ctx.use(Meta)({...}) push): the
301
+ // same withDefer wrapper shape, recording into the result envelope's
302
+ // `handlePushes` so tests assert what the loader wrote. Deferred
303
+ // resolvers record their resolved value when called.
304
+ if (isHandle(dep)) {
305
+ const handleDef = dep as Handle<any, any>;
306
+ return withDefer((dataOrFn: unknown) => {
307
+ const value =
308
+ typeof dataOrFn === "function"
309
+ ? (dataOrFn as () => unknown)()
310
+ : dataOrFn;
311
+ pushRecorder?.push({ handle: handleDef, value });
312
+ });
286
313
  }
287
- if (handleSeeds.has(dep)) return handleSeeds.get(dep);
288
- if (isHandle(dep)) return collectHandle(dep, []);
289
314
  // Production ctx.use(Loader) ALWAYS returns a Promise (the cached loader
290
315
  // promise). The seeded path must match, so a consumer composing on the
291
316
  // result (ctx.use(Dep).then(...), Promise.race, etc.) works the same as
@@ -313,7 +338,7 @@ function runWithLoaderContext<R>(
313
338
  "requires the DSL render barrier, which only exists during a " +
314
339
  "full route match. To unit-test a loader's post-barrier logic, " +
315
340
  "pass { rendered: true } to mock the barrier and { handles: " +
316
- "[[SomeHandle, accumulatedData]] } to seed ctx.use(SomeHandle). " +
341
+ "[[SomeHandle, accumulatedData]] } to seed ctx.get(SomeHandle). " +
317
342
  "For the real push/accumulate/barrier wiring, use an e2e test.",
318
343
  );
319
344
  },
@@ -361,6 +386,11 @@ export interface RunLoaderResult<T> {
361
386
  locationState: Record<string, unknown>;
362
387
  /** The resolved rango state cookie name seeded for the run (default `rango-state_router_0`). */
363
388
  stateCookieName: string;
389
+ /**
390
+ * Handle writes the loader made via `ctx.use(SomeHandle)({...})`, in push
391
+ * order. A `.defer()` resolver's value is recorded when the resolver runs.
392
+ */
393
+ handlePushes: Array<{ handle: Handle<any, any>; value: unknown }>;
364
394
  }
365
395
 
366
396
  export async function runLoaderResult<T>(
@@ -372,14 +402,22 @@ export async function runLoaderResult<T>(
372
402
  buildLoaderCtxOpts(opts),
373
403
  );
374
404
  const reqCtx = ctx as RequestContext<any>;
405
+ const handlePushes: Array<{ handle: Handle<any, any>; value: unknown }> = [];
375
406
  let result: T | undefined;
376
407
  let thrown: unknown;
377
408
  try {
378
- result = await runWithLoaderContext(reqCtx, opts, (loaderCtx) =>
379
- Promise.resolve(loaderFn(loaderCtx)),
409
+ result = await runWithLoaderContext(
410
+ reqCtx,
411
+ opts,
412
+ (loaderCtx) => Promise.resolve(loaderFn(loaderCtx)),
413
+ handlePushes,
380
414
  );
381
415
  } catch (error) {
382
416
  thrown = error;
383
417
  }
384
- return { result, ...buildRunSnapshot(reqCtx, thrown, stateCookieName) };
418
+ return {
419
+ result,
420
+ handlePushes,
421
+ ...buildRunSnapshot(reqCtx, thrown, stateCookieName),
422
+ };
385
423
  }
@@ -74,6 +74,25 @@ export type LoaderDataResult<T = unknown> =
74
74
  ok: false;
75
75
  error: ErrorInfo;
76
76
  fallback: ReactNode | null;
77
+ /**
78
+ * Loader threw notFound() (DataNotFoundError). `fallback` carries the
79
+ * SERVER-RENDERED not-found UI (nearest notFoundBoundary → router
80
+ * notFound option → default), so the client swaps to 404 presentation
81
+ * with zero extra round trips. Routed by decodeLoaderEntry via
82
+ * LOADER_NOT_FOUND_FALLBACK, not the error-fallback marker.
83
+ */
84
+ notFound?: true;
85
+ /**
86
+ * Loader threw redirect(...) (a 3xx Response). `to` is resolved through
87
+ * the soft-redirect same-origin rules BEFORE leaving the server
88
+ * (resolveSoftRedirectUrl), so unsafe targets never reach the wire. The
89
+ * client navigates (replace) when the entry decodes. `state` is the
90
+ * resolved `redirect(url, { state })` record (`__rsc_ls_*` keys) — it
91
+ * travels on the marker because a streaming loader settles after
92
+ * payload metadata flushed; the redirect navigation merges it at the
93
+ * target entry.
94
+ */
95
+ redirect?: { to: string; state?: Record<string, unknown> };
77
96
  };
78
97
 
79
98
  export function isLoaderDataResult(value: unknown): value is LoaderDataResult {
@@ -63,6 +63,7 @@ export type {
63
63
  LoaderContext,
64
64
  LoaderFn,
65
65
  FetchableLoaderOptions,
66
+ LoaderOptions,
66
67
  LoadOptions,
67
68
  LoaderDefinition,
68
69
  } from "./loader-types.js";
@@ -1,5 +1,6 @@
1
1
  import type { ContextVar } from "../context-var.js";
2
2
  import type { Handle } from "../handle.js";
3
+ import type { HandlePush } from "../defer.js";
3
4
  import type { MiddlewareFn } from "../router/middleware.js";
4
5
  import type { ScopedReverseFunction } from "../reverse.js";
5
6
  import type { SearchSchema, ResolveSearchSchema } from "../search-params.js";
@@ -51,14 +52,42 @@ export type LoaderContext<
51
52
  */
52
53
  routeParams: Record<string, string>;
53
54
  search: {} extends TSearch ? {} : ResolveSearchSchema<TSearch>;
55
+ /**
56
+ * Read a context variable — or READ collected handle data after
57
+ * `await ctx.rendered()` (the rendered-barrier contract; handle reads
58
+ * moved here from ctx.use(handle), which is now the write).
59
+ */
54
60
  get: {
55
61
  <T>(contextVar: ContextVar<T>): T | undefined;
62
+ <TData, TAccumulated = TData[]>(
63
+ handle: Handle<TData, TAccumulated>,
64
+ ): TAccumulated;
56
65
  } & (<K extends keyof DefaultVars>(key: K) => DefaultVars[K]);
57
66
  /**
58
- * Access another loader's data, or read handle data after rendered().
67
+ * Access another loader's data, or WRITE handle data (meta, breadcrumbs, …)
68
+ * — handler parity: `ctx.use(Meta)({ title })` pushes exactly like it does
69
+ * in a handler. Handle READS live on `ctx.get(handle)` (after rendered()).
59
70
  *
60
71
  * For loaders: returns a promise (loaders run in parallel).
61
- * For handles: returns collected data (only after `await ctx.rendered()`).
72
+ * For handles: returns the push function, legal for the whole body,
73
+ * streaming loaders included. Delivery is async by the race model: pushes
74
+ * that settle before the handler barrier ride the SSR handle snapshot;
75
+ * later ones stream to the client and apply post-hydration (document lane)
76
+ * or progressively (navigation/action lanes). To guarantee a loader's
77
+ * handles are in the SSR'd document, register it as
78
+ * `loader(Def, { stream: "navigation" })` so the document render awaits it
79
+ * (see {@link LoaderOptions}).
80
+ *
81
+ * @example
82
+ * ```typescript
83
+ * export const ProductLoader = createLoader(async (ctx) => {
84
+ * "use server";
85
+ * const product = await getProduct(ctx.params.slug);
86
+ * ctx.use(Meta)({ title: product.name });
87
+ * ctx.use(Breadcrumbs)({ label: product.name });
88
+ * return product;
89
+ * });
90
+ * ```
62
91
  */
63
92
  use: {
64
93
  <T, TLoaderParams = any>(
@@ -66,13 +95,13 @@ export type LoaderContext<
66
95
  ): Promise<T>;
67
96
  <TData, TAccumulated = TData[]>(
68
97
  handle: Handle<TData, TAccumulated>,
69
- ): TAccumulated;
98
+ ): HandlePush<TData>;
70
99
  };
71
100
  /**
72
101
  * **Experimental.** Wait for all non-loader segments to settle.
73
102
  *
74
103
  * After the returned promise resolves, handle data is available via
75
- * `ctx.use(handle)`. Supported in DSL loaders, including on streaming
104
+ * `ctx.get(handle)`. Supported in DSL loaders, including on streaming
76
105
  * trees that use `loading()` — the barrier waits for the streaming
77
106
  * handlers to finish pushing before it resolves. Throws if called from a
78
107
  * handler-invoked loader, or if a handler is already awaiting this loader
@@ -84,7 +113,7 @@ export type LoaderContext<
84
113
  * const PricesLoader = createLoader(async (ctx) => {
85
114
  * "use server";
86
115
  * await ctx.rendered();
87
- * const products = ctx.use(Products); // reads handle data
116
+ * const products = ctx.get(Products); // reads handle data
88
117
  * return pricing.getLive(products.map(p => p.id));
89
118
  * });
90
119
  * ```
@@ -144,6 +173,32 @@ export type LoaderFn<
144
173
  TEnv = DefaultEnv,
145
174
  > = (ctx: LoaderContext<TParams, TEnv>) => Promise<T> | T;
146
175
 
176
+ /**
177
+ * Delivery mode for a DSL-registered loader: `loader(Def, { stream })`.
178
+ *
179
+ * Default (omitted): the loader streams on every render. Its data, its
180
+ * `ctx.use(Handle)` pushes, and any `notFound()`/`redirect()` it throws may land
181
+ * AFTER the document Response is constructed, so none of them are guaranteed to
182
+ * be in the SSR'd HTML.
183
+ *
184
+ * `"navigation"` narrows streaming to client navigations only: on a DOCUMENT
185
+ * request the loader is awaited before first flush. `useLoader` still suspends,
186
+ * but on an already-settled promise, so no fallback paints. This is the
187
+ * SSR-completeness opt-in — the name is about WHERE streaming still applies, not
188
+ * about disabling it. Choose it when the loader feeds something that must exist
189
+ * in the document: `<head>` meta via a handle, or a real 404 status (an awaited
190
+ * `notFound()` deterministically precedes Response construction, where the
191
+ * streamed default only wins that race opportunistically). It does NOT change
192
+ * PPR capture behavior: capture renders mask loaders and skip this await.
193
+ *
194
+ * Scoped per LOADER, not per segment: a baked loader alongside a deliberately
195
+ * dynamic sibling awaits only itself, and the sibling keeps streaming behind its
196
+ * `loading()`/Suspense boundary.
197
+ */
198
+ export type LoaderOptions = {
199
+ stream?: "navigation";
200
+ };
201
+
147
202
  /**
148
203
  * Options for fetchable loaders
149
204
  *
@@ -258,6 +258,13 @@ export interface MatchResult {
258
258
  * Slots are used for intercepting routes during soft navigation
259
259
  */
260
260
  slots?: Record<string, SlotState>;
261
+ /**
262
+ * Intercept TARGET route names reachable when this location is a navigation
263
+ * origin (chain walk of the matched entry, when-conditionals included).
264
+ * Shipped in payload metadata so the browser-local clientUrls matcher can
265
+ * decline its optimistic presentation for targets an intercept would claim.
266
+ */
267
+ interceptTargets?: string[];
261
268
  /**
262
269
  * Redirect URL for trailing slash normalization.
263
270
  * When set, the RSC handler should return a 308 redirect to this URL
@@ -11,6 +11,12 @@ import {
11
11
  import type { UrlPatterns, IncludeOptions } from "./pattern-types.js";
12
12
  import type { IncludeProvider } from "./include-provider.js";
13
13
  import type { IncludeFn } from "./path-helper-types.js";
14
+ import {
15
+ clientUrlIncludePatterns,
16
+ isClientUrlPatterns,
17
+ isClientUrlReference,
18
+ } from "../client-urls/server-projection.js";
19
+ import type { ClientUrlPatterns } from "../client-urls/types.js";
14
20
 
15
21
  function hasExplicitNameOption(options: IncludeOptions | undefined): boolean {
16
22
  return !!options && Object.prototype.hasOwnProperty.call(options, "name");
@@ -61,15 +67,27 @@ export function processItems(items: readonly AllUseItems[]): AllUseItems[] {
61
67
  export function createIncludeHelper<TEnv>(): IncludeFn<TEnv> {
62
68
  return (
63
69
  prefix: string,
64
- // A `urls()` value (eager) OR an async provider thunk
70
+ // A `urls()` value (eager), an async provider thunk
65
71
  // (`() => import("./routes")`) whose evaluation is deferred to the first
66
- // request matching `prefix`. The provider is stored unevaluated and
67
- // resolved by the runtime lazy-include expansion / build-time discovery.
68
- patterns: UrlPatterns<TEnv> | IncludeProvider<TEnv>,
72
+ // request matching `prefix`, or a clientUrls() definition (object or
73
+ // client reference). Providers are stored unevaluated and resolved by the
74
+ // runtime lazy-include expansion / build-time discovery.
75
+ patterns: UrlPatterns<TEnv> | IncludeProvider<TEnv> | ClientUrlPatterns,
69
76
  options?: IncludeOptions,
70
77
  ): IncludeItem => {
71
78
  const { ctx } = requireDslContext("include() must be called inside urls()");
72
79
 
80
+ // clientUrls() sources mount through include() like any urls() module.
81
+ // Detect them FIRST: a client REFERENCE is a callable proxy, so the
82
+ // downstream provider check (`typeof === "function"`) would otherwise
83
+ // invoke it as an async include thunk. The substituted handler defers
84
+ // materialization to evaluation time, when the discovery-installed
85
+ // projection is available; the include machinery then applies URL and
86
+ // route-name prefixes exactly as for server modules.
87
+ if (isClientUrlPatterns(patterns) || isClientUrlReference(patterns)) {
88
+ patterns = clientUrlIncludePatterns(patterns) as UrlPatterns<TEnv>;
89
+ }
90
+
73
91
  const explicitName = options?.name;
74
92
  const hasExplicitName = hasExplicitNameOption(options);
75
93
  if (hasExplicitName && explicitName) {