@rangojs/router 0.0.0-experimental.b02a2fec → 0.0.0-experimental.bf1b128c

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 (103) hide show
  1. package/README.md +50 -20
  2. package/dist/vite/index.js +1338 -462
  3. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  4. package/package.json +7 -5
  5. package/skills/breadcrumbs/SKILL.md +3 -1
  6. package/skills/handler-use/SKILL.md +362 -0
  7. package/skills/hooks/SKILL.md +28 -20
  8. package/skills/intercept/SKILL.md +20 -0
  9. package/skills/layout/SKILL.md +22 -0
  10. package/skills/links/SKILL.md +88 -16
  11. package/skills/loader/SKILL.md +66 -2
  12. package/skills/middleware/SKILL.md +32 -3
  13. package/skills/migrate-nextjs/SKILL.md +560 -0
  14. package/skills/migrate-react-router/SKILL.md +765 -0
  15. package/skills/parallel/SKILL.md +66 -0
  16. package/skills/rango/SKILL.md +24 -22
  17. package/skills/response-routes/SKILL.md +8 -0
  18. package/skills/route/SKILL.md +24 -0
  19. package/skills/streams-and-websockets/SKILL.md +283 -0
  20. package/skills/typesafety/SKILL.md +3 -1
  21. package/src/browser/app-shell.ts +52 -0
  22. package/src/browser/navigation-bridge.ts +71 -5
  23. package/src/browser/navigation-client.ts +64 -13
  24. package/src/browser/navigation-store.ts +25 -1
  25. package/src/browser/partial-update.ts +34 -3
  26. package/src/browser/prefetch/cache.ts +129 -21
  27. package/src/browser/prefetch/fetch.ts +148 -16
  28. package/src/browser/prefetch/queue.ts +36 -5
  29. package/src/browser/rango-state.ts +53 -13
  30. package/src/browser/react/Link.tsx +30 -2
  31. package/src/browser/react/NavigationProvider.tsx +50 -11
  32. package/src/browser/react/use-navigation.ts +22 -2
  33. package/src/browser/react/use-params.ts +11 -1
  34. package/src/browser/react/use-router.ts +8 -1
  35. package/src/browser/rsc-router.tsx +34 -6
  36. package/src/browser/segment-reconciler.ts +36 -14
  37. package/src/browser/types.ts +13 -0
  38. package/src/build/route-trie.ts +50 -24
  39. package/src/cache/cf/cf-cache-store.ts +5 -7
  40. package/src/client.tsx +82 -174
  41. package/src/index.rsc.ts +3 -0
  42. package/src/index.ts +40 -9
  43. package/src/outlet-context.ts +1 -1
  44. package/src/response-utils.ts +28 -0
  45. package/src/reverse.ts +7 -3
  46. package/src/route-definition/dsl-helpers.ts +175 -23
  47. package/src/route-definition/helpers-types.ts +63 -14
  48. package/src/route-definition/resolve-handler-use.ts +6 -0
  49. package/src/route-types.ts +7 -0
  50. package/src/router/handler-context.ts +24 -4
  51. package/src/router/lazy-includes.ts +6 -6
  52. package/src/router/loader-resolution.ts +3 -0
  53. package/src/router/manifest.ts +22 -13
  54. package/src/router/match-api.ts +3 -3
  55. package/src/router/middleware-types.ts +2 -22
  56. package/src/router/middleware.ts +54 -7
  57. package/src/router/pattern-matching.ts +60 -9
  58. package/src/router/revalidation.ts +15 -1
  59. package/src/router/segment-resolution/revalidation.ts +63 -58
  60. package/src/router/trie-matching.ts +10 -4
  61. package/src/router/url-params.ts +49 -0
  62. package/src/router.ts +1 -2
  63. package/src/rsc/handler.ts +8 -4
  64. package/src/rsc/helpers.ts +69 -41
  65. package/src/rsc/progressive-enhancement.ts +2 -0
  66. package/src/rsc/response-route-handler.ts +14 -1
  67. package/src/rsc/rsc-rendering.ts +7 -0
  68. package/src/rsc/server-action.ts +2 -0
  69. package/src/segment-content-promise.ts +67 -0
  70. package/src/segment-loader-promise.ts +122 -0
  71. package/src/segment-system.tsx +11 -61
  72. package/src/server/context.ts +26 -3
  73. package/src/server/request-context.ts +10 -42
  74. package/src/types/handler-context.ts +12 -39
  75. package/src/types/loader-types.ts +5 -6
  76. package/src/types/request-scope.ts +126 -0
  77. package/src/types/route-entry.ts +11 -0
  78. package/src/types/segments.ts +0 -1
  79. package/src/urls/include-helper.ts +24 -14
  80. package/src/urls/path-helper-types.ts +30 -4
  81. package/src/urls/response-types.ts +2 -10
  82. package/src/vite/debug.ts +184 -0
  83. package/src/vite/discovery/discover-routers.ts +31 -3
  84. package/src/vite/discovery/gate-state.ts +171 -0
  85. package/src/vite/discovery/prerender-collection.ts +48 -1
  86. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  87. package/src/vite/plugins/cjs-to-esm.ts +5 -0
  88. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  89. package/src/vite/plugins/client-ref-hashing.ts +16 -4
  90. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  91. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  92. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  93. package/src/vite/plugins/expose-action-id.ts +52 -28
  94. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  95. package/src/vite/plugins/expose-internal-ids.ts +516 -486
  96. package/src/vite/plugins/performance-tracks.ts +17 -9
  97. package/src/vite/plugins/use-cache-transform.ts +56 -43
  98. package/src/vite/plugins/version-injector.ts +37 -11
  99. package/src/vite/rango.ts +49 -14
  100. package/src/vite/router-discovery.ts +558 -53
  101. package/src/vite/utils/banner.ts +1 -1
  102. package/src/vite/utils/package-resolution.ts +41 -1
  103. package/src/vite/utils/prerender-utils.ts +20 -6
package/src/index.rsc.ts CHANGED
@@ -172,6 +172,9 @@ export type { PublicRequestContext as RequestContext } from "./server/request-co
172
172
  import type { PublicRequestContext } from "./server/request-context.js";
173
173
  import type { DefaultEnv } from "./types/global-namespace.js";
174
174
 
175
+ // Shared base for every user-facing request context (mirrors index.ts).
176
+ export type { RequestScope, ExecutionContext } from "./types/request-scope.js";
177
+
175
178
  export const getRequestContext: <
176
179
  TEnv = DefaultEnv,
177
180
  >() => PublicRequestContext<TEnv> = _getRequestContextInternal;
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
  /**
@@ -236,6 +264,9 @@ export function transition(): never {
236
264
  // Request context type (safe for client)
237
265
  export type { PublicRequestContext as RequestContext } from "./server/request-context.js";
238
266
 
267
+ // Shared base for every user-facing request context.
268
+ export type { RequestScope, ExecutionContext } from "./types/request-scope.js";
269
+
239
270
  // Cookie store types (safe for client)
240
271
  export type {
241
272
  CookieStore,
@@ -1,4 +1,4 @@
1
- import { Context, createContext, type ReactNode } from "react";
1
+ import { type Context, createContext, type ReactNode } from "react";
2
2
  import type { ResolvedSegment } from "./types";
3
3
 
4
4
  export interface OutletContextValue {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Runtime-neutral Response shape utilities.
3
+ *
4
+ * Kept at the src/ root so both `router/` and `rsc/` can depend on it
5
+ * without creating a cross-layer import cycle.
6
+ */
7
+
8
+ /**
9
+ * True when a Response represents a WebSocket upgrade handoff and must not
10
+ * be reconstructed or mutated:
11
+ *
12
+ * - Status 101 (Switching Protocols) is outside the standard Response
13
+ * constructor's 200–599 range, so `new Response(body, { status: 101 })`
14
+ * throws RangeError on Node/undici and any spec-compliant runtime.
15
+ * - Cloudflare's workerd attaches a non-standard `webSocket` property on
16
+ * the upgrade Response (e.g. from `acceptWebSocket`/`handleWebSocketUpgrade`
17
+ * or the `agents` library's `routeAgentRequest`). That property is dropped
18
+ * by a `new Response(...)` copy, breaking the upgrade even on workerd
19
+ * where the status range is relaxed.
20
+ *
21
+ * Callers should short-circuit header/body merges for these responses.
22
+ */
23
+ export function isWebSocketUpgradeResponse(response: Response): boolean {
24
+ return (
25
+ response.status === 101 ||
26
+ (response as unknown as { webSocket?: unknown }).webSocket != null
27
+ );
28
+ }
package/src/reverse.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ExtractParams } from "./types.js";
2
2
  import type { SearchSchema, ResolveSearchSchema } from "./search-params.js";
3
3
  import { serializeSearchParams } from "./search-params.js";
4
+ import { encodePathSegment } from "./router/url-params.js";
4
5
 
5
6
  /**
6
7
  * Sanitize prefix string by removing leading slash
@@ -311,11 +312,14 @@ export function createReverse<TRoutes extends Record<string, string>>(
311
312
  /:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(\?)/g,
312
313
  (_, key, _constraint, optional) => {
313
314
  const value = params[key];
314
- if (value === undefined) {
315
+ // Empty string is treated as omitted — the trie matcher fills
316
+ // unmatched optional params with "" (not undefined), so reverse
317
+ // must collapse those segments instead of leaving empty slots.
318
+ if (value === undefined || value === "") {
315
319
  hadOmittedOptional = true;
316
320
  return "";
317
321
  }
318
- return encodeURIComponent(value);
322
+ return encodePathSegment(value);
319
323
  },
320
324
  );
321
325
  // Second pass: required params (no trailing ?)
@@ -326,7 +330,7 @@ export function createReverse<TRoutes extends Record<string, string>>(
326
330
  if (value === undefined) {
327
331
  throw new Error(`Missing param "${key}" for route "${name}"`);
328
332
  }
329
- return encodeURIComponent(value);
333
+ return encodePathSegment(value);
330
334
  },
331
335
  );
332
336
  // Clean up slashes only when an optional param was actually omitted,
@@ -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
 
@@ -301,6 +304,15 @@ const cache: RouteHelpers<any, any>["cache"] = (
301
304
  return { name: namespace, type: "cache" } as CacheItem;
302
305
  }
303
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
+
304
316
  // With children: create a cache entry (like layout with caching semantics)
305
317
  const namespace = `${ctx.namespace}.${cacheIndex}`;
306
318
  const cacheShortCode = store.getShortCode("cache");
@@ -353,10 +365,37 @@ const cache: RouteHelpers<any, any>["cache"] = (
353
365
  return { name: namespace, type: "cache", uses: result } as CacheItem;
354
366
  };
355
367
 
356
- 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
+
357
397
  // 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) {
398
+ for (const f of fns) {
360
399
  if (isCachedFunction(f)) {
361
400
  throw new Error(
362
401
  `A "use cache" function cannot be used as middleware. ` +
@@ -367,17 +406,80 @@ const middleware: RouteHelpers<any, any>["middleware"] = (...fn) => {
367
406
  }
368
407
  }
369
408
 
370
- const ctx = getContext().getStore();
409
+ const store = getContext();
410
+ const ctx = store.getStore();
371
411
  if (!ctx) throw new Error("middleware() must be called inside map()");
372
412
 
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()");
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;
377
422
  }
378
- const name = `$${getContext().getNextIndex("middleware")}`;
379
- parent.middleware.push(...fn);
380
- 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;
381
483
  };
382
484
 
383
485
  const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
@@ -398,13 +500,25 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
398
500
 
399
501
  const namespace = `${ctx.namespace}.$${store.getNextIndex("parallel")}`;
400
502
 
401
- // 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.
402
510
  const unwrappedSlots: Record<string, any> = {};
511
+ const slotLocalUses: Record<string, (() => any[]) | undefined> = {};
403
512
  let hasStaticSlot = false;
404
513
  const staticSlotIds: Record<string, string> = {};
405
- for (const [slotName, slotHandler] of Object.entries(
514
+ for (const [slotName, rawSlot] of Object.entries(
406
515
  slots as Record<string, any>,
407
516
  )) {
517
+ let slotHandler: any = rawSlot;
518
+ if (isSlotDescriptor(rawSlot)) {
519
+ slotHandler = rawSlot.handler;
520
+ slotLocalUses[slotName] = rawSlot.use;
521
+ }
408
522
  if (isStaticHandler(slotHandler)) {
409
523
  hasStaticSlot = true;
410
524
  unwrappedSlots[slotName] = slotHandler.handler;
@@ -471,13 +585,25 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
471
585
  }),
472
586
  } satisfies EntryData;
473
587
 
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");
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",
606
+ );
481
607
  if (slotMergedUse) {
482
608
  const result = store.run(namespace, slotEntry, slotMergedUse)?.flat(3);
483
609
  invariant(
@@ -491,6 +617,28 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
491
617
  return { name: namespace, type: "parallel" } as ParallelItem;
492
618
  };
493
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
+
494
642
  /**
495
643
  * Intercept helper - defines an intercepting route for soft navigation
496
644
  */
@@ -611,8 +759,12 @@ const loaderFn: RouteHelpers<any, any>["loader"] = (loaderDef, use) => {
611
759
  revalidate: [] as ShouldRevalidateFn<any, any>[],
612
760
  };
613
761
 
614
- // If use() callback provided, run it to collect revalidation rules and cache config
615
- 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) {
616
768
  // Temporarily set context for revalidate()/cache() calls to target this loader
617
769
  const originalParent = ctx.parent;
618
770
  // Create a temporary "parent" with type "loader" so cache() can detect it.
@@ -625,7 +777,7 @@ const loaderFn: RouteHelpers<any, any>["loader"] = (loaderDef, use) => {
625
777
  };
626
778
  ctx.parent = tempParent as EntryData;
627
779
 
628
- const result = use()?.flat(3);
780
+ const result = mergedUse()?.flat(3);
629
781
 
630
782
  // Copy cache config only if cache() was called during the use() callback.
631
783
  // The spread from originalParent may carry an inherited .cache from
@@ -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
@@ -215,7 +259,12 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
215
259
  * ({ defaultShouldRevalidate: true })
216
260
  * )
217
261
  * ```
218
- * @param fn - Function that returns boolean (hard) or { defaultShouldRevalidate } (soft)
262
+ * @param fn - Function returning either:
263
+ * - `boolean` (hard decision — short-circuits the chain),
264
+ * - `{ defaultShouldRevalidate: boolean }` (soft — updates the suggestion
265
+ * for downstream revalidators),
266
+ * - or nothing / `null` / `undefined` (defer — leaves the suggestion
267
+ * unchanged and continues to the next revalidator).
219
268
  */
220
269
  revalidate: (fn: ShouldRevalidateFn<any, TEnv>) => RevalidateItem;
221
270
  /**
@@ -21,6 +21,10 @@ export function resolveHandlerUse(handler: unknown): (() => any[]) | undefined {
21
21
  if (isStaticHandler(handler)) {
22
22
  return (handler as any).use;
23
23
  }
24
+ // Loader definitions from createLoader() — branded objects with optional .use
25
+ if (typeof handler === "object" && (handler as any).__brand === "loader") {
26
+ return (handler as any).use;
27
+ }
24
28
  // Plain handler function
25
29
  if (typeof handler === "function") {
26
30
  return (handler as any).use;
@@ -99,6 +103,8 @@ const MOUNT_SITE_ALLOWED_TYPES: Record<string, Set<string>> = {
99
103
  "when",
100
104
  "transition",
101
105
  ]),
106
+ // LoaderUseItem — only revalidate + cache can attach to a loader entry
107
+ loader: new Set(["revalidate", "cache"]),
102
108
  };
103
109
 
104
110
  /**
@@ -176,6 +176,13 @@ export type IncludeItem = {
176
176
  >;
177
177
  /** Root scope flag for dot-local reverse resolution */
178
178
  rootScoped?: boolean;
179
+ /**
180
+ * Positional include scope token composed from the parent scope plus this
181
+ * include's sibling index (`${parentScope}I${idx}`). Applied to direct-
182
+ * descendant shortCodes during lazy evaluation so routes inside the
183
+ * include cannot collide with siblings declared outside it.
184
+ */
185
+ includeScope?: string;
179
186
  };
180
187
  [IncludeBrand]: void;
181
188
  };
@@ -18,6 +18,8 @@ import { isInsideCacheScope } from "../server/context.js";
18
18
  import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
19
19
  import { isAutoGeneratedRouteName } from "../route-name.js";
20
20
  import { PRERENDER_PASSTHROUGH } from "../prerender.js";
21
+ import { encodePathSegment } from "./url-params.js";
22
+ import { fireAndForgetWaitUntil } from "../types/request-scope.js";
21
23
 
22
24
  /**
23
25
  * Strip internal _rsc* query params from a URL.
@@ -174,11 +176,14 @@ export function createReverseFunction(
174
176
  /:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(\?)/g,
175
177
  (_, key) => {
176
178
  const value = effectiveParams[key];
177
- if (value === undefined) {
179
+ // Empty string is treated as omitted — the trie matcher fills
180
+ // unmatched optional params with "" (not undefined), so reverse
181
+ // must collapse those segments instead of leaving empty slots.
182
+ if (value === undefined || value === "") {
178
183
  hadOmittedOptional = true;
179
184
  return "";
180
185
  }
181
- return encodeURIComponent(value);
186
+ return encodePathSegment(value);
182
187
  },
183
188
  );
184
189
  // Second pass: required params (no trailing ?)
@@ -189,7 +194,7 @@ export function createReverseFunction(
189
194
  if (value === undefined) {
190
195
  throw new Error(`Missing param "${key}" for route "${name}"`);
191
196
  }
192
- return encodeURIComponent(value);
197
+ return encodePathSegment(value);
193
198
  },
194
199
  );
195
200
  // Clean up slashes only when an optional param was actually omitted,
@@ -278,8 +283,12 @@ export function createHandlerContext<TEnv>(
278
283
  search: searchSchema ? resolvedSearchParams : {},
279
284
  pathname,
280
285
  url,
281
- originalUrl: new URL(request.url),
286
+ originalUrl: requestContext?.originalUrl ?? new URL(request.url),
282
287
  env: bindings,
288
+ waitUntil: requestContext
289
+ ? requestContext.waitUntil.bind(requestContext)
290
+ : fireAndForgetWaitUntil,
291
+ executionContext: requestContext?.executionContext,
283
292
  _variables: variables,
284
293
  get: ((keyOrVar: any) => {
285
294
  // Read-time guard: non-cacheable var inside cache() → throw.
@@ -384,6 +393,12 @@ export function createPrerenderContext<TEnv>(
384
393
  "Configure buildEnv in your rango() plugin options to enable build-time env access.",
385
394
  );
386
395
  },
396
+ // Build-time prerender has no live request. waitUntil is a true no-op
397
+ // (running fn() here would fire side effects during build, which is
398
+ // incorrect — these are meant to outlive the live response).
399
+ // executionContext is absent for the same reason.
400
+ waitUntil: () => {},
401
+ executionContext: undefined,
387
402
  _variables: variables,
388
403
  get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
389
404
  set: ((keyOrVar: any, value: any) => {
@@ -473,6 +488,11 @@ export function createStaticContext<TEnv>(
473
488
  "Configure buildEnv in your rango() plugin options to enable build-time env access.",
474
489
  );
475
490
  },
491
+ // Static() handlers have no live request. waitUntil is a true no-op
492
+ // (running fn() here would fire side effects during build, which is
493
+ // incorrect). executionContext is absent for the same reason.
494
+ waitUntil: () => {},
495
+ executionContext: undefined,
476
496
  _variables: variables,
477
497
  get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
478
498
  set: ((keyOrVar: any, value: any) => {