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

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 (112) hide show
  1. package/README.md +112 -17
  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 +33 -20
  8. package/skills/intercept/SKILL.md +20 -0
  9. package/skills/layout/SKILL.md +22 -0
  10. package/skills/links/SKILL.md +90 -16
  11. package/skills/loader/SKILL.md +70 -3
  12. package/skills/middleware/SKILL.md +34 -3
  13. package/skills/migrate-nextjs/SKILL.md +562 -0
  14. package/skills/migrate-react-router/SKILL.md +769 -0
  15. package/skills/parallel/SKILL.md +66 -0
  16. package/skills/rango/SKILL.md +25 -22
  17. package/skills/response-routes/SKILL.md +8 -0
  18. package/skills/route/SKILL.md +24 -0
  19. package/skills/server-actions/SKILL.md +739 -0
  20. package/skills/streams-and-websockets/SKILL.md +283 -0
  21. package/skills/typesafety/SKILL.md +3 -1
  22. package/src/browser/app-shell.ts +52 -0
  23. package/src/browser/event-controller.ts +44 -4
  24. package/src/browser/navigation-bridge.ts +71 -5
  25. package/src/browser/navigation-client.ts +64 -13
  26. package/src/browser/navigation-store.ts +25 -1
  27. package/src/browser/partial-update.ts +34 -3
  28. package/src/browser/prefetch/cache.ts +129 -21
  29. package/src/browser/prefetch/fetch.ts +148 -16
  30. package/src/browser/prefetch/queue.ts +36 -5
  31. package/src/browser/rango-state.ts +53 -13
  32. package/src/browser/react/Link.tsx +30 -2
  33. package/src/browser/react/NavigationProvider.tsx +70 -18
  34. package/src/browser/react/filter-segment-order.ts +51 -7
  35. package/src/browser/react/use-navigation.ts +22 -2
  36. package/src/browser/react/use-params.ts +11 -1
  37. package/src/browser/react/use-router.ts +8 -1
  38. package/src/browser/react/use-segments.ts +11 -8
  39. package/src/browser/rsc-router.tsx +34 -6
  40. package/src/browser/segment-reconciler.ts +36 -14
  41. package/src/browser/types.ts +19 -0
  42. package/src/build/route-trie.ts +50 -24
  43. package/src/cache/cf/cf-cache-store.ts +5 -7
  44. package/src/client.tsx +82 -174
  45. package/src/index.rsc.ts +3 -0
  46. package/src/index.ts +40 -9
  47. package/src/outlet-context.ts +1 -1
  48. package/src/response-utils.ts +28 -0
  49. package/src/reverse.ts +7 -3
  50. package/src/route-definition/dsl-helpers.ts +175 -23
  51. package/src/route-definition/helpers-types.ts +63 -14
  52. package/src/route-definition/resolve-handler-use.ts +6 -0
  53. package/src/route-types.ts +7 -0
  54. package/src/router/handler-context.ts +24 -4
  55. package/src/router/lazy-includes.ts +6 -6
  56. package/src/router/loader-resolution.ts +3 -0
  57. package/src/router/manifest.ts +22 -13
  58. package/src/router/match-api.ts +4 -3
  59. package/src/router/match-handlers.ts +1 -0
  60. package/src/router/match-result.ts +21 -2
  61. package/src/router/middleware-types.ts +2 -22
  62. package/src/router/middleware.ts +54 -7
  63. package/src/router/pattern-matching.ts +87 -17
  64. package/src/router/revalidation.ts +15 -1
  65. package/src/router/segment-resolution/fresh.ts +8 -0
  66. package/src/router/segment-resolution/revalidation.ts +128 -100
  67. package/src/router/trie-matching.ts +18 -13
  68. package/src/router/url-params.ts +49 -0
  69. package/src/router.ts +1 -2
  70. package/src/rsc/handler.ts +8 -4
  71. package/src/rsc/helpers.ts +69 -41
  72. package/src/rsc/progressive-enhancement.ts +4 -0
  73. package/src/rsc/response-route-handler.ts +14 -1
  74. package/src/rsc/rsc-rendering.ts +10 -0
  75. package/src/rsc/server-action.ts +4 -0
  76. package/src/rsc/types.ts +6 -0
  77. package/src/segment-content-promise.ts +67 -0
  78. package/src/segment-loader-promise.ts +122 -0
  79. package/src/segment-system.tsx +11 -61
  80. package/src/server/context.ts +26 -3
  81. package/src/server/request-context.ts +10 -42
  82. package/src/ssr/index.tsx +5 -1
  83. package/src/types/handler-context.ts +12 -39
  84. package/src/types/loader-types.ts +5 -6
  85. package/src/types/request-scope.ts +126 -0
  86. package/src/types/route-entry.ts +11 -0
  87. package/src/types/segments.ts +17 -1
  88. package/src/urls/include-helper.ts +24 -14
  89. package/src/urls/path-helper-types.ts +30 -4
  90. package/src/urls/response-types.ts +2 -10
  91. package/src/vite/debug.ts +184 -0
  92. package/src/vite/discovery/discover-routers.ts +31 -3
  93. package/src/vite/discovery/gate-state.ts +171 -0
  94. package/src/vite/discovery/prerender-collection.ts +48 -1
  95. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  96. package/src/vite/plugins/cjs-to-esm.ts +5 -0
  97. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  98. package/src/vite/plugins/client-ref-hashing.ts +16 -4
  99. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  100. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  101. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  102. package/src/vite/plugins/expose-action-id.ts +52 -28
  103. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  104. package/src/vite/plugins/expose-internal-ids.ts +516 -486
  105. package/src/vite/plugins/performance-tracks.ts +17 -9
  106. package/src/vite/plugins/use-cache-transform.ts +56 -43
  107. package/src/vite/plugins/version-injector.ts +37 -11
  108. package/src/vite/rango.ts +49 -14
  109. package/src/vite/router-discovery.ts +558 -53
  110. package/src/vite/utils/banner.ts +1 -1
  111. package/src/vite/utils/package-resolution.ts +41 -1
  112. package/src/vite/utils/prerender-utils.ts +20 -6
@@ -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) => {
@@ -1,7 +1,7 @@
1
1
  import { registerRouteMap } from "../route-map-builder.js";
2
2
  import { extractStaticPrefix } from "./pattern-matching.js";
3
3
  import {
4
- EntryData,
4
+ type EntryData,
5
5
  RSCRouterContext,
6
6
  runWithPrefixes,
7
7
  getIsolatedLazyParent,
@@ -125,9 +125,8 @@ export function evaluateLazyEntry<TEnv = any>(
125
125
  // Merge captured counters from include() to maintain consistent
126
126
  // shortCode indices with sibling entries from pattern extraction
127
127
  const lazyCounters: Record<string, number> = {};
128
- if (lazyContext && (lazyContext as any).counters) {
129
- const captured = (lazyContext as any).counters as Record<string, number>;
130
- for (const [key, value] of Object.entries(captured)) {
128
+ if (lazyContext?.counters) {
129
+ for (const [key, value] of Object.entries(lazyContext.counters)) {
131
130
  lazyCounters[key] = value;
132
131
  }
133
132
  }
@@ -141,8 +140,9 @@ export function evaluateLazyEntry<TEnv = any>(
141
140
  namespace: "lazy",
142
141
  parent: getIsolatedLazyParent(lazyContext?.parent as EntryData | null),
143
142
  counters: lazyCounters,
144
- cacheProfiles: (lazyContext as any)?.cacheProfiles,
145
- rootScoped: (lazyContext as any)?.rootScoped,
143
+ cacheProfiles: lazyContext?.cacheProfiles,
144
+ rootScoped: lazyContext?.rootScoped,
145
+ includeScope: lazyContext?.includeScope,
146
146
  },
147
147
  () => {
148
148
  // Run the lazy patterns handler with the original context prefixes
@@ -266,7 +266,10 @@ function createLoaderExecutor<TEnv>(
266
266
  search: (ctx as any).search,
267
267
  pathname: ctx.pathname,
268
268
  url: ctx.url,
269
+ originalUrl: ctx.originalUrl,
269
270
  env: ctx.env,
271
+ waitUntil: ctx.waitUntil.bind(ctx),
272
+ executionContext: ctx.executionContext,
270
273
  get: ((keyOrVar: any) =>
271
274
  contextGet(variables, keyOrVar)) as typeof ctx.get,
272
275
  use: ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
@@ -126,28 +126,37 @@ export async function loadManifest(
126
126
  // were created during pattern extraction. This prevents shortCode
127
127
  // collisions between lazy and non-lazy entries under the same parent
128
128
  // (e.g., ArticlesLayout and BlogLayout both under NavLayout).
129
- if (lazyContext && (lazyContext as any).counters) {
130
- const captured = (lazyContext as any).counters as Record<string, number>;
131
- for (const [key, value] of Object.entries(captured)) {
129
+ if (lazyContext?.counters) {
130
+ for (const [key, value] of Object.entries(lazyContext.counters)) {
132
131
  Store.counters[key] = Math.max(Store.counters[key] ?? 0, value);
133
132
  }
134
133
  }
135
134
 
136
135
  // Propagate cache profiles for DSL-time cache("profileName") resolution.
137
136
  // Non-lazy entries carry profiles directly; lazy entries carry them
138
- // in the captured lazyContext from include() time.
139
- const entryProfiles =
140
- entry.cacheProfiles ?? (lazyContext as any)?.cacheProfiles;
141
- if (entryProfiles) {
142
- Store.cacheProfiles = entryProfiles;
143
- }
137
+ // in the captured lazyContext from include() time. Always write
138
+ // (including clearing to undefined) so a prior lazy build's profile
139
+ // map cannot leak into a later non-lazy build on the same ALS-backed
140
+ // Store — which would otherwise let cache("name") resolve a profile
141
+ // from an unrelated entry.
142
+ Store.cacheProfiles = entry.cacheProfiles ?? lazyContext?.cacheProfiles;
144
143
 
145
144
  // Propagate rootScoped from lazyContext so that routes inside
146
145
  // nested { name: "sub" } under { name: "" } keep inherited root scope
147
- // when the manifest is rebuilt on each request.
148
- if (lazyContext && (lazyContext as any).rootScoped !== undefined) {
149
- Store.rootScoped = (lazyContext as any).rootScoped;
150
- }
146
+ // when the manifest is rebuilt on each request. Always write
147
+ // (including clearing to undefined, which makes getRootScoped()
148
+ // return its true default) so a prior lazy build's scope cannot leak
149
+ // into a later non-lazy build on the same ALS-backed Store — which
150
+ // would otherwise mis-register plain routes as non-root-scoped and
151
+ // break dot-local reverse resolution.
152
+ Store.rootScoped = lazyContext?.rootScoped;
153
+
154
+ // Propagate includeScope from lazyContext so that direct-descendant
155
+ // shortCodes of this include use the correct scoped counter namespace
156
+ // on every manifest rebuild. Always write (including clearing to
157
+ // undefined) so a prior lazy build's scope cannot leak into a later
158
+ // non-lazy build on the same ALS-backed Store.
159
+ Store.includeScope = lazyContext?.includeScope;
151
160
 
152
161
  const handlerExecStart = performance.now();
153
162
  const useItems = await getContext().runWithStore(
@@ -22,10 +22,10 @@ import { collectRouteMiddleware } from "./middleware.js";
22
22
  import { traverseBack } from "./pattern-matching.js";
23
23
  import { DefaultErrorFallback } from "../default-error-boundary.js";
24
24
  import {
25
- EntryData,
26
- LoaderEntry,
25
+ type EntryData,
26
+ type LoaderEntry,
27
27
  getContext,
28
- InterceptSelectorContext,
28
+ type InterceptSelectorContext,
29
29
  } from "../server/context";
30
30
  import type { ErrorBoundaryHandler, ErrorInfo, MatchResult } from "../types";
31
31
  import type { ReactNode } from "react";
@@ -550,6 +550,7 @@ export async function matchError<TEnv>(
550
550
  segments: [errorSegment],
551
551
  matched: matchedIds,
552
552
  diff: [errorSegment.id],
553
+ resolvedIds: [errorSegment.id],
553
554
  params: matched.params,
554
555
  };
555
556
  }
@@ -196,6 +196,7 @@ export function createMatchHandlers<TEnv = any>(
196
196
  segments: [],
197
197
  matched: [],
198
198
  diff: [],
199
+ resolvedIds: [],
199
200
  params: {},
200
201
  redirect: result.redirectUrl,
201
202
  };
@@ -270,10 +270,29 @@ export function buildMatchResult<TEnv>(
270
270
  const matchedIds =
271
271
  removedIds.size > 0 ? allIds.filter((id) => !removedIds.has(id)) : allIds;
272
272
 
273
+ // resolvedIds: every segment whose handler actually ran this request.
274
+ // For full-match every segment is fresh; for partial-match we filter by
275
+ // the internal `_handlerRan` flag set in revalidation.ts. Drives the
276
+ // client's handle-bucket cleanup — a slot that re-resolved and pushed
277
+ // nothing must have its previous handle data cleared, but `diff` won't
278
+ // carry it because the segment payload skips null-component cached
279
+ // segments to save bytes.
280
+ const resolvedIds = ctx.isFullMatch
281
+ ? allSegments.map((s) => s.id)
282
+ : allSegments.filter((s) => s._handlerRan).map((s) => s.id);
283
+
284
+ // Strip internal-only fields from the segments going on the wire.
285
+ const cleanedSegments = dedupedSegments.map((s) => {
286
+ if (s._handlerRan === undefined) return s;
287
+ const { _handlerRan: _drop, ...rest } = s;
288
+ return rest as ResolvedSegment;
289
+ });
290
+
273
291
  return {
274
- segments: dedupedSegments,
292
+ segments: cleanedSegments,
275
293
  matched: matchedIds,
276
- diff: dedupedSegments.map((s) => s.id),
294
+ diff: cleanedSegments.map((s) => s.id),
295
+ resolvedIds,
277
296
  params: ctx.matched.params,
278
297
  routeName: ctx.routeKey,
279
298
  slots: Object.keys(state.slots).length > 0 ? state.slots : undefined,