@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
@@ -134,6 +134,35 @@ export function isExternalRedirect(response: Response): boolean {
134
134
  return externalRedirects.has(response);
135
135
  }
136
136
 
137
+ /**
138
+ * Out-of-band carrier for `redirect(url, { state })` location state, keyed on
139
+ * the Response object like the external brand above. Needed by the LOADER
140
+ * lane: streaming loaders settle AFTER attachLocationStateIfPresent stamped
141
+ * payload metadata (rsc-rendering.ts runs it between payload prep and Flight
142
+ * serialization), so request-context state set at throw time misses the
143
+ * payload. The thrown Response IS the object loader-resolution catches — no
144
+ * rebuild path intervenes — so a WeakMap entry survives to the catch site,
145
+ * where it is serialized onto the loader-result redirect marker and delivered
146
+ * WITH the redirect navigation (LoaderRedirect), merging at the target like
147
+ * an action write. Handler/middleware/action redirects keep the metadata lane.
148
+ */
149
+ const redirectStates = new WeakMap<Response, Record<string, unknown>>();
150
+
151
+ /** Brand a redirect Response with its RESOLVED location-state record. */
152
+ export function attachRedirectState(
153
+ response: Response,
154
+ state: Record<string, unknown>,
155
+ ): void {
156
+ redirectStates.set(response, state);
157
+ }
158
+
159
+ /** Read the resolved location-state record off a redirect Response. */
160
+ export function getRedirectState(
161
+ response: Response,
162
+ ): Record<string, unknown> | undefined {
163
+ return redirectStates.get(response);
164
+ }
165
+
137
166
  /**
138
167
  * Reserved internal header name. No longer a trust signal -- the external
139
168
  * opt-in is the out-of-band brand above. It is kept only so the redirect-rebuild
@@ -1,9 +1,64 @@
1
1
  "use client";
2
2
  import type { ReactNode } from "react";
3
- import { Suspense, use } from "react";
3
+ import { Component, Suspense, use } from "react";
4
4
  import { OutletProvider } from "./outlet-provider.js";
5
5
  import type { ResolvedSegment } from "./types.js";
6
- import { decodeLoaderResults } from "./decode-loader-results.js";
6
+ import {
7
+ decodeLoaderResults,
8
+ LOADER_ERROR_FALLBACK,
9
+ LOADER_NOT_FOUND_FALLBACK,
10
+ LOADER_REDIRECT,
11
+ } from "./decode-loader-results.js";
12
+ import { LoaderRedirect } from "./loader-redirect.js";
13
+
14
+ /**
15
+ * Router-owned error boundary for read-site loader errors. segment-system
16
+ * wraps every loader-bearing segment's children in one (unconditionally —
17
+ * streams and forceAwait lanes alike, so the tree shape never differs between
18
+ * navigation lanes; see docs/tree-structure.md). A loader error thrown by a
19
+ * suspending read carries its errorBoundary() fallback via
20
+ * LOADER_ERROR_FALLBACK (decodeLoaderEntry); this boundary renders that node,
21
+ * restoring the pre-streaming errorFallback-swap contract.
22
+ *
23
+ * Loader-thrown AUTHORITY SIGNALS ride sibling markers:
24
+ * - LOADER_NOT_FOUND_FALLBACK (notFound()): renders the SERVER-RENDERED
25
+ * not-found UI carried on the marker — nearest notFoundBoundary → router
26
+ * notFound option — zero extra fetches. Document lane: Fizz emitted the
27
+ * Suspense fallback and replays the throw at hydration, so the swap happens
28
+ * client-side (the HTTP status was already set opportunistically by the
29
+ * producer when the rejection won the flush race).
30
+ * - LOADER_REDIRECT (redirect()): mounts LoaderRedirect, which navigates.
31
+ *
32
+ * Errors without any marker rethrow to the app's own boundaries.
33
+ */
34
+ export class StreamedLoaderErrorBoundary extends Component<
35
+ { children: ReactNode },
36
+ { error: unknown }
37
+ > {
38
+ state: { error: unknown } = { error: null };
39
+
40
+ static getDerivedStateFromError(error: unknown): { error: unknown } {
41
+ return { error };
42
+ }
43
+
44
+ render(): ReactNode {
45
+ const { error } = this.state;
46
+ if (error !== null && error !== undefined) {
47
+ const marked = error as Record<PropertyKey, unknown>;
48
+ const notFoundFallback = marked[LOADER_NOT_FOUND_FALLBACK];
49
+ if (notFoundFallback !== undefined) return notFoundFallback as ReactNode;
50
+ const redirect = marked[LOADER_REDIRECT];
51
+ if (redirect !== undefined) {
52
+ const r = redirect as { to: string; state?: Record<string, unknown> };
53
+ return <LoaderRedirect to={r.to} state={r.state} />;
54
+ }
55
+ const fallback = marked[LOADER_ERROR_FALLBACK];
56
+ if (fallback !== undefined) return fallback as ReactNode;
57
+ throw error;
58
+ }
59
+ return this.props.children;
60
+ }
61
+ }
7
62
 
8
63
  /**
9
64
  * Stable async wrapper component for route content
@@ -67,6 +122,13 @@ const Suspender = ({
67
122
  export interface LoaderBoundaryProps {
68
123
  loaderDataPromise: Promise<any[]> | any[];
69
124
  loaderIds: string[];
125
+ /**
126
+ * SPIKE (streaming useLoader): per-loader UNDECODED results from the
127
+ * producer (values or individually-pending promises). When present, the
128
+ * resolver passes them through instead of resolving the aggregate above
129
+ * the children; useLoader suspends per loader at the read site.
130
+ */
131
+ loaderStreams?: Record<string, unknown>;
70
132
  fallback?: ReactNode;
71
133
  outletKey: string;
72
134
  outletContent: ReactNode;
@@ -78,6 +140,7 @@ export interface LoaderBoundaryProps {
78
140
  export function LoaderBoundary({
79
141
  loaderDataPromise,
80
142
  loaderIds,
143
+ loaderStreams,
81
144
  fallback,
82
145
  outletKey,
83
146
  outletContent,
@@ -90,6 +153,7 @@ export function LoaderBoundary({
90
153
  <LoaderResolver
91
154
  loaderDataPromise={loaderDataPromise}
92
155
  loaderIds={loaderIds}
156
+ loaderStreams={loaderStreams}
93
157
  outletKey={outletKey}
94
158
  outletContent={outletContent}
95
159
  segment={segment}
@@ -103,17 +167,46 @@ export function LoaderBoundary({
103
167
 
104
168
  /**
105
169
  * Internal component that resolves loader promises and renders OutletProvider
170
+ *
171
+ * SPIKE (streaming useLoader): when the producer provides per-loader streams,
172
+ * nothing resolves here above the children — the streams pass through
173
+ * OutletProvider and useLoader suspends at the read site, with the
174
+ * LoaderBoundary's loading() fallback as the catching boundary. The streams
175
+ * MUST be per-loader promises from the producer: deriving them by splitting
176
+ * the aggregate (aggregate.then(r => r[i])) is wrong — Promise.all resolves
177
+ * at the SLOWEST loader, so every derived promise inherits the slowest
178
+ * timing and per-loader granularity is erased (measured: a 400ms loader's
179
+ * content held until a 2000ms sibling resolved).
180
+ *
181
+ * Without streams, the pre-spike behavior is preserved: a pending aggregate
182
+ * resolves ABOVE via use() (parallel/intercept slots still take this path —
183
+ * ResolvedSegment carries only the aggregate for slots), and a resolved
184
+ * array decodes synchronously (forceAwait/action lanes commit whole).
106
185
  */
107
186
  function LoaderResolver({
108
187
  loaderDataPromise,
109
188
  loaderIds,
189
+ loaderStreams,
110
190
  outletKey,
111
191
  outletContent,
112
192
  segment,
113
193
  parallel,
114
194
  children,
115
195
  }: Omit<LoaderBoundaryProps, "fallback">): ReactNode {
116
- // Resolve loader promises using React's use()
196
+ if (loaderStreams) {
197
+ return (
198
+ <OutletProvider
199
+ key={outletKey}
200
+ content={outletContent}
201
+ segment={segment}
202
+ parallel={parallel}
203
+ loaderStreams={loaderStreams}
204
+ >
205
+ {children}
206
+ </OutletProvider>
207
+ );
208
+ }
209
+
117
210
  const resolvedData =
118
211
  loaderDataPromise instanceof Promise
119
212
  ? use(loaderDataPromise)
@@ -19,6 +19,7 @@ import {
19
19
  type HelperContext,
20
20
  type InterceptEntry,
21
21
  type InterceptConfig,
22
+ type LoaderEntry,
22
23
  } from "../server/context";
23
24
  import { invariant } from "../errors";
24
25
  import { validateUserRouteName } from "../route-name.js";
@@ -782,7 +783,11 @@ const intercept = (
782
783
  /**
783
784
  * Loader helper - attaches a loader to the current entry
784
785
  */
785
- const loader: RouteHelpers<any, any>["loader"] = (loaderDef, use) => {
786
+ const loader: RouteHelpers<any, any>["loader"] = (
787
+ loaderDef,
788
+ optionsOrUse,
789
+ maybeUse,
790
+ ) => {
786
791
  const { store, ctx } = requireDslContext(
787
792
  "loader() must be called inside urls()",
788
793
  );
@@ -792,12 +797,32 @@ const loader: RouteHelpers<any, any>["loader"] = (loaderDef, use) => {
792
797
  invariant(false, "No parent entry available for loader()");
793
798
  }
794
799
 
800
+ // loader(Def, use) and loader(Def, options, use) — same options-or-use
801
+ // disambiguation path() uses, so the long-standing 2-arg form is untouched.
802
+ const optionsGiven =
803
+ typeof optionsOrUse === "function" ? undefined : optionsOrUse;
804
+ const use = typeof optionsOrUse === "function" ? optionsOrUse : maybeUse;
805
+ invariant(
806
+ !(typeof optionsOrUse === "function" && maybeUse !== undefined),
807
+ "loader() received two use() callbacks. Pass loader(Def, options, use) or loader(Def, use).",
808
+ );
809
+ invariant(
810
+ optionsGiven?.stream === undefined || optionsGiven.stream === "navigation",
811
+ `loader() stream must be "navigation" (got ${JSON.stringify(optionsGiven?.stream)}). Omit it to stream on every render.`,
812
+ );
813
+
795
814
  const name = `${ctx.namespace}.$${store.getNextIndex("loader")}`;
796
815
 
797
- // Create loader entry with empty revalidate array
798
- const loaderEntry = {
816
+ // Create loader entry with empty revalidate array. awaitBeforeFlush is
817
+ // resolved here, at DSL-evaluation time, for the same reason
818
+ // loading({ ssr: false }) is (below) — per-isSSR entry caching makes the
819
+ // flag request-mode-correct with no isSSR threading; see LoaderEntry.
820
+ const loaderEntry: LoaderEntry = {
799
821
  loader: loaderDef,
800
822
  revalidate: [] as ShouldRevalidateFn<any, any>[],
823
+ ...(optionsGiven?.stream === "navigation" && ctx.isSSR
824
+ ? { awaitBeforeFlush: true as const }
825
+ : {}),
801
826
  };
802
827
 
803
828
  // Merge handler.use defaults (attached to the loader definition) with explicit use
@@ -5,6 +5,7 @@ import type {
5
5
  PartialCacheOptions,
6
6
  ErrorBoundaryHandler,
7
7
  LoaderDefinition,
8
+ LoaderOptions,
8
9
  MiddlewareFn,
9
10
  NotFoundBoundaryHandler,
10
11
  ResolvedRouteMap,
@@ -309,11 +310,23 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
309
310
  * return <div>{data.name}</div>;
310
311
  * }
311
312
  * ```
313
+ * Pass `{ stream: "navigation" }` to await this loader before first flush on
314
+ * DOCUMENT requests (see {@link LoaderOptions}) — the opt-in for loaders whose
315
+ * data, handle pushes, or thrown notFound()/redirect() must be in the SSR'd
316
+ * HTML. Per-loader: a dynamic sibling in the same segment keeps streaming.
317
+ *
318
+ * ```typescript
319
+ * loader(ProductLoader, { stream: "navigation" }, () => [cache()]),
320
+ * loader(RecommendationsLoader), // still streams behind loading()
321
+ * ```
322
+ *
312
323
  * @param loaderDef - Loader created with createLoader()
324
+ * @param optionsOrUse - Delivery options, or the use() callback when passing none
313
325
  * @param use - Optional callback for loader-specific revalidation rules
314
326
  */
315
327
  loader: <TData>(
316
328
  loaderDef: LoaderDefinition<TData>,
329
+ optionsOrUse?: LoaderOptions | (() => UseItems<LoaderUseItem>),
317
330
  use?: () => UseItems<LoaderUseItem>,
318
331
  ) => LoaderItem;
319
332
  /**
@@ -1,9 +1,13 @@
1
1
  import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
2
+ import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
2
3
  import {
3
4
  getRequestContext,
4
5
  _getRequestContext,
5
6
  } from "../server/request-context.js";
6
- import { markExternalRedirect } from "../redirect-origin.js";
7
+ import {
8
+ attachRedirectState,
9
+ markExternalRedirect,
10
+ } from "../redirect-origin.js";
7
11
 
8
12
  /**
9
13
  * Create a soft redirect Response for middleware short-circuit
@@ -77,23 +81,6 @@ export function redirect(
77
81
  if (state) {
78
82
  const ctx = getRequestContext();
79
83
  ctx.setLocationState(state);
80
-
81
- if (process.env.NODE_ENV !== "production") {
82
- const reqCtx = getRequestContext();
83
- // Warn only on true full-page SSR loads. SPA partial requests and server
84
- // actions both deliver state through Flight payloads, so suppress for those.
85
- if (
86
- reqCtx &&
87
- !reqCtx.originalUrl.searchParams.has("_rsc_partial") &&
88
- !reqCtx.request.headers.has("rsc-action") &&
89
- !reqCtx.originalUrl.searchParams.has("_rsc_action")
90
- ) {
91
- console.warn(
92
- `[Router] redirect() with state during a full-page (SSR) request to "${url}". ` +
93
- "Location state is only delivered during SPA navigations and will be lost on this request.",
94
- );
95
- }
96
- }
97
84
  }
98
85
 
99
86
  // Auto-prefix root-relative URLs with basename for app-local redirects.
@@ -120,6 +107,18 @@ export function redirect(
120
107
 
121
108
  const response = new Response(null, { status, headers });
122
109
 
110
+ // Also brand the Response itself with the resolved state (see
111
+ // redirect-origin.ts): the request-context write above misses the payload
112
+ // when a STREAMING loader throws this Response after metadata flushed, so
113
+ // the loader lane reads the state off the thrown object and ships it on
114
+ // the redirect marker instead.
115
+ if (state) {
116
+ attachRedirectState(
117
+ response,
118
+ resolveLocationStateEntries(Array.isArray(state) ? state : [state]),
119
+ );
120
+ }
121
+
123
122
  // Mark an explicit off-host redirect with an out-of-band brand so the
124
123
  // same-origin guard (rsc/redirect-guard.ts) lets it through. The brand is a
125
124
  // WeakSet membership on this Response object -- NOT a wire header -- so the
@@ -5,7 +5,7 @@
5
5
  * Also includes the shared invokeOnError utility for error callback invocation.
6
6
  */
7
7
 
8
- import type { ReactNode } from "react";
8
+ import { createElement, type ReactNode } from "react";
9
9
  import type { EntryData } from "../server/context";
10
10
  import type {
11
11
  ResolvedSegment,
@@ -281,6 +281,69 @@ export function createNotFoundInfo(
281
281
  };
282
282
  }
283
283
 
284
+ /** Router-level `createRouter({ notFound })` option shape. */
285
+ export type NotFoundComponentOption =
286
+ | ReactNode
287
+ | ((props: { pathname: string }) => ReactNode);
288
+
289
+ /**
290
+ * Pick the effective notFound fallback: nearest `notFoundBoundary`, else the
291
+ * router-level `notFound` option, else a plain default.
292
+ *
293
+ * The router option is resolved ONLY when no nearer boundary won — it may be a
294
+ * user render function doing arbitrary work, so computing it eagerly (as three
295
+ * earlier copies of this policy did) burned a full render on every 404 that a
296
+ * boundary was going to handle anyway.
297
+ *
298
+ * Every 404 origin routes through here — segment resolution
299
+ * (`segment-resolution/helpers.ts`), loader-thrown `notFound()`
300
+ * (`loader-resolution.ts`), and the unmatched-route path (`rsc/handler.ts`) —
301
+ * so the default text and the boundary-wins precedence cannot drift apart.
302
+ */
303
+ export function resolveNotFoundFallback(
304
+ boundary: ReactNode | NotFoundBoundaryHandler | null | undefined,
305
+ notFoundComponent: NotFoundComponentOption | undefined,
306
+ pathname: string | undefined,
307
+ ): ReactNode | NotFoundBoundaryHandler {
308
+ if (boundary !== null && boundary !== undefined) return boundary;
309
+ return resolveDefaultNotFound(notFoundComponent, pathname);
310
+ }
311
+
312
+ /**
313
+ * The router-level default alone: `createRouter({ notFound })` rendered, or the
314
+ * plain fallback node. Separate from {@link resolveNotFoundFallback} for the
315
+ * unmatched-route path, which has no entry chain and therefore no boundary to
316
+ * consult — it gets a `ReactNode` back rather than a possible handler.
317
+ */
318
+ export function resolveDefaultNotFound(
319
+ notFoundComponent: NotFoundComponentOption | undefined,
320
+ pathname: string | undefined,
321
+ ): ReactNode {
322
+ if (typeof notFoundComponent === "function") {
323
+ return notFoundComponent({ pathname: pathname ?? "" });
324
+ }
325
+ return notFoundComponent ?? createElement("h1", null, "Not Found");
326
+ }
327
+
328
+ /**
329
+ * Invoke a notFound fallback that may be a boundary handler or a plain node.
330
+ *
331
+ * Callers that cannot propagate a throw (the loader envelope) wrap this in
332
+ * their own try/catch; it deliberately does not swallow, so a throwing boundary
333
+ * still surfaces where that is the correct behavior.
334
+ */
335
+ export function renderNotFoundFallback(
336
+ fallback: ReactNode | NotFoundBoundaryHandler,
337
+ notFoundInfo: NotFoundInfo,
338
+ ): ReactNode {
339
+ if (typeof fallback === "function") {
340
+ return fallback({
341
+ notFound: notFoundInfo,
342
+ } satisfies NotFoundBoundaryFallbackProps);
343
+ }
344
+ return fallback;
345
+ }
346
+
284
347
  /**
285
348
  * Create a notFound segment with the fallback component
286
349
  * Renders the fallback with not found info
@@ -291,16 +354,7 @@ export function createNotFoundSegment(
291
354
  entry: EntryData,
292
355
  params: Record<string, string>,
293
356
  ): ResolvedSegment {
294
- let component: ReactNode;
295
-
296
- if (typeof fallback === "function") {
297
- const props: NotFoundBoundaryFallbackProps = {
298
- notFound: notFoundInfo,
299
- };
300
- component = fallback(props);
301
- } else {
302
- component = fallback;
303
- }
357
+ const component: ReactNode = renderNotFoundFallback(fallback, notFoundInfo);
304
358
 
305
359
  return {
306
360
  id: `${entry.shortCode}.notFound`,
@@ -86,6 +86,35 @@ export function evaluateInterceptWhen(
86
86
  * Find an intercept for the target route by walking up the entry chain.
87
87
  * Returns the first (innermost) matching intercept along with the entry that defines it.
88
88
  */
89
+ /**
90
+ * Collect every intercept TARGET route name reachable from an origin entry —
91
+ * the same chain walk findInterceptForRoute() performs when this location is
92
+ * the navigation origin. Shipped in payload metadata so the browser-local
93
+ * clientUrls matcher can DECLINE its optimistic presentation for targets an
94
+ * intercept would claim (the committed result keeps the origin page + modal,
95
+ * so destination loading would flash and revert). Deliberately includes
96
+ * `when`-conditional intercepts: selectors need the live navigation context,
97
+ * so the browser stays conservative — worst case a non-intercepted navigation
98
+ * loses its optimistic loading, never the reverse.
99
+ */
100
+ export function collectInterceptTargetNames(
101
+ fromEntry: EntryData | null,
102
+ ): string[] {
103
+ const names = new Set<string>();
104
+ let current: EntryData | null = fromEntry;
105
+ while (current) {
106
+ // Tolerate partial entries (unit-test mocks omit the arrays); real
107
+ // registration always populates layout/intercept.
108
+ for (const source of [current, ...(current.layout ?? [])]) {
109
+ for (const intercept of source.intercept ?? []) {
110
+ names.add(intercept.routeName);
111
+ }
112
+ }
113
+ current = current.parent;
114
+ }
115
+ return [...names];
116
+ }
117
+
89
118
  export function findInterceptForRoute(
90
119
  targetRouteKey: string,
91
120
  fromEntry: EntryData | null,