@rangojs/router 0.5.2 → 0.6.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 (173) 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 +62 -0
  16. package/dist/types/client-urls/types.d.ts +144 -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/trie-matching.d.ts +1 -1
  36. package/dist/types/rsc/manifest-init.d.ts +5 -5
  37. package/dist/types/rsc/shell-capture.d.ts +9 -0
  38. package/dist/types/rsc/shell-serve.d.ts +11 -0
  39. package/dist/types/rsc/types.d.ts +30 -0
  40. package/dist/types/segment-system.d.ts +2 -0
  41. package/dist/types/server/context.d.ts +10 -0
  42. package/dist/types/server/handle-store.d.ts +34 -3
  43. package/dist/types/server/request-context.d.ts +11 -1
  44. package/dist/types/server.d.ts +1 -0
  45. package/dist/types/ssr/index.d.ts +22 -0
  46. package/dist/types/ssr/ssr-root.d.ts +10 -0
  47. package/dist/types/testing/dom.entry.d.ts +1 -1
  48. package/dist/types/testing/render-route.d.ts +16 -6
  49. package/dist/types/testing/run-loader.d.ts +9 -0
  50. package/dist/types/types/boundaries.d.ts +22 -0
  51. package/dist/types/types/index.d.ts +1 -1
  52. package/dist/types/types/loader-types.d.ts +57 -5
  53. package/dist/types/types/segments.d.ts +7 -0
  54. package/dist/types/urls/path-helper-types.d.ts +10 -4
  55. package/dist/types/vite/discovery/client-urls-projection.d.ts +53 -0
  56. package/dist/types/vite/discovery/discover-routers.d.ts +1 -1
  57. package/dist/types/vite/discovery/state.d.ts +8 -1
  58. package/dist/vite/index.js +5313 -2365
  59. package/package.json +1 -1
  60. package/skills/breadcrumbs/SKILL.md +39 -9
  61. package/skills/catalog.json +7 -1
  62. package/skills/client-urls/SKILL.md +338 -0
  63. package/skills/comparison/references/framework-comparison.md +23 -9
  64. package/skills/hooks/SKILL.md +2 -2
  65. package/skills/hooks/data.md +11 -2
  66. package/skills/hooks/handle-and-actions.md +7 -0
  67. package/skills/hooks/outlets.md +26 -5
  68. package/skills/hooks/urls.md +40 -3
  69. package/skills/loader/SKILL.md +132 -20
  70. package/skills/migrate-nextjs/SKILL.md +70 -10
  71. package/skills/migrate-react-router/SKILL.md +49 -13
  72. package/skills/migrate-react-router/component-migration.md +18 -13
  73. package/skills/migrate-react-router/data-and-actions.md +14 -3
  74. package/skills/migrate-react-router/route-mapping.md +15 -2
  75. package/skills/parallel/SKILL.md +32 -1
  76. package/skills/ppr/SKILL.md +16 -6
  77. package/skills/prerender/SKILL.md +8 -4
  78. package/skills/rango/SKILL.md +21 -17
  79. package/skills/react-compiler/SKILL.md +3 -3
  80. package/skills/route/SKILL.md +5 -2
  81. package/skills/router-setup/SKILL.md +16 -2
  82. package/skills/scripts/SKILL.md +16 -6
  83. package/skills/shell-manifest/SKILL.md +16 -7
  84. package/skills/testing/SKILL.md +2 -2
  85. package/skills/testing/client-components.md +6 -0
  86. package/skills/testing/handles.md +30 -8
  87. package/skills/testing/loader.md +51 -49
  88. package/skills/testing/middleware.md +1 -1
  89. package/skills/theme/SKILL.md +8 -5
  90. package/src/bin/rango.ts +7 -3
  91. package/src/browser/navigation-bridge.ts +6 -0
  92. package/src/browser/navigation-client.ts +5 -0
  93. package/src/browser/partial-update.ts +65 -13
  94. package/src/browser/react/use-router.ts +40 -11
  95. package/src/browser/react/use-search-params.ts +140 -17
  96. package/src/browser/rsc-router.tsx +59 -0
  97. package/src/browser/server-action-bridge.ts +26 -0
  98. package/src/browser/types.ts +22 -0
  99. package/src/build/merge-full-manifests.ts +161 -0
  100. package/src/build/route-trie.ts +9 -332
  101. package/src/build/route-types/include-resolution.ts +66 -11
  102. package/src/build/route-types/per-module-writer.ts +11 -6
  103. package/src/build/route-types/router-processing.ts +184 -153
  104. package/src/build/runtime-discovery.ts +23 -12
  105. package/src/cache/cache-exec-scope.ts +47 -0
  106. package/src/cache/cache-runtime.ts +24 -25
  107. package/src/cache/taint.ts +28 -9
  108. package/src/client-urls/client-root.tsx +168 -0
  109. package/src/client-urls/client-urls.ts +698 -0
  110. package/src/client-urls/navigation.ts +237 -0
  111. package/src/client-urls/revalidation-protocol.ts +56 -0
  112. package/src/client-urls/server-projection.ts +579 -0
  113. package/src/client-urls/types.ts +195 -0
  114. package/src/client.rsc.tsx +12 -0
  115. package/src/client.tsx +49 -6
  116. package/src/decode-loader-results.ts +113 -0
  117. package/src/errors.ts +14 -0
  118. package/src/handles/deferred-resolution.ts +14 -7
  119. package/src/index.ts +1 -0
  120. package/src/loader-redirect.tsx +64 -0
  121. package/src/outlet-context.ts +12 -0
  122. package/src/outlet-provider.tsx +15 -1
  123. package/src/redirect-origin.ts +29 -0
  124. package/src/route-content-wrapper.tsx +96 -3
  125. package/src/route-definition/dsl-helpers.ts +28 -3
  126. package/src/route-definition/helpers-types.ts +13 -0
  127. package/src/route-definition/redirect.ts +17 -18
  128. package/src/router/error-handling.ts +65 -11
  129. package/src/router/intercept-resolution.ts +29 -0
  130. package/src/router/loader-resolution.ts +261 -28
  131. package/src/router/match-result.ts +7 -0
  132. package/src/router/revalidation.ts +24 -11
  133. package/src/router/route-trie-builder.ts +334 -0
  134. package/src/router/router-interfaces.ts +38 -0
  135. package/src/router/segment-resolution/fresh.ts +47 -0
  136. package/src/router/segment-resolution/helpers.ts +9 -11
  137. package/src/router/segment-resolution/loader-cache.ts +14 -24
  138. package/src/router/segment-resolution/revalidation.ts +20 -1
  139. package/src/router/trie-matching.ts +3 -3
  140. package/src/router.ts +46 -1
  141. package/src/rsc/full-payload.ts +6 -0
  142. package/src/rsc/handler.ts +10 -7
  143. package/src/rsc/loader-fetch.ts +2 -2
  144. package/src/rsc/manifest-init.ts +28 -9
  145. package/src/rsc/rsc-rendering.ts +15 -1
  146. package/src/rsc/shell-capture.ts +12 -0
  147. package/src/rsc/shell-serve.ts +15 -2
  148. package/src/rsc/ssr-setup.ts +10 -1
  149. package/src/rsc/types.ts +31 -2
  150. package/src/segment-system.tsx +83 -26
  151. package/src/server/context.ts +10 -0
  152. package/src/server/cookie-store.ts +19 -19
  153. package/src/server/handle-store.ts +185 -48
  154. package/src/server/request-context.ts +30 -6
  155. package/src/server.ts +7 -0
  156. package/src/ssr/index.tsx +37 -2
  157. package/src/ssr/ssr-root.tsx +29 -2
  158. package/src/testing/dom.entry.ts +1 -1
  159. package/src/testing/render-route.tsx +22 -8
  160. package/src/testing/run-loader.ts +51 -13
  161. package/src/types/boundaries.ts +19 -0
  162. package/src/types/index.ts +1 -0
  163. package/src/types/loader-types.ts +60 -5
  164. package/src/types/segments.ts +7 -0
  165. package/src/urls/include-helper.ts +22 -4
  166. package/src/urls/path-helper-types.ts +14 -1
  167. package/src/use-loader.tsx +67 -6
  168. package/src/vite/discovery/client-urls-projection.ts +322 -0
  169. package/src/vite/discovery/discover-routers.ts +43 -17
  170. package/src/vite/discovery/state.ts +11 -1
  171. package/src/vite/discovery/virtual-module-codegen.ts +20 -0
  172. package/src/vite/plugins/virtual-entries.ts +12 -3
  173. package/src/vite/router-discovery.ts +163 -12
@@ -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) {
@@ -5,6 +5,7 @@ import type {
5
5
  Handler,
6
6
  HandlerContext,
7
7
  LoaderDefinition,
8
+ LoaderOptions,
8
9
  MiddlewareFn,
9
10
  NotFoundBoundaryHandler,
10
11
  PartialCacheOptions,
@@ -57,6 +58,7 @@ import type {
57
58
  IncludeOptions,
58
59
  } from "./pattern-types.js";
59
60
  import type { ExtractRoutes, ExtractResponses } from "./type-extraction.js";
61
+ import type { ClientUrlPatterns } from "../client-urls/types.js";
60
62
 
61
63
  /**
62
64
  * Base path function signature for defining routes with URL patterns.
@@ -175,6 +177,11 @@ export type IncludeArg<
175
177
  TResponses extends Record<string, unknown>,
176
178
  > =
177
179
  | UrlPatterns<TEnv, TRoutes, TResponses>
180
+ // clientUrls() definitions mount through include() like any urls() module;
181
+ // on the server the runtime value is the module's client reference, but the
182
+ // TypeScript type of that default export IS ClientUrlPatterns, so route
183
+ // names flow into NamedRoutes through the same TRoutes inference.
184
+ | ClientUrlPatterns<TRoutes>
178
185
  | (() =>
179
186
  | IncludeResolved<TEnv, TRoutes, TResponses>
180
187
  | Promise<IncludeResolved<TEnv, TRoutes, TResponses>>);
@@ -338,10 +345,16 @@ export type PathHelpers<TEnv> = {
338
345
  revalidate: (fn: ShouldRevalidateFn<any, TEnv>) => RevalidateItem;
339
346
 
340
347
  /**
341
- * Attach a data loader to the current route/layout
348
+ * Attach a data loader to the current route/layout.
349
+ *
350
+ * Pass `{ stream: "navigation" }` to await this loader before first flush
351
+ * on DOCUMENT requests (see {@link LoaderOptions}) — the opt-in for loaders
352
+ * whose data, handle pushes, or thrown notFound()/redirect() must be in the
353
+ * SSR'd HTML. Per-loader: a dynamic sibling keeps streaming.
342
354
  */
343
355
  loader: <TData>(
344
356
  loaderDef: LoaderDefinition<TData>,
357
+ optionsOrUse?: LoaderOptions | (() => LoaderUseItem[]),
345
358
  use?: () => LoaderUseItem[],
346
359
  ) => LoaderItem;
347
360
 
@@ -3,6 +3,7 @@
3
3
  import {
4
4
  isValidElement,
5
5
  startTransition,
6
+ use,
6
7
  useCallback,
7
8
  useContext,
8
9
  useEffect,
@@ -13,6 +14,7 @@ import {
13
14
  } from "react";
14
15
  import { OutletContext, type OutletContextValue } from "./outlet-context.js";
15
16
  import { loaderStore, type LoaderEntry } from "./loader-store.js";
17
+ import { decodeLoaderEntry } from "./decode-loader-results.js";
16
18
  import type { LoaderDefinition, LoadOptions } from "./types.js";
17
19
 
18
20
  function isShareableGet(options: LoadOptions | undefined): boolean {
@@ -34,19 +36,47 @@ let privateGroupBucketSeq = 0;
34
36
 
35
37
  const NOT_FOUND = Symbol("not-found");
36
38
 
39
+ /**
40
+ * SPIKE (streaming useLoader): lookup results distinguish a synchronously
41
+ * available value from a still-streaming per-loader promise. A pending stream
42
+ * is `use()`d at the read site (implicit suspension); the promise identity is
43
+ * stable per commit (it comes from the provider's memoized context value /
44
+ * element props), which use() requires across replays.
45
+ */
46
+ type LoaderLookup =
47
+ | typeof NOT_FOUND
48
+ | { value: unknown }
49
+ | { stream: Promise<unknown> };
50
+
51
+ function lookupLoaderStreams(
52
+ loaderStreams: Record<string, unknown> | undefined,
53
+ loaderId: string,
54
+ ): LoaderLookup {
55
+ if (!loaderStreams || !(loaderId in loaderStreams)) return NOT_FOUND;
56
+ const entry = loaderStreams[loaderId];
57
+ // Within the loaderStreams channel a Promise always means "still streaming";
58
+ // settled entries are undecoded result values, never promises.
59
+ return entry instanceof Promise
60
+ ? { stream: entry }
61
+ : { value: decodeLoaderEntry(entry) };
62
+ }
63
+
37
64
  function extractContentLoaderData(
38
65
  node: ReactNode,
39
66
  loaderId: string,
40
- ): unknown | typeof NOT_FOUND {
67
+ ): LoaderLookup {
41
68
  if (!isValidElement(node)) return NOT_FOUND;
42
69
  const props = node.props as Record<string, any> | undefined;
43
70
  if (!props) return NOT_FOUND;
44
71
 
45
72
  // Direct OutletProvider with loaderData
46
73
  if (props.loaderData && loaderId in props.loaderData) {
47
- return props.loaderData[loaderId];
74
+ return { value: props.loaderData[loaderId] };
48
75
  }
49
76
 
77
+ const streamed = lookupLoaderStreams(props.loaderStreams, loaderId);
78
+ if (streamed !== NOT_FOUND) return streamed;
79
+
50
80
  if (
51
81
  props.loaderIds &&
52
82
  Array.isArray(props.loaderIds) &&
@@ -57,9 +87,9 @@ function extractContentLoaderData(
57
87
  if (idx !== -1) {
58
88
  const data = (props.loaderDataPromise as any[])[idx];
59
89
  if (data && typeof data === "object" && "ok" in data) {
60
- return data.ok ? data.data : NOT_FOUND;
90
+ return data.ok ? { value: data.data } : NOT_FOUND;
61
91
  }
62
- return data;
92
+ return { value: data };
63
93
  }
64
94
  }
65
95
 
@@ -102,9 +132,10 @@ function useLoaderInternal<T>(
102
132
  ): UseFetchLoaderResult<T> {
103
133
  const context = useContext(OutletContext);
104
134
 
105
- const { contextData, hasContextData } = useMemo((): {
135
+ const walk = useMemo((): {
106
136
  contextData: T | undefined;
107
137
  hasContextData: boolean;
138
+ pendingStream?: Promise<unknown>;
108
139
  } => {
109
140
  let current: OutletContextValue | null | undefined = context;
110
141
  while (current) {
@@ -114,18 +145,48 @@ function useLoaderInternal<T>(
114
145
  hasContextData: true,
115
146
  };
116
147
  }
148
+ const streamed = lookupLoaderStreams(current.loaderStreams, loader.$$id);
149
+ if (streamed !== NOT_FOUND) {
150
+ if ("stream" in streamed) {
151
+ return {
152
+ contextData: undefined,
153
+ hasContextData: true,
154
+ pendingStream: streamed.stream,
155
+ };
156
+ }
157
+ return { contextData: streamed.value as T, hasContextData: true };
158
+ }
117
159
  const contentData = extractContentLoaderData(
118
160
  current.content,
119
161
  loader.$$id,
120
162
  );
121
163
  if (contentData !== NOT_FOUND) {
122
- return { contextData: contentData as T, hasContextData: true };
164
+ if ("stream" in contentData) {
165
+ return {
166
+ contextData: undefined,
167
+ hasContextData: true,
168
+ pendingStream: contentData.stream,
169
+ };
170
+ }
171
+ return { contextData: contentData.value as T, hasContextData: true };
123
172
  }
124
173
  current = current.parent;
125
174
  }
126
175
  return { contextData: undefined, hasContextData: false };
127
176
  }, [context, loader.$$id]);
128
177
 
178
+ // SPIKE (streaming useLoader): a pending per-loader stream suspends HERE —
179
+ // the implicit-suspense read. use() is exempt from hook-order rules, so the
180
+ // conditional call is legal; on replay after resolution it returns
181
+ // synchronously and the decoded value takes the contextData slot. Hooks
182
+ // below never run in a suspended render, so their order is stable across
183
+ // every COMPLETED render.
184
+ const { hasContextData } = walk;
185
+ let contextData = walk.contextData;
186
+ if (walk.pendingStream) {
187
+ contextData = decodeLoaderEntry(use(walk.pendingStream)) as T;
188
+ }
189
+
129
190
  const loaderId = loader.$$id;
130
191
  const key = options?.key;
131
192
  const refreshGroupOption = options?.refreshGroup;