@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
@@ -0,0 +1,195 @@
1
+ import type { ComponentType, ReactNode } from "react";
2
+ import type {
3
+ LoaderDefinition,
4
+ LoaderOptions,
5
+ TransitionConfig,
6
+ } from "../types.js";
7
+ import type { TrieMatchResult } from "../router/trie-matching.js";
8
+ import type { PathOptions } from "../urls/pattern-types.js";
9
+ import type { SearchSchema } from "../search-params.js";
10
+ import type { TypedLayoutItem, TypedRouteItem } from "../route-types.js";
11
+ import type { ExtractRoutes } from "../urls/type-extraction.js";
12
+ import type { UnnamedRoute } from "../urls/pattern-types.js";
13
+
14
+ declare const CLIENT_URL_ITEM_BRAND: unique symbol;
15
+ declare const CLIENT_URL_PATTERNS_BRAND: unique symbol;
16
+
17
+ /** Opaque value returned by a clientUrls() helper. */
18
+ export interface ClientUrlItem {
19
+ readonly [CLIENT_URL_ITEM_BRAND]: void;
20
+ }
21
+
22
+ export type ClientUrlItemInput = ClientUrlItem | readonly ClientUrlItemInput[];
23
+
24
+ export type ClientUrlItems = readonly ClientUrlItemInput[];
25
+
26
+ export type ClientUrlUse = () => ClientUrlItems;
27
+
28
+ export type ClientPathOptions<
29
+ TName extends string = string,
30
+ TSearch extends SearchSchema = SearchSchema,
31
+ > = Pick<PathOptions<TName, TSearch>, "name" | "search" | "trailingSlash">;
32
+
33
+ export type ClientPathFn = <
34
+ const TPattern extends string,
35
+ const TName extends string = UnnamedRoute,
36
+ const TSearch extends SearchSchema = {},
37
+ >(
38
+ pattern: TPattern,
39
+ component: ComponentType,
40
+ optionsOrUse?: ClientPathOptions<TName, TSearch> | ClientUrlUse,
41
+ use?: ClientUrlUse,
42
+ ) => ClientUrlItem & TypedRouteItem<TName, TPattern, unknown, TSearch>;
43
+
44
+ export type ClientLayoutFn = <const TItems extends ClientUrlItems>(
45
+ component: ComponentType,
46
+ children: () => TItems,
47
+ ) => ClientUrlItem & TypedLayoutItem<ExtractRoutes<TItems>>;
48
+
49
+ /**
50
+ * Arguments a clientUrls() revalidate() predicate receives. A client-computable
51
+ * subset of the server ShouldRevalidateFn args — the predicate RUNS IN THE
52
+ * BROWSER (it is declared in a "use client" module and never crosses the
53
+ * projection boundary); only its decision is sent to the server. There is no
54
+ * `context` — no server handler context exists where this executes.
55
+ */
56
+ export interface ClientRevalidateArgs {
57
+ /** Full URL of the page being navigated away from (current location). */
58
+ readonly currentUrl: URL;
59
+ /** Full URL of the navigation target (equals currentUrl for actions). */
60
+ readonly nextUrl: URL;
61
+ /** Route params of the held client route (definition-local match). */
62
+ readonly currentParams: Record<string, string>;
63
+ /** Route params for the navigation target (definition-local match). */
64
+ readonly nextParams: Record<string, string>;
65
+ /**
66
+ * The locked default decision for this loader, computed client-side with
67
+ * the same rules the server would apply: `true` on actions and when
68
+ * params/search changed, `false` otherwise. Return it for default behavior
69
+ * plus your own conditions.
70
+ */
71
+ readonly defaultShouldRevalidate: boolean;
72
+ /** True when this is a stale history-entry background revalidation. */
73
+ readonly stale: boolean;
74
+ /** True when revalidation is triggered by a server action. */
75
+ readonly isAction: boolean;
76
+ /** The triggering server action's id, when isAction. */
77
+ readonly actionId?: string;
78
+ }
79
+
80
+ export type ClientRevalidateFn = (args: ClientRevalidateArgs) => boolean;
81
+
82
+ export interface ClientUrlLoaderRecord {
83
+ readonly loader: LoaderDefinition<any, any>;
84
+ /** Client-run per-loader revalidation predicates; empty = locked defaults. */
85
+ readonly revalidate: readonly ClientRevalidateFn[];
86
+ /**
87
+ * loader(Def, { stream: "navigation" }): document renders await this loader
88
+ * before first flush (see {@link LoaderOptions}). Projected into the server
89
+ * tree, where the per-isSSR entry stamping applies — client navigations
90
+ * stream regardless.
91
+ */
92
+ readonly stream?: "navigation";
93
+ }
94
+
95
+ /**
96
+ * The data-only subset of TransitionConfig a clientUrls() route may declare:
97
+ * ViewTransition classes/name plus the boundary opt-out. The `when` gate is a
98
+ * server-executed predicate and stays in the server tree — it cannot cross the
99
+ * "use client" projection boundary.
100
+ */
101
+ export type ClientTransitionConfig = Pick<
102
+ TransitionConfig,
103
+ "enter" | "exit" | "update" | "share" | "default" | "name" | "viewTransition"
104
+ >;
105
+
106
+ export interface ClientUrlRouteRecord {
107
+ readonly id: string;
108
+ readonly pattern: string;
109
+ readonly name: string | undefined;
110
+ readonly options: Readonly<ClientPathOptions> | undefined;
111
+ readonly component: ComponentType;
112
+ readonly layouts: readonly ComponentType[];
113
+ readonly loaders: readonly ClientUrlLoaderRecord[];
114
+ readonly loading: ReactNode | undefined;
115
+ readonly transition: Readonly<ClientTransitionConfig> | undefined;
116
+ }
117
+
118
+ /**
119
+ * A restricted intercept declared inside clientUrls(). Compared to the server
120
+ * intercept() there is no `when` selector, no middleware, and the target must
121
+ * be a dot-local NAMED route in the same definition — every field is
122
+ * JSON-projectable, which is what makes the declaration legal in a
123
+ * "use client" module. `targetName` is stored bare (no leading dot).
124
+ */
125
+ export interface ClientUrlInterceptRecord {
126
+ readonly slotName: `@${string}`;
127
+ readonly targetName: string;
128
+ readonly component: ComponentType;
129
+ readonly loaders: readonly ClientUrlLoaderRecord[];
130
+ readonly loading: ReactNode | undefined;
131
+ }
132
+
133
+ export interface ClientUrlHelpers {
134
+ readonly path: ClientPathFn;
135
+ readonly layout: ClientLayoutFn;
136
+ /**
137
+ * Attach a projected loader. The optional use callback may contain
138
+ * revalidate() only — a CLIENT-RUN per-loader predicate; its decision (not
139
+ * the function) is sent with the revalidation request.
140
+ *
141
+ * Pass `{ stream: "navigation" }` to await this loader before first flush
142
+ * on DOCUMENT requests (see {@link LoaderOptions}) — the opt-in for loaders
143
+ * whose data, handle pushes, or thrown notFound()/redirect() must be in the
144
+ * SSR'd HTML. Per-loader: a dynamic sibling keeps streaming.
145
+ */
146
+ readonly loader: <TData>(
147
+ definition: LoaderDefinition<TData>,
148
+ optionsOrUse?: LoaderOptions | ClientUrlUse,
149
+ use?: ClientUrlUse,
150
+ ) => ClientUrlItem;
151
+ readonly loading: (component: ReactNode) => ClientUrlItem;
152
+ /**
153
+ * Per-loader revalidation predicate, valid inside a loader() use callback
154
+ * only. Runs IN THE BROWSER with client-computable args; return true to
155
+ * re-run the loader, false to keep held data. Absent predicates (and
156
+ * requests that carry no decisions: no-JS, PE, prefetch, document loads)
157
+ * follow the locked server defaults.
158
+ */
159
+ readonly revalidate: (fn: ClientRevalidateFn) => ClientUrlItem;
160
+ /**
161
+ * Declare an intercept for a named route in THIS definition. The target is
162
+ * dot-local (`.detail`); scoping is module-local — only navigations whose
163
+ * origin is inside this clientUrls() group render the intercept. `use` may
164
+ * contain loader() and loading() only.
165
+ */
166
+ readonly intercept: (
167
+ slotName: `@${string}`,
168
+ targetName: `.${string}`,
169
+ component: ComponentType,
170
+ use?: ClientUrlUse,
171
+ ) => ClientUrlItem;
172
+ /**
173
+ * Opt THIS route into transition-driven navigation: the canonical commit
174
+ * holds previous content instead of re-streaming the loading() fallback,
175
+ * and on experimental React the config's ViewTransition classes apply.
176
+ * Data-only — no `when` gate (server-executed; declare it in the server
177
+ * tree). Valid inside a path() use callback only.
178
+ */
179
+ readonly transition: (config: ClientTransitionConfig) => ClientUrlItem;
180
+ }
181
+
182
+ export type ClientUrlBuilder<TItems extends ClientUrlItems = ClientUrlItems> = (
183
+ helpers: ClientUrlHelpers,
184
+ ) => TItems;
185
+
186
+ export interface ClientUrlPatterns<
187
+ TRoutes extends Record<string, any> = Record<string, any>,
188
+ > {
189
+ readonly __brand: "client-urls";
190
+ readonly routes: readonly ClientUrlRouteRecord[];
191
+ readonly intercepts: readonly ClientUrlInterceptRecord[];
192
+ readonly [CLIENT_URL_PATTERNS_BRAND]: void;
193
+ readonly _routes?: TRoutes;
194
+ match(pathname: string): TrieMatchResult | null;
195
+ }
@@ -15,6 +15,7 @@ export {
15
15
  Outlet,
16
16
  ParallelOutlet,
17
17
  useOutlet,
18
+ type OutletState,
18
19
  useLoader,
19
20
  ErrorBoundary,
20
21
  type ErrorBoundaryProps,
@@ -31,6 +32,12 @@ export {
31
32
 
32
33
  export { createLoader } from "./route-definition.js";
33
34
 
35
+ export { clientUrls } from "./client-urls/client-urls.js";
36
+ export type {
37
+ ClientUrlPatterns,
38
+ ClientUrlRouteRecord,
39
+ } from "./client-urls/client-urls.js";
40
+
34
41
  // "use client" hooks the default ./client entry exports. They are client
35
42
  // references in the RSC graph, identical in kind to useHref/useReverse/
36
43
  // useHandle already forwarded below; forward them so the RSC client entry's
@@ -39,6 +46,11 @@ export { createLoader } from "./route-definition.js";
39
46
  export { useRouter } from "./browser/react/use-router.js";
40
47
  export { usePathname } from "./browser/react/use-pathname.js";
41
48
  export { useSearchParams } from "./browser/react/use-search-params.js";
49
+ export type {
50
+ SearchParamsInit,
51
+ SetSearchParams,
52
+ SetSearchParamsOptions,
53
+ } from "./browser/react/use-search-params.js";
42
54
  export { useParams } from "./browser/react/use-params.js";
43
55
  // CSP nonce for userland head-script components (analytics/GTM/inline init);
44
56
  // forwarded so the RSC client entry's hook surface matches the default entry.
package/src/client.tsx CHANGED
@@ -141,17 +141,41 @@ function useSlotSegment(
141
141
  * }
142
142
  * ```
143
143
  */
144
- export function Outlet({ name }: { name?: `@${string}` } = {}): ReactNode {
144
+ export function Outlet({
145
+ name,
146
+ fallback,
147
+ }: { name?: `@${string}`; fallback?: ReactNode } = {}): ReactNode {
145
148
  const context = useContext(OutletContext);
146
149
  const namedSegment = useSlotSegment(context, name);
147
150
 
148
151
  if (name) {
149
- return renderSlotContent(namedSegment);
152
+ const slot = renderSlotContent(namedSegment);
153
+ // EXPERIMENT (Outlet fallback): layout-declared pending UI for a named
154
+ // slot — an outer Suspense catching whatever the slot's content leaves
155
+ // unhandled.
156
+ return fallback !== undefined ? (
157
+ <Suspense fallback={fallback}>{slot}</Suspense>
158
+ ) : (
159
+ slot
160
+ );
150
161
  }
151
162
 
152
163
  // Default: render child content
153
164
  const content = context?.content ?? null;
154
165
 
166
+ // EXPERIMENT (Outlet fallback): the layout owns the outlet position, so it
167
+ // may declare the placeholder for pending child content right where it
168
+ // renders — a plain Suspense boundary, no DSL wiring, no segment-resolution
169
+ // threading. With read-site useLoader suspension this covers loading()'s
170
+ // roles by construction: document SSR streams the fallback then the
171
+ // content; PPR postpones unresolved readers here (the outlet becomes the
172
+ // hole); same-route re-renders are held before commit so the fallback does
173
+ // NOT flash; cross-route navs show it as destination feedback. An explicit
174
+ // prop wins over the child segment's loading().
175
+ if (fallback !== undefined) {
176
+ return <Suspense fallback={fallback}>{content}</Suspense>;
177
+ }
178
+
155
179
  // If this segment defines a loading component, wrap outlet content with Suspense
156
180
  // The loading component becomes the Suspense fallback, shown during streaming/navigation
157
181
  if (context?.loading) {
@@ -199,7 +223,7 @@ export function ParallelOutlet({ name }: { name: `@${string}` }): ReactNode {
199
223
  // internal component and is intentionally not part of the public ./client API.
200
224
 
201
225
  /**
202
- * Hook to access outlet content programmatically
226
+ * Hook to access outlet content and descendant pending state programmatically.
203
227
  *
204
228
  * Alternative to using <Outlet /> component. Useful when you need
205
229
  * direct access to the outlet content in your logic.
@@ -208,13 +232,21 @@ export function ParallelOutlet({ name }: { name: `@${string}` }): ReactNode {
208
232
  * ```tsx
209
233
  * function BlogLayout() {
210
234
  * const outlet = useOutlet();
211
- * return <div><h1>Blog</h1>{outlet}</div>;
235
+ * return <div aria-busy={outlet.pending}><h1>Blog</h1>{outlet.content}</div>;
212
236
  * }
213
237
  * ```
214
238
  */
215
- export function useOutlet(): ReactNode {
239
+ export interface OutletState {
240
+ readonly content: ReactNode;
241
+ readonly pending: boolean;
242
+ }
243
+
244
+ export function useOutlet(): OutletState {
216
245
  const context = useContext(OutletContext);
217
- return context?.content ?? null;
246
+ return {
247
+ content: context?.content ?? null,
248
+ pending: context?.pending ?? false,
249
+ };
218
250
  }
219
251
 
220
252
  export {
@@ -227,6 +259,12 @@ export {
227
259
  type UseLoaderOptions,
228
260
  } from "./use-loader.js";
229
261
 
262
+ export { clientUrls } from "./client-urls/client-urls.js";
263
+ export type {
264
+ ClientUrlPatterns,
265
+ ClientUrlRouteRecord,
266
+ } from "./client-urls/client-urls.js";
267
+
230
268
  /**
231
269
  * Props for the ErrorBoundary component
232
270
  */
@@ -339,6 +377,11 @@ export { useNavigation } from "./browser/react/use-navigation.js";
339
377
  export { useRouter } from "./browser/react/use-router.js";
340
378
  export { usePathname } from "./browser/react/use-pathname.js";
341
379
  export { useSearchParams } from "./browser/react/use-search-params.js";
380
+ export type {
381
+ SearchParamsInit,
382
+ SetSearchParams,
383
+ SetSearchParamsOptions,
384
+ } from "./browser/react/use-search-params.js";
342
385
  export { useParams } from "./browser/react/use-params.js";
343
386
  // CSP nonce for the active request, for userland components that inject their
344
387
  // own <script>/<style> into the document head (analytics, GTM, inline init).
@@ -1,5 +1,66 @@
1
1
  import type { ReactNode } from "react";
2
+ import { createElement } from "react";
2
3
  import { isLoaderDataResult } from "./types.js";
4
+ import { LoaderRedirect } from "./loader-redirect.js";
5
+
6
+ /**
7
+ * Markers for loader-thrown AUTHORITY SIGNALS (notFound()/redirect()) on the
8
+ * reconstructed read-site error. Siblings of LOADER_ERROR_FALLBACK, routed
9
+ * differently by StreamedLoaderErrorBoundary: NOT_FOUND renders the
10
+ * server-rendered not-found UI riding the envelope; REDIRECT triggers a
11
+ * client replace-navigation to the (server-resolved, same-origin-guarded)
12
+ * target. Signals are control flow, not failures — they take precedence over
13
+ * the plain error-fallback path.
14
+ *
15
+ * Only decodeLoaderEntry (the read-site path — client components, so the
16
+ * throw lands in the router-owned boundary; during document SSR, Fizz emits
17
+ * the Suspense fallback and replays the throw at hydration) throws these.
18
+ * decodeLoaderResults runs during the SERVER tree build on forceAwait/action
19
+ * lanes, where a throw would collapse the whole payload — it routes signals
20
+ * through the errorFallback slot instead (same visual: the slot replaces the
21
+ * children under OutletProvider).
22
+ */
23
+ export const LOADER_NOT_FOUND_FALLBACK: unique symbol = Symbol(
24
+ "rango.loaderNotFound",
25
+ );
26
+ export const LOADER_REDIRECT: unique symbol = Symbol("rango.loaderRedirect");
27
+
28
+ function throwSignal(result: any): void {
29
+ if (result.notFound === true) {
30
+ const err = new Error(result.error?.message ?? "Not found");
31
+ err.name = result.error?.name ?? "DataNotFoundError";
32
+ (err as any)[LOADER_NOT_FOUND_FALLBACK] = result.fallback ?? null;
33
+ throw err;
34
+ }
35
+ if (result.redirect) {
36
+ const err = new Error(result.error?.message ?? "Loader redirect");
37
+ err.name = "LoaderRedirect";
38
+ (err as any)[LOADER_REDIRECT] = result.redirect;
39
+ throw err;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Non-throwing signal resolution for the aggregate path. Returns the node to
45
+ * place in the errorFallback slot, or undefined when the result carries no
46
+ * signal.
47
+ */
48
+ function signalFallback(result: any): ReactNode | undefined {
49
+ if (result.notFound === true) {
50
+ return (result.fallback ?? null) as ReactNode;
51
+ }
52
+ if (result.redirect) {
53
+ const redirect = result.redirect as {
54
+ to: string;
55
+ state?: Record<string, unknown>;
56
+ };
57
+ return createElement(LoaderRedirect, {
58
+ to: redirect.to,
59
+ state: redirect.state,
60
+ });
61
+ }
62
+ return undefined;
63
+ }
3
64
 
4
65
  // Shared by segment-system (server) and LoaderResolver (client) so the
5
66
  // legacy/ok/error-fallback/throw decode of resolved loader values lives once.
@@ -25,6 +86,16 @@ export function decodeLoaderResults(
25
86
  continue;
26
87
  }
27
88
 
89
+ // Authority signals take the errorFallback slot (this decode runs during
90
+ // the server tree build on forceAwait/action lanes — throwing here would
91
+ // collapse the payload). A signal outranks a plain error fallback and
92
+ // stops the scan: notFound renders the server-rendered 404 UI, redirect
93
+ // mounts LoaderRedirect which navigates.
94
+ const signal = signalFallback(result);
95
+ if (signal !== undefined) {
96
+ return { loaderData, errorFallback: signal };
97
+ }
98
+
28
99
  // null/undefined is the producer's ONLY "no boundary found" sentinel
29
100
  // (loader-resolution.ts sets fallback: null for the no-boundary and the
30
101
  // fallback-render-threw cases). A matched boundary's rendered ReactNode can
@@ -50,3 +121,45 @@ export function decodeLoaderResults(
50
121
 
51
122
  return { loaderData, errorFallback };
52
123
  }
124
+
125
+ /**
126
+ * Marker property carrying the errorBoundary() fallback on a read-site loader
127
+ * error. decodeLoaderEntry attaches the pre-rendered boundary node (produced
128
+ * server-side by loader-resolution) to the thrown error; the router-owned
129
+ * StreamedLoaderErrorBoundary above the readers (segment-system wires it for
130
+ * every loader-bearing segment) catches by this marker and renders the
131
+ * fallback — restoring the build-time errorFallback-swap contract for
132
+ * streamed loaders. Errors without the marker rethrow to the app's boundaries.
133
+ */
134
+ export const LOADER_ERROR_FALLBACK: unique symbol = Symbol(
135
+ "rango.loaderErrorFallback",
136
+ );
137
+
138
+ /**
139
+ * Streaming useLoader: single-entry decode for read-site resolution. Mirrors
140
+ * decodeLoaderResults for one result. An error entry throws the reconstructed
141
+ * error (name/stack/code/cause preserved); when the entry carries an
142
+ * errorBoundary() fallback, the node rides the throw via
143
+ * LOADER_ERROR_FALLBACK for the boundary above the readers to render.
144
+ */
145
+ export function decodeLoaderEntry(result: any): any {
146
+ if (!isLoaderDataResult(result)) {
147
+ return result;
148
+ }
149
+ if (result.ok) {
150
+ return result.data;
151
+ }
152
+ throwSignal(result);
153
+ const info = result.error;
154
+ const err = new Error(
155
+ info.message,
156
+ info.cause !== undefined ? { cause: info.cause } : undefined,
157
+ );
158
+ if (info.name) err.name = info.name;
159
+ if (info.stack) err.stack = info.stack;
160
+ if (info.code !== undefined) (err as { code?: string }).code = info.code;
161
+ if (result.fallback != null) {
162
+ (err as any)[LOADER_ERROR_FALLBACK] = result.fallback;
163
+ }
164
+ throw err;
165
+ }
package/src/errors.ts CHANGED
@@ -62,6 +62,20 @@ export class DataNotFoundError extends Error {
62
62
  }
63
63
  }
64
64
 
65
+ // name fallback covers cross-realm errors (Vite dev dupes, RSC serialization)
66
+ // where instanceof fails — same rationale as isRouteNotFoundError above. Every
67
+ // notFound() origin (segment resolution, loader envelopes, fetchable loaders)
68
+ // must use this rather than a bare instanceof, or the same thrown signal is
69
+ // honored on one path and escapes as a 500 on another.
70
+ export function isDataNotFoundError(
71
+ error: unknown,
72
+ ): error is DataNotFoundError {
73
+ return (
74
+ error instanceof DataNotFoundError ||
75
+ (error instanceof Error && error.name === "DataNotFoundError")
76
+ );
77
+ }
78
+
65
79
  /**
66
80
  * Convenience function to throw a DataNotFoundError
67
81
  * Shorter syntax for common not-found scenarios
@@ -92,14 +92,21 @@ async function resolveValues(values: unknown[]): Promise<unknown[]> {
92
92
  export async function* resolvedHandleStream(
93
93
  handleStore: HandleStore,
94
94
  ): AsyncGenerator<HandleData, void, unknown> {
95
- // Drain stream() (NOT getData()) for the converged snapshot: stream() sets the
96
- // store's `completed` flag on seal+settle, and that flag is what makes a LATE
97
- // push throw LateHandlePushError — an async JSX subtree that suspended and later
98
- // calls ctx.use(Handle)(...) after collection. getData() never sets `completed`,
99
- // so the late push would silently land. Both wait for the same settle barrier, so
100
- // the final yielded value is identical; we just keep the late-push guard.
95
+ // Drain stream() (NOT getData()) for the converged snapshot: consuming a
96
+ // stream arms the store's late-push guard (LateHandlePushError for pushes
97
+ // after FULL settle — an async JSX subtree that suspended and later calls
98
+ // ctx.use(Handle)(...) after collection). getData() never arms it, so the
99
+ // late push would silently land.
100
+ //
101
+ // Scoped to the HANDLER barrier ("settled"), NOT full settle: both document
102
+ // consumers (ssr-root.tsx React.use, rsc-router.tsx pre-hydration drain)
103
+ // block on this generator's COMPLETION, so waiting for the auxiliary
104
+ // (loader) lane would hold SSR markup and hydration hostage to the slowest
105
+ // streaming loader. Loader pushes that beat the handler barrier are in this
106
+ // snapshot (the race); later ones ride metadata.handlesLate
107
+ // (handleStore.streamLate()), applied client-side post-hydration.
101
108
  let snapshot: HandleData = {};
102
- for await (const data of handleStore.stream()) {
109
+ for await (const data of handleStore.stream("settled")) {
103
110
  snapshot = data;
104
111
  }
105
112
  yield await resolveDeferredHandleValues(snapshot);
package/src/index.ts CHANGED
@@ -50,6 +50,7 @@ export type {
50
50
  LoaderDefinition,
51
51
  LoaderFn,
52
52
  LoaderContext,
53
+ LoaderOptions,
53
54
  FetchableLoaderOptions,
54
55
  LoadOptions,
55
56
  // Error boundary types
@@ -0,0 +1,64 @@
1
+ "use client";
2
+ import type { ReactNode } from "react";
3
+ import { useEffect } from "react";
4
+ import type { NavigateOptionsInternal } from "./browser/types.js";
5
+ import { useRouter } from "./browser/react/use-router.js";
6
+ import { validateExternalRedirect } from "./browser/validate-redirect-origin.js";
7
+ import { resolveSameOriginRedirect } from "./redirect-origin.js";
8
+
9
+ /**
10
+ * Executes a loader-thrown redirect() once mounted. The target was resolved
11
+ * through the soft-redirect same-origin rules server-side
12
+ * (wrapLoaderWithErrorHandling), so anything absolute AND cross-origin here is
13
+ * an explicit `{ external: true }` opt-in — those leave via location.replace
14
+ * (the SPA router cannot cross origins); everything else is a router
15
+ * replace-navigation. Renders nothing while the navigation commits.
16
+ *
17
+ * Own module (not route-content-wrapper.tsx) because BOTH consumers need it
18
+ * without a cycle: the StreamedLoaderErrorBoundary redirect branch (read-site
19
+ * throw path) and decodeLoaderResults (aggregate forceAwait/action path, which
20
+ * runs during the server tree build and cannot throw — it plants this element
21
+ * in the errorFallback slot instead).
22
+ */
23
+ export function LoaderRedirect({
24
+ to,
25
+ state,
26
+ }: {
27
+ to: string;
28
+ /**
29
+ * Resolved `redirect(url, { state })` record off the loader-result marker.
30
+ * Delivered as the redirect navigation's state (same application path as
31
+ * the ServerRedirect lane in navigation-bridge.ts: buildHistoryState
32
+ * spreads the `__rsc_ls_*` keys into the target entry). `_skipCache`
33
+ * matches that lane — a cached segment commit must not skip the re-render
34
+ * that lets useLocationState read the fresh entry.
35
+ */
36
+ state?: Record<string, unknown>;
37
+ }): ReactNode {
38
+ const router = useRouter();
39
+ useEffect(() => {
40
+ const origin = window.location.origin;
41
+ // Same-origin first: a target that resolves on-origin is a router
42
+ // navigation. Only when it does NOT is this the `{ external: true }`
43
+ // opt-in, which waives same-origin but NOT scheme safety — both branches
44
+ // go through the shared resolvers in redirect-origin.ts so this channel
45
+ // cannot drift from the fetch/action/document ones.
46
+ // Pure resolver (not validateRedirectOrigin) for the probe: an off-origin
47
+ // target here is the legitimate external opt-in, not a blocked redirect,
48
+ // so it must not log a "blocked" error on its way to the branch below.
49
+ const sameOrigin = resolveSameOriginRedirect(to, origin);
50
+ if (sameOrigin) {
51
+ const options: Omit<NavigateOptionsInternal, "replace"> | undefined =
52
+ state ? { state, _skipCache: true } : undefined;
53
+ void router.replace(sameOrigin, options);
54
+ return;
55
+ }
56
+ const externalUrl = validateExternalRedirect(to, origin);
57
+ if (externalUrl) {
58
+ window.location.replace(externalUrl);
59
+ }
60
+ // Redirect targets are request-scoped constants; re-running on identity
61
+ // change only (StrictMode double-invoke is idempotent — same target).
62
+ }, [to, state, router]);
63
+ return null;
64
+ }
@@ -3,9 +3,21 @@ import type { ResolvedSegment } from "./types";
3
3
 
4
4
  export interface OutletContextValue {
5
5
  content: ReactNode;
6
+ /** Unresolved client-route work owned by descendants of this outlet. */
7
+ pending?: boolean;
6
8
  parallel?: ResolvedSegment[];
7
9
  segment?: ResolvedSegment;
8
10
  loaderData?: Record<string, any>;
11
+ /**
12
+ * SPIKE (streaming useLoader): per-loader UNDECODED results keyed by loader
13
+ * $$id. A value may be a pending Promise (loader still streaming) or an
14
+ * already-settled result entry. useLoader `use()`es a pending value at the
15
+ * read site — implicit suspension to the nearest consumer Suspense boundary —
16
+ * and decodes via decodeLoaderEntry. Populated instead of `loaderData` on
17
+ * streaming lanes (plain navs, document SSR); forceAwait/action lanes keep
18
+ * the resolved `loaderData` record.
19
+ */
20
+ loaderStreams?: Record<string, unknown>;
9
21
  parent?: OutletContextValue | null;
10
22
  /** Loading component for Suspense fallback (from segment's loading() definition) */
11
23
  loading?: ReactNode;
@@ -12,12 +12,16 @@ export function OutletProvider({
12
12
  parallel,
13
13
  segment,
14
14
  loaderData,
15
+ loaderStreams,
16
+ pending = false,
15
17
  children,
16
18
  }: {
17
19
  content: ReactNode;
18
20
  parallel?: ResolvedSegment[];
19
21
  segment?: ResolvedSegment;
20
22
  loaderData?: Record<string, any>;
23
+ loaderStreams?: Record<string, unknown>;
24
+ pending?: boolean;
21
25
  children: ReactNode;
22
26
  }): ReactNode {
23
27
  // Get parent context to enable walking up the chain for loader lookups
@@ -29,10 +33,20 @@ export function OutletProvider({
29
33
  parallel,
30
34
  segment,
31
35
  loaderData,
36
+ loaderStreams,
37
+ pending,
32
38
  parent: parentContext,
33
39
  loading: segment?.loading,
34
40
  }),
35
- [content, parallel, segment, loaderData, parentContext],
41
+ [
42
+ content,
43
+ parallel,
44
+ segment,
45
+ loaderData,
46
+ loaderStreams,
47
+ pending,
48
+ parentContext,
49
+ ],
36
50
  );
37
51
 
38
52
  return (