@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.
Files changed (36) hide show
  1. package/dist/vite/index.js +16 -8
  2. package/package.json +3 -3
  3. package/skills/handler-use/SKILL.md +362 -0
  4. package/skills/intercept/SKILL.md +20 -0
  5. package/skills/layout/SKILL.md +22 -0
  6. package/skills/middleware/SKILL.md +32 -3
  7. package/skills/migrate-nextjs/SKILL.md +560 -0
  8. package/skills/migrate-react-router/SKILL.md +764 -0
  9. package/skills/parallel/SKILL.md +59 -0
  10. package/skills/rango/SKILL.md +24 -22
  11. package/skills/route/SKILL.md +24 -0
  12. package/src/browser/navigation-bridge.ts +21 -2
  13. package/src/browser/partial-update.ts +9 -2
  14. package/src/browser/prefetch/fetch.ts +20 -4
  15. package/src/browser/react/use-navigation.ts +11 -10
  16. package/src/browser/segment-reconciler.ts +10 -14
  17. package/src/build/route-trie.ts +50 -24
  18. package/src/client.tsx +82 -174
  19. package/src/index.ts +37 -9
  20. package/src/reverse.ts +4 -1
  21. package/src/route-definition/dsl-helpers.ts +159 -20
  22. package/src/route-definition/helpers-types.ts +57 -13
  23. package/src/route-types.ts +7 -0
  24. package/src/router/handler-context.ts +4 -1
  25. package/src/router/lazy-includes.ts +5 -5
  26. package/src/router/manifest.ts +22 -13
  27. package/src/router/match-middleware/cache-lookup.ts +2 -1
  28. package/src/segment-content-promise.ts +67 -0
  29. package/src/segment-loader-promise.ts +122 -0
  30. package/src/segment-system.tsx +11 -61
  31. package/src/server/context.ts +26 -3
  32. package/src/types/route-entry.ts +11 -0
  33. package/src/types/segments.ts +0 -1
  34. package/src/urls/include-helper.ts +24 -14
  35. package/src/urls/path-helper-types.ts +30 -4
  36. 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
- const segment = context?.parallel?.find((seg) => seg.slot === name) ?? null;
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 = useMemo(() => {
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
- let result: ReactNode;
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
- * Error-throwing stub for server-only `Prerender` function.
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(): never {
153
- throw serverOnlyStubError("Prerender");
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
- * Error-throwing stub for server-only `Passthrough` function.
168
+ * SSR/client stub for server-only `Passthrough` function.
158
169
  */
159
- export function Passthrough(): never {
160
- throw serverOnlyStubError("Passthrough");
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
- * Error-throwing stub for server-only `Static` function.
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(): never {
167
- throw serverOnlyStubError("Static");
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
- if (value === undefined) {
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"] = (...fn) => {
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
- // Checked before context validation — this is a static invariant.
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 ctx = getContext().getStore();
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
- // Attach to last entry in stack
374
- const parent = ctx.parent;
375
- if (!parent || !("middleware" in parent)) {
376
- invariant(false, "No parent entry available for middleware()");
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
- const name = `$${getContext().getNextIndex("middleware")}`;
379
- parent.middleware.push(...fn);
380
- return { name, type: "middleware" } as MiddlewareItem;
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 any static handler definitions in parallel slots
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, slotHandler] of Object.entries(
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: handler.use defaults first, then explicit use second.
475
- // This matches the "defaults first, overrides second" rule used by
476
- // path(), layout(), and intercept(). Each slot's handler.use is
477
- // scoped to its own entry (no cross-slot bleed).
478
- const slotHandler = (slots as Record<string, any>)[slotName];
479
- const slotHandlerUse = resolveHandlerUse(slotHandler);
480
- const slotMergedUse = mergeHandlerUse(slotHandlerUse, use, "parallel");
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<`@${string}`, Handler<any, any, TEnv> | ReactNode>,
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
- * middleware(async (ctx, next) => {
188
- * const session = await getSession(ctx.request);
189
- * if (!session) return redirect("/login");
190
- * ctx.set("user", session.user);
191
- * next();
192
- * })
213
+ * layout(<DashboardShell />, () => [
214
+ * middleware(authMiddleware),
215
+ * middleware([authMiddleware, loggingMiddleware]),
216
+ * path("/", DashboardPage),
217
+ * ])
218
+ * ```
193
219
  *
194
- * // Chain multiple middleware
195
- * middleware(authMiddleware, loggingMiddleware, rateLimitMiddleware)
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: (...fns: MiddlewareFn<TEnv>[]) => MiddlewareItem;
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