@rangojs/router 0.0.0-experimental.dfdb0387 → 0.0.0-experimental.ede38110
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.
- package/dist/vite/index.js +16 -8
- package/package.json +3 -3
- package/skills/handler-use/SKILL.md +362 -0
- package/skills/intercept/SKILL.md +20 -0
- package/skills/layout/SKILL.md +22 -0
- package/skills/middleware/SKILL.md +32 -3
- package/skills/migrate-nextjs/SKILL.md +560 -0
- package/skills/migrate-react-router/SKILL.md +764 -0
- package/skills/parallel/SKILL.md +59 -0
- package/skills/rango/SKILL.md +24 -22
- package/skills/route/SKILL.md +24 -0
- package/src/browser/navigation-bridge.ts +21 -2
- package/src/browser/partial-update.ts +9 -2
- package/src/browser/prefetch/fetch.ts +20 -4
- package/src/browser/react/use-navigation.ts +11 -10
- package/src/browser/segment-reconciler.ts +10 -14
- package/src/build/route-trie.ts +50 -24
- package/src/client.tsx +82 -174
- package/src/index.ts +37 -9
- package/src/reverse.ts +4 -1
- package/src/route-definition/dsl-helpers.ts +159 -20
- package/src/route-definition/helpers-types.ts +57 -13
- package/src/route-types.ts +7 -0
- package/src/router/handler-context.ts +4 -1
- package/src/router/lazy-includes.ts +5 -5
- package/src/router/manifest.ts +22 -13
- package/src/router/match-middleware/cache-lookup.ts +2 -1
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +11 -61
- package/src/server/context.ts +26 -3
- package/src/types/route-entry.ts +11 -0
- package/src/types/segments.ts +0 -1
- package/src/urls/include-helper.ts +24 -14
- package/src/urls/path-helper-types.ts +30 -4
- package/src/vite/utils/prerender-utils.ts +20 -6
package/src/client.tsx
CHANGED
|
@@ -21,6 +21,83 @@ import {
|
|
|
21
21
|
} from "./route-content-wrapper.js";
|
|
22
22
|
import { OutletProvider } from "./outlet-provider.js";
|
|
23
23
|
import { MountContextProvider } from "./browser/react/mount-context.js";
|
|
24
|
+
import { getMemoizedContentPromise } from "./segment-content-promise.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Render the content for a named parallel/intercept slot segment.
|
|
28
|
+
*
|
|
29
|
+
* Shared by Outlet (with `name` prop) and ParallelOutlet — both resolve a
|
|
30
|
+
* segment from context.parallel by slot name and then render it through the
|
|
31
|
+
* same layout/loader/mountPath wrapping pipeline.
|
|
32
|
+
*/
|
|
33
|
+
function renderSlotContent(segment: ResolvedSegment | null): ReactNode {
|
|
34
|
+
if (!segment) return null;
|
|
35
|
+
|
|
36
|
+
const content: ReactNode =
|
|
37
|
+
segment.loading || segment.component instanceof Promise ? (
|
|
38
|
+
<RouteContentWrapper
|
|
39
|
+
content={getMemoizedContentPromise(segment.component)}
|
|
40
|
+
fallback={segment.loading}
|
|
41
|
+
segmentId={segment.id}
|
|
42
|
+
/>
|
|
43
|
+
) : (
|
|
44
|
+
(segment.component ?? null)
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const hasOwnLoaders = !!(segment.loaderDataPromise && segment.loaderIds);
|
|
48
|
+
const loaderWrapped = hasOwnLoaders ? (
|
|
49
|
+
<LoaderBoundary
|
|
50
|
+
loaderDataPromise={segment.loaderDataPromise!}
|
|
51
|
+
loaderIds={segment.loaderIds!}
|
|
52
|
+
fallback={segment.loading}
|
|
53
|
+
outletKey={segment.id + "-loader"}
|
|
54
|
+
outletContent={null}
|
|
55
|
+
segment={segment}
|
|
56
|
+
>
|
|
57
|
+
{content}
|
|
58
|
+
</LoaderBoundary>
|
|
59
|
+
) : null;
|
|
60
|
+
|
|
61
|
+
let result: ReactNode;
|
|
62
|
+
if (segment.layout) {
|
|
63
|
+
// Layout renders immediately; if loaders exist, the LoaderBoundary becomes
|
|
64
|
+
// the outlet content so layout's <Outlet /> suspends until loaders resolve.
|
|
65
|
+
result = (
|
|
66
|
+
<OutletProvider
|
|
67
|
+
content={hasOwnLoaders ? loaderWrapped : content}
|
|
68
|
+
segment={segment}
|
|
69
|
+
>
|
|
70
|
+
{segment.layout}
|
|
71
|
+
</OutletProvider>
|
|
72
|
+
);
|
|
73
|
+
} else if (hasOwnLoaders) {
|
|
74
|
+
// No layout but has loaders — wrap content with LoaderBoundary for useLoader context.
|
|
75
|
+
// Common for intercept routes that use useLoader without a custom layout.
|
|
76
|
+
result = loaderWrapped;
|
|
77
|
+
} else {
|
|
78
|
+
result = content;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (segment.mountPath) {
|
|
82
|
+
return (
|
|
83
|
+
<MountContextProvider value={segment.mountPath}>
|
|
84
|
+
{result}
|
|
85
|
+
</MountContextProvider>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function useSlotSegment(
|
|
93
|
+
context: OutletContextValue | null,
|
|
94
|
+
name: `@${string}` | undefined,
|
|
95
|
+
): ResolvedSegment | null {
|
|
96
|
+
return useMemo(() => {
|
|
97
|
+
if (!name || !context?.parallel) return null;
|
|
98
|
+
return context.parallel.find((seg) => seg.slot === name) ?? null;
|
|
99
|
+
}, [context, name]);
|
|
100
|
+
}
|
|
24
101
|
|
|
25
102
|
/**
|
|
26
103
|
* Outlet component - renders child content in layouts
|
|
@@ -61,95 +138,10 @@ import { MountContextProvider } from "./browser/react/mount-context.js";
|
|
|
61
138
|
*/
|
|
62
139
|
export function Outlet({ name }: { name?: `@${string}` } = {}): ReactNode {
|
|
63
140
|
const context = useContext(OutletContext);
|
|
141
|
+
const namedSegment = useSlotSegment(context, name);
|
|
64
142
|
|
|
65
|
-
// If name provided, render parallel/intercept content for that slot
|
|
66
143
|
if (name) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (!segment) return null;
|
|
70
|
-
|
|
71
|
-
// Determine the content to render
|
|
72
|
-
let content: ReactNode;
|
|
73
|
-
if (segment.loading || segment.component instanceof Promise) {
|
|
74
|
-
// Use RouteContentWrapper to handle Suspense wrapping properly
|
|
75
|
-
content = (
|
|
76
|
-
<RouteContentWrapper
|
|
77
|
-
content={
|
|
78
|
-
segment.component instanceof Promise
|
|
79
|
-
? segment.component
|
|
80
|
-
: Promise.resolve(segment.component)
|
|
81
|
-
}
|
|
82
|
-
fallback={segment.loading}
|
|
83
|
-
segmentId={segment.id}
|
|
84
|
-
/>
|
|
85
|
-
);
|
|
86
|
-
} else {
|
|
87
|
-
content = segment.component ?? null;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
let result: ReactNode;
|
|
91
|
-
|
|
92
|
-
// If segment has a layout, wrap appropriately
|
|
93
|
-
if (segment.layout) {
|
|
94
|
-
// Check if this segment has loaders that need streaming
|
|
95
|
-
// The layout renders immediately, LoaderBoundary becomes the outlet content
|
|
96
|
-
// When layout renders <Outlet />, it gets the LoaderBoundary which suspends
|
|
97
|
-
if (segment.loaderDataPromise && segment.loaderIds) {
|
|
98
|
-
const loaderAwareContent = (
|
|
99
|
-
<LoaderBoundary
|
|
100
|
-
loaderDataPromise={segment.loaderDataPromise}
|
|
101
|
-
loaderIds={segment.loaderIds}
|
|
102
|
-
fallback={segment.loading}
|
|
103
|
-
outletKey={segment.id + "-loader"}
|
|
104
|
-
outletContent={null}
|
|
105
|
-
segment={segment}
|
|
106
|
-
>
|
|
107
|
-
{content}
|
|
108
|
-
</LoaderBoundary>
|
|
109
|
-
);
|
|
110
|
-
|
|
111
|
-
result = (
|
|
112
|
-
<OutletProvider content={loaderAwareContent} segment={segment}>
|
|
113
|
-
{segment.layout}
|
|
114
|
-
</OutletProvider>
|
|
115
|
-
);
|
|
116
|
-
} else {
|
|
117
|
-
// No loaders - wrap in OutletProvider so layout can use <Outlet />
|
|
118
|
-
result = (
|
|
119
|
-
<OutletProvider content={content} segment={segment}>
|
|
120
|
-
{segment.layout}
|
|
121
|
-
</OutletProvider>
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
} else if (segment.loaderDataPromise && segment.loaderIds) {
|
|
125
|
-
// No layout but has loaders - wrap content with LoaderBoundary for useLoader context
|
|
126
|
-
// This is common for intercept routes that use useLoader without a custom layout
|
|
127
|
-
result = (
|
|
128
|
-
<LoaderBoundary
|
|
129
|
-
loaderDataPromise={segment.loaderDataPromise}
|
|
130
|
-
loaderIds={segment.loaderIds}
|
|
131
|
-
fallback={segment.loading}
|
|
132
|
-
outletKey={segment.id + "-loader"}
|
|
133
|
-
outletContent={null}
|
|
134
|
-
segment={segment}
|
|
135
|
-
>
|
|
136
|
-
{content}
|
|
137
|
-
</LoaderBoundary>
|
|
138
|
-
);
|
|
139
|
-
} else {
|
|
140
|
-
result = content;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Wrap with MountContextProvider for include() scoped parallel/intercept slots
|
|
144
|
-
if (segment.mountPath) {
|
|
145
|
-
return (
|
|
146
|
-
<MountContextProvider value={segment.mountPath}>
|
|
147
|
-
{result}
|
|
148
|
-
</MountContextProvider>
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
return result;
|
|
144
|
+
return renderSlotContent(namedSegment);
|
|
153
145
|
}
|
|
154
146
|
|
|
155
147
|
// Default: render child content
|
|
@@ -163,6 +155,7 @@ export function Outlet({ name }: { name?: `@${string}` } = {}): ReactNode {
|
|
|
163
155
|
|
|
164
156
|
return content;
|
|
165
157
|
}
|
|
158
|
+
|
|
166
159
|
/**
|
|
167
160
|
* ParallelOutlet component - renders content for a named parallel slot
|
|
168
161
|
*
|
|
@@ -187,94 +180,9 @@ export function Outlet({ name }: { name?: `@${string}` } = {}): ReactNode {
|
|
|
187
180
|
*/
|
|
188
181
|
export function ParallelOutlet({ name }: { name: `@${string}` }): ReactNode {
|
|
189
182
|
const context = useContext(OutletContext);
|
|
190
|
-
const segment =
|
|
191
|
-
if (!context?.parallel) return null;
|
|
192
|
-
return context.parallel.find((seg) => seg.slot === name) ?? null;
|
|
193
|
-
}, [context, name]);
|
|
194
|
-
|
|
195
|
-
if (!segment) return null;
|
|
196
|
-
|
|
197
|
-
// Determine the content to render
|
|
198
|
-
let content: ReactNode;
|
|
199
|
-
if (segment.loading || segment.component instanceof Promise) {
|
|
200
|
-
// Use RouteContentWrapper to handle Suspense wrapping properly
|
|
201
|
-
content = (
|
|
202
|
-
<RouteContentWrapper
|
|
203
|
-
content={
|
|
204
|
-
segment.component instanceof Promise
|
|
205
|
-
? segment.component
|
|
206
|
-
: Promise.resolve(segment.component)
|
|
207
|
-
}
|
|
208
|
-
fallback={segment.loading}
|
|
209
|
-
segmentId={segment.id}
|
|
210
|
-
/>
|
|
211
|
-
);
|
|
212
|
-
} else {
|
|
213
|
-
content = segment.component ?? null;
|
|
214
|
-
}
|
|
183
|
+
const segment = useSlotSegment(context, name);
|
|
215
184
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
// If segment has a layout, wrap appropriately
|
|
219
|
-
if (segment.layout) {
|
|
220
|
-
// Check if this segment has loaders that need streaming
|
|
221
|
-
// The layout renders immediately, LoaderBoundary becomes the outlet content
|
|
222
|
-
if (segment.loaderDataPromise && segment.loaderIds) {
|
|
223
|
-
const loaderAwareContent = (
|
|
224
|
-
<LoaderBoundary
|
|
225
|
-
loaderDataPromise={segment.loaderDataPromise}
|
|
226
|
-
loaderIds={segment.loaderIds}
|
|
227
|
-
fallback={segment.loading}
|
|
228
|
-
outletKey={segment.id + "-loader"}
|
|
229
|
-
outletContent={null}
|
|
230
|
-
segment={segment}
|
|
231
|
-
>
|
|
232
|
-
{content}
|
|
233
|
-
</LoaderBoundary>
|
|
234
|
-
);
|
|
235
|
-
|
|
236
|
-
result = (
|
|
237
|
-
<OutletProvider content={loaderAwareContent} segment={segment}>
|
|
238
|
-
{segment.layout}
|
|
239
|
-
</OutletProvider>
|
|
240
|
-
);
|
|
241
|
-
} else {
|
|
242
|
-
// No loaders - wrap in OutletProvider so layout can use <Outlet />
|
|
243
|
-
result = (
|
|
244
|
-
<OutletProvider content={content} segment={segment}>
|
|
245
|
-
{segment.layout}
|
|
246
|
-
</OutletProvider>
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
} else if (segment.loaderDataPromise && segment.loaderIds) {
|
|
250
|
-
// No layout but has loaders - wrap content with LoaderBoundary for useLoader context
|
|
251
|
-
// This is common for intercept routes that use useLoader without a custom layout
|
|
252
|
-
result = (
|
|
253
|
-
<LoaderBoundary
|
|
254
|
-
loaderDataPromise={segment.loaderDataPromise}
|
|
255
|
-
loaderIds={segment.loaderIds}
|
|
256
|
-
fallback={segment.loading}
|
|
257
|
-
outletKey={segment.id + "-loader"}
|
|
258
|
-
outletContent={null}
|
|
259
|
-
segment={segment}
|
|
260
|
-
>
|
|
261
|
-
{content}
|
|
262
|
-
</LoaderBoundary>
|
|
263
|
-
);
|
|
264
|
-
} else {
|
|
265
|
-
result = content;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Wrap with MountContextProvider for include() scoped parallel/intercept slots
|
|
269
|
-
if (segment.mountPath) {
|
|
270
|
-
return (
|
|
271
|
-
<MountContextProvider value={segment.mountPath}>
|
|
272
|
-
{result}
|
|
273
|
-
</MountContextProvider>
|
|
274
|
-
);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
return result;
|
|
185
|
+
return renderSlotContent(segment);
|
|
278
186
|
}
|
|
279
187
|
|
|
280
188
|
// OutletProvider is defined in outlet-provider.tsx to break a circular
|
package/src/index.ts
CHANGED
|
@@ -147,24 +147,52 @@ export { createVar, type ContextVar } from "./context-var.js";
|
|
|
147
147
|
export { nonce } from "./rsc/nonce.js";
|
|
148
148
|
|
|
149
149
|
/**
|
|
150
|
-
*
|
|
150
|
+
* SSR/client stub for server-only `Prerender` function.
|
|
151
|
+
*
|
|
152
|
+
* Returns a lightweight stub object instead of throwing so that the
|
|
153
|
+
* production SSR build can safely bundle the RSC entry chunk — the SSR
|
|
154
|
+
* bundler resolves `@rangojs/router` to this (SSR) entry, so Prerender
|
|
155
|
+
* calls in RSC code must not crash at module-evaluation time.
|
|
151
156
|
*/
|
|
152
|
-
export function Prerender(
|
|
153
|
-
|
|
157
|
+
export function Prerender(
|
|
158
|
+
_handler?: any,
|
|
159
|
+
_optionsOrId?: any,
|
|
160
|
+
__injectedId?: string,
|
|
161
|
+
): any {
|
|
162
|
+
const id =
|
|
163
|
+
typeof _optionsOrId === "string" ? _optionsOrId : __injectedId || "";
|
|
164
|
+
return { __brand: "prerenderHandler" as const, $$id: id };
|
|
154
165
|
}
|
|
155
166
|
|
|
156
167
|
/**
|
|
157
|
-
*
|
|
168
|
+
* SSR/client stub for server-only `Passthrough` function.
|
|
158
169
|
*/
|
|
159
|
-
export function Passthrough(
|
|
160
|
-
|
|
170
|
+
export function Passthrough(
|
|
171
|
+
_handler?: any,
|
|
172
|
+
_optionsOrId?: any,
|
|
173
|
+
__injectedId?: string,
|
|
174
|
+
): any {
|
|
175
|
+
const id =
|
|
176
|
+
typeof _optionsOrId === "string" ? _optionsOrId : __injectedId || "";
|
|
177
|
+
return { __brand: "passthroughHandler" as const, $$id: id };
|
|
161
178
|
}
|
|
162
179
|
|
|
163
180
|
/**
|
|
164
|
-
*
|
|
181
|
+
* SSR/client stub for server-only `Static` function.
|
|
182
|
+
*
|
|
183
|
+
* Returns a lightweight stub object instead of throwing so that the
|
|
184
|
+
* production SSR build can safely bundle the RSC entry chunk — the SSR
|
|
185
|
+
* bundler resolves `@rangojs/router` to this (SSR) entry, so Static
|
|
186
|
+
* calls in RSC code must not crash at module-evaluation time.
|
|
165
187
|
*/
|
|
166
|
-
export function Static(
|
|
167
|
-
|
|
188
|
+
export function Static(
|
|
189
|
+
_handler?: any,
|
|
190
|
+
_optionsOrId?: any,
|
|
191
|
+
__injectedId?: string,
|
|
192
|
+
): any {
|
|
193
|
+
const id =
|
|
194
|
+
typeof _optionsOrId === "string" ? _optionsOrId : __injectedId || "";
|
|
195
|
+
return { __brand: "staticHandler" as const, $$id: id };
|
|
168
196
|
}
|
|
169
197
|
|
|
170
198
|
/**
|
package/src/reverse.ts
CHANGED
|
@@ -311,7 +311,10 @@ export function createReverse<TRoutes extends Record<string, string>>(
|
|
|
311
311
|
/:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(\?)/g,
|
|
312
312
|
(_, key, _constraint, optional) => {
|
|
313
313
|
const value = params[key];
|
|
314
|
-
|
|
314
|
+
// Empty string is treated as omitted — the trie matcher fills
|
|
315
|
+
// unmatched optional params with "" (not undefined), so reverse
|
|
316
|
+
// must collapse those segments instead of leaving empty slots.
|
|
317
|
+
if (value === undefined || value === "") {
|
|
315
318
|
hadOmittedOptional = true;
|
|
316
319
|
return "";
|
|
317
320
|
}
|
|
@@ -55,6 +55,9 @@ const hasRoutesInItem = (item: AllUseItems): boolean => {
|
|
|
55
55
|
if (item.type === "layout" && item.uses) {
|
|
56
56
|
return item.uses.some((child) => hasRoutesInItem(child));
|
|
57
57
|
}
|
|
58
|
+
if (item.type === "middleware" && item.uses) {
|
|
59
|
+
return item.uses.some((child) => hasRoutesInItem(child));
|
|
60
|
+
}
|
|
58
61
|
return false;
|
|
59
62
|
};
|
|
60
63
|
|
|
@@ -353,10 +356,37 @@ const cache: RouteHelpers<any, any>["cache"] = (
|
|
|
353
356
|
return { name: namespace, type: "cache", uses: result } as CacheItem;
|
|
354
357
|
};
|
|
355
358
|
|
|
356
|
-
const middleware: RouteHelpers<any, any>["middleware"] = (...
|
|
359
|
+
const middleware: RouteHelpers<any, any>["middleware"] = (...args: any[]) => {
|
|
360
|
+
// Four call forms:
|
|
361
|
+
// middleware(fn) — single fn, sibling
|
|
362
|
+
// middleware(fn, () => [...]) — single fn, wrapping
|
|
363
|
+
// middleware([fn1, fn2]) — array, sibling
|
|
364
|
+
// middleware([fn1, fn2], () => [...]) — array, wrapping
|
|
365
|
+
const isArray = Array.isArray(args[0]);
|
|
366
|
+
|
|
367
|
+
// Reject the removed variadic form before executing anything.
|
|
368
|
+
// middleware(fn1, fn2, fn3) — 3+ args, always wrong.
|
|
369
|
+
// middleware(fn1, fn2) where fn2 is a middleware fn (length >= 1), not a
|
|
370
|
+
// children callback (length === 0) — legacy two-fn form, reject early.
|
|
371
|
+
if (
|
|
372
|
+
args.length > 2 ||
|
|
373
|
+
(!isArray &&
|
|
374
|
+
args.length === 2 &&
|
|
375
|
+
typeof args[1] === "function" &&
|
|
376
|
+
args[1].length > 0)
|
|
377
|
+
) {
|
|
378
|
+
throw new Error(
|
|
379
|
+
"middleware() no longer accepts variadic arguments. " +
|
|
380
|
+
"Use middleware([fn1, fn2, ...]) instead of middleware(fn1, fn2, ...).",
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const fns: MiddlewareFn<any>[] = isArray ? args[0] : [args[0]];
|
|
385
|
+
const children: (() => any[]) | undefined =
|
|
386
|
+
typeof args[1] === "function" ? args[1] : undefined;
|
|
387
|
+
|
|
357
388
|
// Prevent "use cache" functions from being used as middleware.
|
|
358
|
-
|
|
359
|
-
for (const f of fn) {
|
|
389
|
+
for (const f of fns) {
|
|
360
390
|
if (isCachedFunction(f)) {
|
|
361
391
|
throw new Error(
|
|
362
392
|
`A "use cache" function cannot be used as middleware. ` +
|
|
@@ -367,17 +397,80 @@ const middleware: RouteHelpers<any, any>["middleware"] = (...fn) => {
|
|
|
367
397
|
}
|
|
368
398
|
}
|
|
369
399
|
|
|
370
|
-
const
|
|
400
|
+
const store = getContext();
|
|
401
|
+
const ctx = store.getStore();
|
|
371
402
|
if (!ctx) throw new Error("middleware() must be called inside map()");
|
|
372
403
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
404
|
+
if (!children) {
|
|
405
|
+
// Sibling mode: attach to parent entry
|
|
406
|
+
const parent = ctx.parent;
|
|
407
|
+
if (!parent || !("middleware" in parent)) {
|
|
408
|
+
invariant(false, "No parent entry available for middleware()");
|
|
409
|
+
}
|
|
410
|
+
const name = `$${store.getNextIndex("middleware")}`;
|
|
411
|
+
parent.middleware.push(...fns);
|
|
412
|
+
return { name, type: "middleware" } as MiddlewareItem;
|
|
377
413
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
414
|
+
|
|
415
|
+
// Wrapping mode: create a transparent layout that carries the middleware
|
|
416
|
+
const mwIndex = store.getNextIndex("middleware");
|
|
417
|
+
const namespace = `${ctx.namespace}.${mwIndex}`;
|
|
418
|
+
|
|
419
|
+
const urlPrefix = getUrlPrefix();
|
|
420
|
+
const entry = {
|
|
421
|
+
id: namespace,
|
|
422
|
+
shortCode: store.getShortCode("layout"),
|
|
423
|
+
type: "layout",
|
|
424
|
+
parent: ctx.parent,
|
|
425
|
+
handler: RootLayout,
|
|
426
|
+
loading: undefined,
|
|
427
|
+
middleware: [...fns],
|
|
428
|
+
revalidate: [],
|
|
429
|
+
errorBoundary: [],
|
|
430
|
+
notFoundBoundary: [],
|
|
431
|
+
layout: [],
|
|
432
|
+
parallel: {},
|
|
433
|
+
intercept: [],
|
|
434
|
+
loader: [],
|
|
435
|
+
...(urlPrefix ? { mountPath: urlPrefix } : {}),
|
|
436
|
+
} as EntryData;
|
|
437
|
+
|
|
438
|
+
// Run children callback. If the second arg was actually a middleware fn
|
|
439
|
+
// (old variadic form: middleware(mw1, mw2)), this will return a non-array
|
|
440
|
+
// and the invariant below gives a clear migration error.
|
|
441
|
+
const rawResult = store.run(namespace, entry, children);
|
|
442
|
+
|
|
443
|
+
invariant(
|
|
444
|
+
Array.isArray(rawResult),
|
|
445
|
+
"middleware(fn, children) expects the second argument to return an array of use items. " +
|
|
446
|
+
"To pass multiple middleware, use middleware([fn1, fn2]).",
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
const result = rawResult.flat(3);
|
|
450
|
+
|
|
451
|
+
invariant(
|
|
452
|
+
result.every((item: any) => isValidUseItem(item)),
|
|
453
|
+
`middleware() children callback must return an array of use items [${namespace}]`,
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
const hasRoutes =
|
|
457
|
+
result &&
|
|
458
|
+
Array.isArray(result) &&
|
|
459
|
+
result.some((item) => item != null && hasRoutesInItem(item));
|
|
460
|
+
|
|
461
|
+
if (!hasRoutes) {
|
|
462
|
+
const parent = ctx.parent;
|
|
463
|
+
if (parent && "layout" in parent) {
|
|
464
|
+
entry.parent = null;
|
|
465
|
+
parent.layout.push(entry);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
name: namespace,
|
|
471
|
+
type: "middleware",
|
|
472
|
+
uses: result,
|
|
473
|
+
} as MiddlewareItem;
|
|
381
474
|
};
|
|
382
475
|
|
|
383
476
|
const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
|
|
@@ -398,13 +491,25 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
|
|
|
398
491
|
|
|
399
492
|
const namespace = `${ctx.namespace}.$${store.getNextIndex("parallel")}`;
|
|
400
493
|
|
|
401
|
-
// Unwrap
|
|
494
|
+
// Unwrap slot values. A slot value can be:
|
|
495
|
+
// - a Handler / ReactNode (legacy form)
|
|
496
|
+
// - a Static() definition (build-time only)
|
|
497
|
+
// - a slot descriptor `{ handler, use? }` for slot-local overrides
|
|
498
|
+
// The descriptor's `use` runs after the broadcast `use` for that slot,
|
|
499
|
+
// so single-assignment items like `loading()` placed there win without
|
|
500
|
+
// affecting siblings.
|
|
402
501
|
const unwrappedSlots: Record<string, any> = {};
|
|
502
|
+
const slotLocalUses: Record<string, (() => any[]) | undefined> = {};
|
|
403
503
|
let hasStaticSlot = false;
|
|
404
504
|
const staticSlotIds: Record<string, string> = {};
|
|
405
|
-
for (const [slotName,
|
|
505
|
+
for (const [slotName, rawSlot] of Object.entries(
|
|
406
506
|
slots as Record<string, any>,
|
|
407
507
|
)) {
|
|
508
|
+
let slotHandler: any = rawSlot;
|
|
509
|
+
if (isSlotDescriptor(rawSlot)) {
|
|
510
|
+
slotHandler = rawSlot.handler;
|
|
511
|
+
slotLocalUses[slotName] = rawSlot.use;
|
|
512
|
+
}
|
|
408
513
|
if (isStaticHandler(slotHandler)) {
|
|
409
514
|
hasStaticSlot = true;
|
|
410
515
|
unwrappedSlots[slotName] = slotHandler.handler;
|
|
@@ -471,13 +576,25 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
|
|
|
471
576
|
}),
|
|
472
577
|
} satisfies EntryData;
|
|
473
578
|
|
|
474
|
-
// Per-slot
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
579
|
+
// Per-slot merge order (narrowest-scope-wins for single-assignment items
|
|
580
|
+
// like loading()):
|
|
581
|
+
// 1. handler.use — defaults baked into the handler
|
|
582
|
+
// 2. shared `use` — broadcast at the parallel() call site
|
|
583
|
+
// 3. slot-local `use` — per-slot override via `{ handler, use }` descriptor
|
|
584
|
+
// Items that accumulate (loader, middleware, revalidate, …) compose
|
|
585
|
+
// across all three layers regardless of order.
|
|
586
|
+
const rawSlot = (slots as Record<string, any>)[slotName];
|
|
587
|
+
const slotHandlerForUse = isSlotDescriptor(rawSlot)
|
|
588
|
+
? rawSlot.handler
|
|
589
|
+
: rawSlot;
|
|
590
|
+
const slotHandlerUse = resolveHandlerUse(slotHandlerForUse);
|
|
591
|
+
const slotLocalUse = slotLocalUses[slotName];
|
|
592
|
+
const explicitUse = combineExplicitUses(use, slotLocalUse);
|
|
593
|
+
const slotMergedUse = mergeHandlerUse(
|
|
594
|
+
slotHandlerUse,
|
|
595
|
+
explicitUse,
|
|
596
|
+
"parallel",
|
|
597
|
+
);
|
|
481
598
|
if (slotMergedUse) {
|
|
482
599
|
const result = store.run(namespace, slotEntry, slotMergedUse)?.flat(3);
|
|
483
600
|
invariant(
|
|
@@ -491,6 +608,28 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
|
|
|
491
608
|
return { name: namespace, type: "parallel" } as ParallelItem;
|
|
492
609
|
};
|
|
493
610
|
|
|
611
|
+
function isSlotDescriptor(
|
|
612
|
+
value: unknown,
|
|
613
|
+
): value is { handler: unknown; use?: () => any[] } {
|
|
614
|
+
return (
|
|
615
|
+
typeof value === "object" &&
|
|
616
|
+
value !== null &&
|
|
617
|
+
!("__brand" in value) &&
|
|
618
|
+
"handler" in value &&
|
|
619
|
+
typeof (value as any).handler !== "undefined"
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function combineExplicitUses(
|
|
624
|
+
sharedUse: (() => any[]) | undefined,
|
|
625
|
+
slotLocalUse: (() => any[]) | undefined,
|
|
626
|
+
): (() => any[]) | undefined {
|
|
627
|
+
if (!sharedUse && !slotLocalUse) return undefined;
|
|
628
|
+
if (!slotLocalUse) return sharedUse;
|
|
629
|
+
if (!sharedUse) return slotLocalUse;
|
|
630
|
+
return () => [...sharedUse(), ...slotLocalUse()];
|
|
631
|
+
}
|
|
632
|
+
|
|
494
633
|
/**
|
|
495
634
|
* Intercept helper - defines an intercepting route for soft navigation
|
|
496
635
|
*/
|
|
@@ -123,7 +123,7 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
|
|
|
123
123
|
* "@main": async (ctx) => <MainContent data={ctx.use(DataLoader)} />,
|
|
124
124
|
* })
|
|
125
125
|
*
|
|
126
|
-
* // With loaders and loading states
|
|
126
|
+
* // With loaders and loading states (broadcast to every slot)
|
|
127
127
|
* parallel({
|
|
128
128
|
* "@analytics": AnalyticsPanel,
|
|
129
129
|
* "@metrics": MetricsPanel,
|
|
@@ -131,12 +131,36 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
|
|
|
131
131
|
* loader(DashboardLoader),
|
|
132
132
|
* loading(<DashboardSkeleton />),
|
|
133
133
|
* ])
|
|
134
|
+
*
|
|
135
|
+
* // Per-slot scoped use via slot descriptor — for single-assignment items
|
|
136
|
+
* // like loading() that should not broadcast to siblings.
|
|
137
|
+
* parallel({
|
|
138
|
+
* "@meta": MetaSlot,
|
|
139
|
+
* "@sidebar": {
|
|
140
|
+
* handler: SidebarSlot,
|
|
141
|
+
* use: () => [loading(<SidebarSkeleton />)],
|
|
142
|
+
* },
|
|
143
|
+
* })
|
|
134
144
|
* ```
|
|
135
145
|
* @param slots - Object with slot names (prefixed with @) mapped to handlers
|
|
146
|
+
* or `{ handler, use? }` slot descriptors.
|
|
136
147
|
* @param use - Optional callback for loaders, loading, revalidate, etc.
|
|
148
|
+
* Items here apply to every slot in the call (broadcast).
|
|
149
|
+
* For per-slot single-assignment items, use the slot descriptor's
|
|
150
|
+
* own `use` callback — slot-local items run after the broadcast,
|
|
151
|
+
* so they take precedence on `loading()` and other last-write-wins
|
|
152
|
+
* fields.
|
|
137
153
|
*/
|
|
138
154
|
parallel: <
|
|
139
|
-
TSlots extends Record
|
|
155
|
+
TSlots extends Record<
|
|
156
|
+
`@${string}`,
|
|
157
|
+
| Handler<any, any, TEnv>
|
|
158
|
+
| ReactNode
|
|
159
|
+
| {
|
|
160
|
+
handler: Handler<any, any, TEnv> | ReactNode;
|
|
161
|
+
use?: () => UseItems<ParallelUseItem>;
|
|
162
|
+
}
|
|
163
|
+
>,
|
|
140
164
|
>(
|
|
141
165
|
slots: TSlots,
|
|
142
166
|
use?: () => UseItems<ParallelUseItem>,
|
|
@@ -182,21 +206,41 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
|
|
|
182
206
|
): InterceptItem;
|
|
183
207
|
};
|
|
184
208
|
/**
|
|
185
|
-
* Attach middleware to the current route/layout
|
|
209
|
+
* Attach middleware to the current route/layout, or wrap child segments
|
|
210
|
+
*
|
|
211
|
+
* **Sibling mode** — attaches middleware to the parent entry:
|
|
186
212
|
* ```typescript
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
213
|
+
* layout(<DashboardShell />, () => [
|
|
214
|
+
* middleware(authMiddleware),
|
|
215
|
+
* middleware([authMiddleware, loggingMiddleware]),
|
|
216
|
+
* path("/", DashboardPage),
|
|
217
|
+
* ])
|
|
218
|
+
* ```
|
|
193
219
|
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
220
|
+
* **Wrapping mode** — scopes middleware to the children only:
|
|
221
|
+
* ```typescript
|
|
222
|
+
* middleware(authMiddleware, () => [
|
|
223
|
+
* path("/dashboard", DashboardPage),
|
|
224
|
+
* path("/settings", SettingsPage),
|
|
225
|
+
* ])
|
|
226
|
+
*
|
|
227
|
+
* middleware([authMiddleware, loggingMiddleware], () => [
|
|
228
|
+
* path("/admin", AdminPage),
|
|
229
|
+
* ])
|
|
196
230
|
* ```
|
|
197
|
-
* @param fns - One or more middleware functions to execute in order
|
|
198
231
|
*/
|
|
199
|
-
middleware:
|
|
232
|
+
middleware: {
|
|
233
|
+
(fn: MiddlewareFn<TEnv>): MiddlewareItem;
|
|
234
|
+
(
|
|
235
|
+
fn: MiddlewareFn<TEnv>,
|
|
236
|
+
children: () => UseItems<LayoutUseItem>,
|
|
237
|
+
): MiddlewareItem;
|
|
238
|
+
(fns: MiddlewareFn<TEnv>[]): MiddlewareItem;
|
|
239
|
+
(
|
|
240
|
+
fns: MiddlewareFn<TEnv>[],
|
|
241
|
+
children: () => UseItems<LayoutUseItem>,
|
|
242
|
+
): MiddlewareItem;
|
|
243
|
+
};
|
|
200
244
|
/**
|
|
201
245
|
* Control when a segment should revalidate during navigation
|
|
202
246
|
* ```typescript
|