@rangojs/router 0.0.0-experimental.8a4d0430 → 0.0.0-experimental.8bcfea43

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 (174) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +126 -38
  3. package/dist/bin/rango.js +138 -50
  4. package/dist/vite/index.js +1171 -461
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +19 -16
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/cache-guide/SKILL.md +32 -0
  9. package/skills/caching/SKILL.md +45 -4
  10. package/skills/handler-use/SKILL.md +362 -0
  11. package/skills/hooks/SKILL.md +28 -20
  12. package/skills/intercept/SKILL.md +20 -0
  13. package/skills/layout/SKILL.md +22 -0
  14. package/skills/links/SKILL.md +91 -17
  15. package/skills/loader/SKILL.md +88 -45
  16. package/skills/middleware/SKILL.md +34 -3
  17. package/skills/migrate-nextjs/SKILL.md +560 -0
  18. package/skills/migrate-react-router/SKILL.md +765 -0
  19. package/skills/parallel/SKILL.md +185 -0
  20. package/skills/prerender/SKILL.md +110 -68
  21. package/skills/rango/SKILL.md +24 -22
  22. package/skills/response-routes/SKILL.md +8 -0
  23. package/skills/route/SKILL.md +55 -0
  24. package/skills/router-setup/SKILL.md +87 -2
  25. package/skills/streams-and-websockets/SKILL.md +283 -0
  26. package/skills/typesafety/SKILL.md +13 -1
  27. package/src/__internal.ts +1 -1
  28. package/src/browser/app-shell.ts +52 -0
  29. package/src/browser/app-version.ts +14 -0
  30. package/src/browser/event-controller.ts +5 -0
  31. package/src/browser/navigation-bridge.ts +90 -16
  32. package/src/browser/navigation-client.ts +167 -59
  33. package/src/browser/navigation-store.ts +68 -9
  34. package/src/browser/navigation-transaction.ts +11 -9
  35. package/src/browser/partial-update.ts +113 -17
  36. package/src/browser/prefetch/cache.ts +184 -16
  37. package/src/browser/prefetch/fetch.ts +180 -33
  38. package/src/browser/prefetch/policy.ts +6 -0
  39. package/src/browser/prefetch/queue.ts +123 -20
  40. package/src/browser/prefetch/resource-ready.ts +77 -0
  41. package/src/browser/rango-state.ts +53 -13
  42. package/src/browser/react/Link.tsx +81 -9
  43. package/src/browser/react/NavigationProvider.tsx +89 -14
  44. package/src/browser/react/context.ts +7 -2
  45. package/src/browser/react/use-handle.ts +9 -58
  46. package/src/browser/react/use-navigation.ts +22 -2
  47. package/src/browser/react/use-params.ts +11 -1
  48. package/src/browser/react/use-router.ts +29 -9
  49. package/src/browser/rsc-router.tsx +168 -65
  50. package/src/browser/scroll-restoration.ts +41 -42
  51. package/src/browser/segment-reconciler.ts +36 -9
  52. package/src/browser/server-action-bridge.ts +8 -6
  53. package/src/browser/types.ts +49 -5
  54. package/src/build/generate-manifest.ts +6 -6
  55. package/src/build/generate-route-types.ts +3 -0
  56. package/src/build/route-trie.ts +50 -24
  57. package/src/build/route-types/include-resolution.ts +8 -1
  58. package/src/build/route-types/router-processing.ts +223 -74
  59. package/src/build/route-types/scan-filter.ts +8 -1
  60. package/src/cache/cache-runtime.ts +15 -11
  61. package/src/cache/cache-scope.ts +48 -7
  62. package/src/cache/cf/cf-cache-store.ts +455 -15
  63. package/src/cache/cf/index.ts +5 -1
  64. package/src/cache/document-cache.ts +17 -7
  65. package/src/cache/index.ts +1 -0
  66. package/src/cache/taint.ts +55 -0
  67. package/src/client.tsx +84 -230
  68. package/src/context-var.ts +72 -2
  69. package/src/debug.ts +2 -2
  70. package/src/handle.ts +40 -0
  71. package/src/index.rsc.ts +6 -1
  72. package/src/index.ts +49 -6
  73. package/src/outlet-context.ts +1 -1
  74. package/src/prerender/store.ts +5 -4
  75. package/src/prerender.ts +138 -77
  76. package/src/response-utils.ts +28 -0
  77. package/src/reverse.ts +27 -2
  78. package/src/route-definition/dsl-helpers.ts +240 -40
  79. package/src/route-definition/helpers-types.ts +67 -19
  80. package/src/route-definition/index.ts +3 -0
  81. package/src/route-definition/redirect.ts +11 -3
  82. package/src/route-definition/resolve-handler-use.ts +155 -0
  83. package/src/route-map-builder.ts +7 -1
  84. package/src/route-types.ts +18 -0
  85. package/src/router/content-negotiation.ts +100 -1
  86. package/src/router/find-match.ts +4 -2
  87. package/src/router/handler-context.ts +101 -25
  88. package/src/router/intercept-resolution.ts +11 -4
  89. package/src/router/lazy-includes.ts +10 -7
  90. package/src/router/loader-resolution.ts +159 -21
  91. package/src/router/logging.ts +5 -2
  92. package/src/router/manifest.ts +31 -16
  93. package/src/router/match-api.ts +127 -192
  94. package/src/router/match-middleware/background-revalidation.ts +30 -2
  95. package/src/router/match-middleware/cache-lookup.ts +94 -17
  96. package/src/router/match-middleware/cache-store.ts +53 -10
  97. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  98. package/src/router/match-middleware/segment-resolution.ts +61 -5
  99. package/src/router/match-result.ts +104 -10
  100. package/src/router/metrics.ts +6 -1
  101. package/src/router/middleware-types.ts +8 -30
  102. package/src/router/middleware.ts +36 -10
  103. package/src/router/navigation-snapshot.ts +182 -0
  104. package/src/router/pattern-matching.ts +60 -9
  105. package/src/router/prerender-match.ts +110 -10
  106. package/src/router/preview-match.ts +30 -102
  107. package/src/router/request-classification.ts +310 -0
  108. package/src/router/route-snapshot.ts +245 -0
  109. package/src/router/router-context.ts +6 -1
  110. package/src/router/router-interfaces.ts +36 -4
  111. package/src/router/router-options.ts +37 -11
  112. package/src/router/segment-resolution/fresh.ts +198 -20
  113. package/src/router/segment-resolution/helpers.ts +29 -24
  114. package/src/router/segment-resolution/loader-cache.ts +1 -0
  115. package/src/router/segment-resolution/revalidation.ts +438 -300
  116. package/src/router/segment-wrappers.ts +2 -0
  117. package/src/router/trie-matching.ts +10 -4
  118. package/src/router/types.ts +1 -0
  119. package/src/router/url-params.ts +49 -0
  120. package/src/router.ts +60 -8
  121. package/src/rsc/handler.ts +478 -374
  122. package/src/rsc/helpers.ts +69 -41
  123. package/src/rsc/loader-fetch.ts +23 -3
  124. package/src/rsc/manifest-init.ts +5 -1
  125. package/src/rsc/progressive-enhancement.ts +16 -2
  126. package/src/rsc/response-route-handler.ts +14 -1
  127. package/src/rsc/rsc-rendering.ts +19 -1
  128. package/src/rsc/server-action.ts +10 -0
  129. package/src/rsc/ssr-setup.ts +2 -2
  130. package/src/rsc/types.ts +9 -1
  131. package/src/segment-content-promise.ts +67 -0
  132. package/src/segment-loader-promise.ts +122 -0
  133. package/src/segment-system.tsx +109 -23
  134. package/src/server/context.ts +166 -17
  135. package/src/server/handle-store.ts +19 -0
  136. package/src/server/loader-registry.ts +9 -8
  137. package/src/server/request-context.ts +194 -60
  138. package/src/ssr/index.tsx +4 -0
  139. package/src/static-handler.ts +18 -6
  140. package/src/types/cache-types.ts +4 -4
  141. package/src/types/handler-context.ts +137 -65
  142. package/src/types/loader-types.ts +41 -15
  143. package/src/types/request-scope.ts +126 -0
  144. package/src/types/route-entry.ts +19 -1
  145. package/src/types/segments.ts +2 -0
  146. package/src/urls/include-helper.ts +24 -14
  147. package/src/urls/path-helper-types.ts +39 -6
  148. package/src/urls/path-helper.ts +48 -13
  149. package/src/urls/pattern-types.ts +12 -0
  150. package/src/urls/response-types.ts +18 -16
  151. package/src/use-loader.tsx +77 -5
  152. package/src/vite/debug.ts +55 -0
  153. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  154. package/src/vite/discovery/discover-routers.ts +5 -1
  155. package/src/vite/discovery/prerender-collection.ts +128 -74
  156. package/src/vite/discovery/state.ts +13 -6
  157. package/src/vite/index.ts +4 -0
  158. package/src/vite/plugin-types.ts +51 -79
  159. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  160. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  161. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  162. package/src/vite/plugins/expose-action-id.ts +1 -3
  163. package/src/vite/plugins/expose-id-utils.ts +12 -0
  164. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  165. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  166. package/src/vite/plugins/performance-tracks.ts +86 -0
  167. package/src/vite/plugins/refresh-cmd.ts +88 -26
  168. package/src/vite/plugins/version-plugin.ts +13 -1
  169. package/src/vite/rango.ts +204 -217
  170. package/src/vite/router-discovery.ts +335 -64
  171. package/src/vite/utils/banner.ts +4 -4
  172. package/src/vite/utils/package-resolution.ts +41 -1
  173. package/src/vite/utils/prerender-utils.ts +37 -5
  174. package/src/vite/utils/shared-utils.ts +3 -2
@@ -37,6 +37,7 @@ import type {
37
37
  UseItems,
38
38
  } from "../route-types.js";
39
39
  import type { RouteHelpers } from "./helpers-types.js";
40
+ import { resolveHandlerUse, mergeHandlerUse } from "./resolve-handler-use.js";
40
41
 
41
42
  /**
42
43
  * Check if an item contains routes (directly or inside nested structures like cache).
@@ -54,6 +55,9 @@ const hasRoutesInItem = (item: AllUseItems): boolean => {
54
55
  if (item.type === "layout" && item.uses) {
55
56
  return item.uses.some((child) => hasRoutesInItem(child));
56
57
  }
58
+ if (item.type === "middleware" && item.uses) {
59
+ return item.uses.some((child) => hasRoutesInItem(child));
60
+ }
57
61
  return false;
58
62
  };
59
63
 
@@ -282,7 +286,7 @@ const cache: RouteHelpers<any, any>["cache"] = (
282
286
  errorBoundary: [],
283
287
  notFoundBoundary: [],
284
288
  layout: [],
285
- parallel: [],
289
+ parallel: {},
286
290
  intercept: [],
287
291
  loader: [],
288
292
  ...(cacheUrlPrefix ? { mountPath: cacheUrlPrefix } : {}),
@@ -300,6 +304,15 @@ const cache: RouteHelpers<any, any>["cache"] = (
300
304
  return { name: namespace, type: "cache" } as CacheItem;
301
305
  }
302
306
 
307
+ // Inside a loader() use() callback, only the direct form — cache()/cache(opts)/
308
+ // cache("profile") — writes cache config to the loader entry. The wrapper
309
+ // form creates a structural cache boundary with its own children scope, which
310
+ // has no effect on the loader and would silently no-op.
311
+ invariant(
312
+ !(ctx.parent && (ctx.parent as any).type === "loader"),
313
+ "cache() wrapper form is not valid inside loader() use(). Use cache({...}) without children to configure the loader's cache.",
314
+ );
315
+
303
316
  // With children: create a cache entry (like layout with caching semantics)
304
317
  const namespace = `${ctx.namespace}.${cacheIndex}`;
305
318
  const cacheShortCode = store.getShortCode("cache");
@@ -320,7 +333,7 @@ const cache: RouteHelpers<any, any>["cache"] = (
320
333
  errorBoundary: [],
321
334
  notFoundBoundary: [],
322
335
  layout: [],
323
- parallel: [],
336
+ parallel: {},
324
337
  intercept: [],
325
338
  loader: [],
326
339
  ...(cacheUrlPrefix2 ? { mountPath: cacheUrlPrefix2 } : {}),
@@ -352,10 +365,37 @@ const cache: RouteHelpers<any, any>["cache"] = (
352
365
  return { name: namespace, type: "cache", uses: result } as CacheItem;
353
366
  };
354
367
 
355
- const middleware: RouteHelpers<any, any>["middleware"] = (...fn) => {
368
+ const middleware: RouteHelpers<any, any>["middleware"] = (...args: any[]) => {
369
+ // Four call forms:
370
+ // middleware(fn) — single fn, sibling
371
+ // middleware(fn, () => [...]) — single fn, wrapping
372
+ // middleware([fn1, fn2]) — array, sibling
373
+ // middleware([fn1, fn2], () => [...]) — array, wrapping
374
+ const isArray = Array.isArray(args[0]);
375
+
376
+ // Reject the removed variadic form before executing anything.
377
+ // middleware(fn1, fn2, fn3) — 3+ args, always wrong.
378
+ // middleware(fn1, fn2) where fn2 is a middleware fn (length >= 1), not a
379
+ // children callback (length === 0) — legacy two-fn form, reject early.
380
+ if (
381
+ args.length > 2 ||
382
+ (!isArray &&
383
+ args.length === 2 &&
384
+ typeof args[1] === "function" &&
385
+ args[1].length > 0)
386
+ ) {
387
+ throw new Error(
388
+ "middleware() no longer accepts variadic arguments. " +
389
+ "Use middleware([fn1, fn2, ...]) instead of middleware(fn1, fn2, ...).",
390
+ );
391
+ }
392
+
393
+ const fns: MiddlewareFn<any>[] = isArray ? args[0] : [args[0]];
394
+ const children: (() => any[]) | undefined =
395
+ typeof args[1] === "function" ? args[1] : undefined;
396
+
356
397
  // Prevent "use cache" functions from being used as middleware.
357
- // Checked before context validation — this is a static invariant.
358
- for (const f of fn) {
398
+ for (const f of fns) {
359
399
  if (isCachedFunction(f)) {
360
400
  throw new Error(
361
401
  `A "use cache" function cannot be used as middleware. ` +
@@ -366,17 +406,80 @@ const middleware: RouteHelpers<any, any>["middleware"] = (...fn) => {
366
406
  }
367
407
  }
368
408
 
369
- const ctx = getContext().getStore();
409
+ const store = getContext();
410
+ const ctx = store.getStore();
370
411
  if (!ctx) throw new Error("middleware() must be called inside map()");
371
412
 
372
- // Attach to last entry in stack
373
- const parent = ctx.parent;
374
- if (!parent || !("middleware" in parent)) {
375
- invariant(false, "No parent entry available for middleware()");
413
+ if (!children) {
414
+ // Sibling mode: attach to parent entry
415
+ const parent = ctx.parent;
416
+ if (!parent || !("middleware" in parent)) {
417
+ invariant(false, "No parent entry available for middleware()");
418
+ }
419
+ const name = `$${store.getNextIndex("middleware")}`;
420
+ parent.middleware.push(...fns);
421
+ return { name, type: "middleware" } as MiddlewareItem;
376
422
  }
377
- const name = `$${getContext().getNextIndex("middleware")}`;
378
- parent.middleware.push(...fn);
379
- return { name, type: "middleware" } as MiddlewareItem;
423
+
424
+ // Wrapping mode: create a transparent layout that carries the middleware
425
+ const mwIndex = store.getNextIndex("middleware");
426
+ const namespace = `${ctx.namespace}.${mwIndex}`;
427
+
428
+ const urlPrefix = getUrlPrefix();
429
+ const entry = {
430
+ id: namespace,
431
+ shortCode: store.getShortCode("layout"),
432
+ type: "layout",
433
+ parent: ctx.parent,
434
+ handler: RootLayout,
435
+ loading: undefined,
436
+ middleware: [...fns],
437
+ revalidate: [],
438
+ errorBoundary: [],
439
+ notFoundBoundary: [],
440
+ layout: [],
441
+ parallel: {},
442
+ intercept: [],
443
+ loader: [],
444
+ ...(urlPrefix ? { mountPath: urlPrefix } : {}),
445
+ } as EntryData;
446
+
447
+ // Run children callback. If the second arg was actually a middleware fn
448
+ // (old variadic form: middleware(mw1, mw2)), this will return a non-array
449
+ // and the invariant below gives a clear migration error.
450
+ const rawResult = store.run(namespace, entry, children);
451
+
452
+ invariant(
453
+ Array.isArray(rawResult),
454
+ "middleware(fn, children) expects the second argument to return an array of use items. " +
455
+ "To pass multiple middleware, use middleware([fn1, fn2]).",
456
+ );
457
+
458
+ const result = rawResult.flat(3);
459
+
460
+ invariant(
461
+ result.every((item: any) => isValidUseItem(item)),
462
+ `middleware() children callback must return an array of use items [${namespace}]`,
463
+ );
464
+
465
+ const hasRoutes =
466
+ result &&
467
+ Array.isArray(result) &&
468
+ result.some((item) => item != null && hasRoutesInItem(item));
469
+
470
+ if (!hasRoutes) {
471
+ const parent = ctx.parent;
472
+ if (parent && "layout" in parent) {
473
+ entry.parent = null;
474
+ parent.layout.push(entry);
475
+ }
476
+ }
477
+
478
+ return {
479
+ name: namespace,
480
+ type: "middleware",
481
+ uses: result,
482
+ } as MiddlewareItem;
380
483
  };
381
484
 
382
485
  const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
@@ -393,15 +496,29 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
393
496
  "parallel() cannot be nested inside another parallel()",
394
497
  );
395
498
 
499
+ const slotNames = Object.keys(slots as Record<string, any>) as `@${string}`[];
500
+
396
501
  const namespace = `${ctx.namespace}.$${store.getNextIndex("parallel")}`;
397
502
 
398
- // Unwrap any static handler definitions in parallel slots
503
+ // Unwrap slot values. A slot value can be:
504
+ // - a Handler / ReactNode (legacy form)
505
+ // - a Static() definition (build-time only)
506
+ // - a slot descriptor `{ handler, use? }` for slot-local overrides
507
+ // The descriptor's `use` runs after the broadcast `use` for that slot,
508
+ // so single-assignment items like `loading()` placed there win without
509
+ // affecting siblings.
399
510
  const unwrappedSlots: Record<string, any> = {};
511
+ const slotLocalUses: Record<string, (() => any[]) | undefined> = {};
400
512
  let hasStaticSlot = false;
401
513
  const staticSlotIds: Record<string, string> = {};
402
- for (const [slotName, slotHandler] of Object.entries(
514
+ for (const [slotName, rawSlot] of Object.entries(
403
515
  slots as Record<string, any>,
404
516
  )) {
517
+ let slotHandler: any = rawSlot;
518
+ if (isSlotDescriptor(rawSlot)) {
519
+ slotHandler = rawSlot.handler;
520
+ slotLocalUses[slotName] = rawSlot.use;
521
+ }
405
522
  if (isStaticHandler(slotHandler)) {
406
523
  hasStaticSlot = true;
407
524
  unwrappedSlots[slotName] = slotHandler.handler;
@@ -431,7 +548,7 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
431
548
  errorBoundary: [],
432
549
  notFoundBoundary: [],
433
550
  layout: [],
434
- parallel: [],
551
+ parallel: {},
435
552
  intercept: [],
436
553
  loader: [],
437
554
  ...(parallelUrlPrefix ? { mountPath: parallelUrlPrefix } : {}),
@@ -445,19 +562,83 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
445
562
  : {}),
446
563
  } satisfies EntryData;
447
564
 
448
- // Run use callback if provided to collect loaders, revalidate, loading
449
- if (use && typeof use === "function") {
450
- const result = store.run(namespace, entry, use)?.flat(3);
451
- invariant(
452
- Array.isArray(result) && result.every((item) => isValidUseItem(item)),
453
- `parallel() use() callback must return an array of use items [${namespace}]`,
565
+ for (const slotName of slotNames) {
566
+ const slotEntry = {
567
+ ...entry,
568
+ handler: { [slotName]: unwrappedSlots[slotName]! },
569
+ middleware: [...entry.middleware],
570
+ revalidate: [...entry.revalidate],
571
+ errorBoundary: [...entry.errorBoundary],
572
+ notFoundBoundary: [...entry.notFoundBoundary],
573
+ layout: [...entry.layout],
574
+ parallel: { ...entry.parallel },
575
+ intercept: [...entry.intercept],
576
+ loader: [...entry.loader],
577
+ ...(entry.staticHandlerIds?.[slotName]
578
+ ? {
579
+ isStaticPrerender: true as const,
580
+ staticHandlerIds: { [slotName]: entry.staticHandlerIds[slotName]! },
581
+ }
582
+ : {
583
+ isStaticPrerender: undefined,
584
+ staticHandlerIds: undefined,
585
+ }),
586
+ } satisfies EntryData;
587
+
588
+ // Per-slot merge order (narrowest-scope-wins for single-assignment items
589
+ // like loading()):
590
+ // 1. handler.use — defaults baked into the handler
591
+ // 2. shared `use` — broadcast at the parallel() call site
592
+ // 3. slot-local `use` — per-slot override via `{ handler, use }` descriptor
593
+ // Items that accumulate (loader, middleware, revalidate, …) compose
594
+ // across all three layers regardless of order.
595
+ const rawSlot = (slots as Record<string, any>)[slotName];
596
+ const slotHandlerForUse = isSlotDescriptor(rawSlot)
597
+ ? rawSlot.handler
598
+ : rawSlot;
599
+ const slotHandlerUse = resolveHandlerUse(slotHandlerForUse);
600
+ const slotLocalUse = slotLocalUses[slotName];
601
+ const explicitUse = combineExplicitUses(use, slotLocalUse);
602
+ const slotMergedUse = mergeHandlerUse(
603
+ slotHandlerUse,
604
+ explicitUse,
605
+ "parallel",
454
606
  );
455
- }
607
+ if (slotMergedUse) {
608
+ const result = store.run(namespace, slotEntry, slotMergedUse)?.flat(3);
609
+ invariant(
610
+ Array.isArray(result) && result.every((item) => isValidUseItem(item)),
611
+ `parallel() use() callback must return an array of use items [${namespace}]`,
612
+ );
613
+ }
456
614
 
457
- ctx.parent.parallel.push(entry);
615
+ ctx.parent.parallel[slotName] = slotEntry;
616
+ }
458
617
  return { name: namespace, type: "parallel" } as ParallelItem;
459
618
  };
460
619
 
620
+ function isSlotDescriptor(
621
+ value: unknown,
622
+ ): value is { handler: unknown; use?: () => any[] } {
623
+ return (
624
+ typeof value === "object" &&
625
+ value !== null &&
626
+ !("__brand" in value) &&
627
+ "handler" in value &&
628
+ typeof (value as any).handler !== "undefined"
629
+ );
630
+ }
631
+
632
+ function combineExplicitUses(
633
+ sharedUse: (() => any[]) | undefined,
634
+ slotLocalUse: (() => any[]) | undefined,
635
+ ): (() => any[]) | undefined {
636
+ if (!sharedUse && !slotLocalUse) return undefined;
637
+ if (!slotLocalUse) return sharedUse;
638
+ if (!sharedUse) return slotLocalUse;
639
+ return () => [...sharedUse(), ...slotLocalUse()];
640
+ }
641
+
461
642
  /**
462
643
  * Intercept helper - defines an intercepting route for soft navigation
463
644
  */
@@ -502,8 +683,12 @@ const intercept = (
502
683
  when: [], // Selector conditions for conditional interception
503
684
  };
504
685
 
505
- // Run use callback if provided to collect loaders, revalidate, middleware, etc.
506
- if (use && typeof use === "function") {
686
+ // Merge handler.use defaults with explicit use
687
+ const handlerUseFn = resolveHandlerUse(handler);
688
+ const mergedUse = mergeHandlerUse(handlerUseFn, use, "intercept");
689
+
690
+ // Run merged use callback to collect loaders, revalidate, middleware, etc.
691
+ if (mergedUse) {
507
692
  // Create a temporary parent context for the use() callback
508
693
  // so that middleware, loader, revalidate attach to the intercept entry
509
694
  const originalParent = ctx.parent;
@@ -530,7 +715,7 @@ const intercept = (
530
715
  };
531
716
  ctx.parent = tempParent as EntryData;
532
717
 
533
- const result = use()?.flat(3);
718
+ const result = mergedUse()?.flat(3);
534
719
 
535
720
  // Restore original parent
536
721
  ctx.parent = originalParent;
@@ -574,8 +759,12 @@ const loaderFn: RouteHelpers<any, any>["loader"] = (loaderDef, use) => {
574
759
  revalidate: [] as ShouldRevalidateFn<any, any>[],
575
760
  };
576
761
 
577
- // If use() callback provided, run it to collect revalidation rules and cache config
578
- if (use && typeof use === "function") {
762
+ // Merge handler.use defaults (attached to the loader definition) with explicit use
763
+ const handlerUseFn = resolveHandlerUse(loaderDef);
764
+ const mergedUse = mergeHandlerUse(handlerUseFn, use, "loader");
765
+
766
+ // If any use callback is in effect, run it to collect revalidation rules and cache config
767
+ if (mergedUse) {
579
768
  // Temporarily set context for revalidate()/cache() calls to target this loader
580
769
  const originalParent = ctx.parent;
581
770
  // Create a temporary "parent" with type "loader" so cache() can detect it.
@@ -588,7 +777,7 @@ const loaderFn: RouteHelpers<any, any>["loader"] = (loaderDef, use) => {
588
777
  };
589
778
  ctx.parent = tempParent as EntryData;
590
779
 
591
- const result = use()?.flat(3);
780
+ const result = mergedUse()?.flat(3);
592
781
 
593
782
  // Copy cache config only if cache() was called during the use() callback.
594
783
  // The spread from originalParent may carry an inherited .cache from
@@ -627,11 +816,15 @@ const loadingFn: RouteHelpers<any, any>["loading"] = (component, options) => {
627
816
  invariant(false, "No parent entry available for loading()");
628
817
  }
629
818
 
819
+ // Unwrap function form: loading(() => <Skeleton />) → loading(<Skeleton />)
820
+ const resolved =
821
+ typeof component === "function" ? (component as () => any)() : component;
822
+
630
823
  // If ssr: false and we're in SSR, set loading to false
631
824
  if (options?.ssr === false && ctx.isSSR) {
632
825
  parent.loading = false;
633
826
  } else {
634
- parent.loading = component;
827
+ parent.loading = resolved;
635
828
  }
636
829
 
637
830
  const name = `$${store.getNextIndex("loading")}`;
@@ -687,7 +880,7 @@ const transitionFn = (
687
880
  errorBoundary: [],
688
881
  notFoundBoundary: [],
689
882
  layout: [],
690
- parallel: [],
883
+ parallel: {},
691
884
  intercept: [],
692
885
  loader: [],
693
886
  } as EntryData;
@@ -727,14 +920,14 @@ const routeFn: RouteHelpers<any, any>["route"] = (name, handler, use) => {
727
920
  shortCode: store.getShortCode("route"),
728
921
  type: "route",
729
922
  parent: ctx.parent,
730
- handler,
923
+ handler: handler as unknown as Handler<any, any, any>,
731
924
  loading: undefined, // Allow loading() to attach loading state
732
925
  middleware: [],
733
926
  revalidate: [],
734
927
  errorBoundary: [],
735
928
  notFoundBoundary: [],
736
929
  layout: [],
737
- parallel: [],
930
+ parallel: {},
738
931
  intercept: [],
739
932
  loader: [],
740
933
  } satisfies EntryData;
@@ -746,9 +939,12 @@ const routeFn: RouteHelpers<any, any>["route"] = (name, handler, use) => {
746
939
  );
747
940
  /* Register route entry */
748
941
  ctx.manifest.set(name, entry);
942
+ /* Merge handler.use defaults with explicit use */
943
+ const handlerUseFn = resolveHandlerUse(handler);
944
+ const mergedUse = mergeHandlerUse(handlerUseFn, use, "route");
749
945
  /* Run use and attach handlers */
750
- if (use && typeof use === "function") {
751
- const result = store.run(namespace, entry, use)?.flat(3);
946
+ if (mergedUse) {
947
+ const result = store.run(namespace, entry, mergedUse)?.flat(3);
752
948
  invariant(
753
949
  Array.isArray(result) && result.every((item) => isValidUseItem(item)),
754
950
  `route() use() callback must return an array of use items [${namespace}]`,
@@ -791,7 +987,7 @@ const layout: RouteHelpers<any, any>["layout"] = (handler, use) => {
791
987
  revalidate: [],
792
988
  errorBoundary: [],
793
989
  notFoundBoundary: [],
794
- parallel: [],
990
+ parallel: {},
795
991
  intercept: [],
796
992
  layout: [],
797
993
  loader: [],
@@ -809,10 +1005,14 @@ const layout: RouteHelpers<any, any>["layout"] = (handler, use) => {
809
1005
  (handler as any).$$routePrefix = ctx.namePrefix;
810
1006
  }
811
1007
 
812
- // Run use callback if provided
1008
+ // Merge handler.use defaults with explicit use
1009
+ const handlerUseFn = resolveHandlerUse(handler);
1010
+ const mergedUse = mergeHandlerUse(handlerUseFn, use, "layout");
1011
+
1012
+ // Run merged use callback if present
813
1013
  let result: AllUseItems[] | undefined;
814
- if (use && typeof use === "function") {
815
- result = store.run(namespace, entry, use)?.flat(3);
1014
+ if (mergedUse) {
1015
+ result = store.run(namespace, entry, mergedUse)?.flat(3);
816
1016
 
817
1017
  invariant(
818
1018
  Array.isArray(result) && result.every((item) => isValidUseItem(item)),
@@ -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
+ * ```
219
+ *
220
+ * **Wrapping mode** — scopes middleware to the children only:
221
+ * ```typescript
222
+ * middleware(authMiddleware, () => [
223
+ * path("/dashboard", DashboardPage),
224
+ * path("/settings", SettingsPage),
225
+ * ])
193
226
  *
194
- * // Chain multiple middleware
195
- * middleware(authMiddleware, loggingMiddleware, rateLimitMiddleware)
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
@@ -228,11 +272,12 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
228
272
  * revalidate(({ actionId }) => actionId?.includes("Cart") ?? false),
229
273
  * ])
230
274
  *
231
- * // Access loader data in handlers via ctx.use()
232
- * route("products.detail", async (ctx) => {
233
- * const product = await ctx.use(ProductLoader);
234
- * return <ProductPage product={product} />;
235
- * })
275
+ * // Consume in client components with useLoader()
276
+ * // (preferred — cache-safe, always fresh)
277
+ * function ProductDetails() {
278
+ * const { data } = useLoader(ProductLoader);
279
+ * return <div>{data.name}</div>;
280
+ * }
236
281
  * ```
237
282
  * @param loaderDef - Loader created with createLoader()
238
283
  * @param use - Optional callback for loader-specific revalidation rules
@@ -254,7 +299,10 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
254
299
  * @param options - Configuration options
255
300
  * @param options.ssr - If false, skip showing loading on document requests (SSR)
256
301
  */
257
- loading: (component: ReactNode, options?: { ssr?: boolean }) => LoadingItem;
302
+ loading: (
303
+ component: ReactNode | (() => ReactNode),
304
+ options?: { ssr?: boolean },
305
+ ) => LoadingItem;
258
306
  /**
259
307
  * Attach an error boundary to catch errors in this segment and children
260
308
  * ```typescript
@@ -45,6 +45,9 @@ export {
45
45
  type RouteHandlers,
46
46
  } from "./helper-factories.js";
47
47
 
48
+ // Handler use resolver
49
+ export { resolveHandlerUse } from "./resolve-handler-use.js";
50
+
48
51
  // Redirect
49
52
  export { redirect } from "./redirect.js";
50
53
 
@@ -2,6 +2,7 @@ import type { LocationStateEntry } from "../browser/react/location-state-shared.
2
2
  import {
3
3
  requireRequestContext,
4
4
  getRequestContext,
5
+ _getRequestContext,
5
6
  } from "../server/request-context.js";
6
7
 
7
8
  /**
@@ -71,9 +72,9 @@ export function redirect(
71
72
  // actions both deliver state through Flight payloads, so suppress for those.
72
73
  if (
73
74
  reqCtx &&
74
- !reqCtx.url.searchParams.has("_rsc_partial") &&
75
+ !reqCtx.originalUrl.searchParams.has("_rsc_partial") &&
75
76
  !reqCtx.request.headers.has("rsc-action") &&
76
- !reqCtx.url.searchParams.has("_rsc_action")
77
+ !reqCtx.originalUrl.searchParams.has("_rsc_action")
77
78
  ) {
78
79
  console.warn(
79
80
  `[Router] redirect() with state during a full-page (SSR) request to "${url}". ` +
@@ -83,10 +84,17 @@ export function redirect(
83
84
  }
84
85
  }
85
86
 
87
+ // Auto-prefix root-relative URLs with basename for app-local redirects.
88
+ const bn = _getRequestContext()?._basename;
89
+ let resolvedUrl = url;
90
+ if (bn && url.startsWith("/") && !url.startsWith(bn + "/") && url !== bn) {
91
+ resolvedUrl = url === "/" ? bn : bn + url;
92
+ }
93
+
86
94
  return new Response(null, {
87
95
  status,
88
96
  headers: {
89
- Location: url,
97
+ Location: resolvedUrl,
90
98
  "X-RSC-Redirect": "soft",
91
99
  },
92
100
  });