@rangojs/router 0.0.0-experimental.88a3b2f7 → 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 (102) hide show
  1. package/README.md +50 -20
  2. package/dist/vite/index.js +647 -176
  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 +35 -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 +59 -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 +72 -4
  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 +84 -230
  41. package/src/index.rsc.ts +3 -0
  42. package/src/index.ts +44 -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 +180 -24
  47. package/src/route-definition/helpers-types.ts +61 -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 +73 -46
  53. package/src/router/manifest.ts +22 -13
  54. package/src/router/match-api.ts +3 -3
  55. package/src/router/match-middleware/cache-lookup.ts +10 -5
  56. package/src/router/match-middleware/segment-resolution.ts +1 -1
  57. package/src/router/match-result.ts +82 -4
  58. package/src/router/middleware-types.ts +2 -22
  59. package/src/router/middleware.ts +32 -4
  60. package/src/router/pattern-matching.ts +60 -9
  61. package/src/router/segment-resolution/fresh.ts +52 -0
  62. package/src/router/segment-resolution/revalidation.ts +69 -1
  63. package/src/router/trie-matching.ts +10 -4
  64. package/src/router/url-params.ts +49 -0
  65. package/src/router.ts +1 -2
  66. package/src/rsc/handler.ts +21 -9
  67. package/src/rsc/helpers.ts +69 -41
  68. package/src/rsc/loader-fetch.ts +23 -3
  69. package/src/rsc/progressive-enhancement.ts +12 -2
  70. package/src/rsc/response-route-handler.ts +14 -1
  71. package/src/rsc/rsc-rendering.ts +12 -1
  72. package/src/rsc/server-action.ts +8 -0
  73. package/src/rsc/types.ts +1 -0
  74. package/src/segment-content-promise.ts +67 -0
  75. package/src/segment-loader-promise.ts +122 -0
  76. package/src/segment-system.tsx +11 -61
  77. package/src/server/context.ts +26 -3
  78. package/src/server/handle-store.ts +19 -0
  79. package/src/server/request-context.ts +64 -56
  80. package/src/types/handler-context.ts +2 -34
  81. package/src/types/loader-types.ts +5 -6
  82. package/src/types/request-scope.ts +126 -0
  83. package/src/types/route-entry.ts +11 -0
  84. package/src/types/segments.ts +1 -1
  85. package/src/urls/include-helper.ts +24 -14
  86. package/src/urls/path-helper-types.ts +34 -5
  87. package/src/urls/response-types.ts +2 -10
  88. package/src/use-loader.tsx +77 -5
  89. package/src/vite/debug.ts +55 -0
  90. package/src/vite/discovery/prerender-collection.ts +124 -83
  91. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  92. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  93. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  94. package/src/vite/plugins/expose-id-utils.ts +12 -0
  95. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  96. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  97. package/src/vite/plugins/performance-tracks.ts +4 -6
  98. package/src/vite/rango.ts +49 -14
  99. package/src/vite/router-discovery.ts +186 -26
  100. package/src/vite/utils/banner.ts +1 -1
  101. package/src/vite/utils/package-resolution.ts +41 -1
  102. package/src/vite/utils/prerender-utils.ts +20 -6
@@ -21,7 +21,7 @@ import type {
21
21
  } from "../types";
22
22
  import type { LoaderRevalidationResult, ActionContext } from "./types";
23
23
  import { isHandle, collectHandleData, type Handle } from "../handle.js";
24
- import type { HandleStore, HandleData } from "../server/handle-store.js";
24
+ import { buildHandleSnapshot } from "../server/handle-store.js";
25
25
  import { getFetchableLoader } from "../server/fetchable-loader-store.js";
26
26
  import { _getRequestContext } from "../server/request-context.js";
27
27
  import { isInsideLoaderScope } from "../server/context.js";
@@ -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>) => {
@@ -284,10 +287,9 @@ function createLoaderExecutor<TEnv>(
284
287
  );
285
288
  }
286
289
  const segmentOrder = reqCtx._renderBarrierSegmentOrder ?? [];
287
- const snapshot = buildHandleSnapshot(
288
- reqCtx._handleStore,
289
- segmentOrder,
290
- );
290
+ const snapshot =
291
+ reqCtx._renderBarrierHandleSnapshot ??
292
+ buildHandleSnapshot(reqCtx._handleStore, segmentOrder);
291
293
  return collectHandleData(item, snapshot, segmentOrder);
292
294
  }
293
295
 
@@ -324,6 +326,17 @@ function createLoaderExecutor<TEnv>(
324
326
  );
325
327
  }
326
328
 
329
+ // Bidirectional deadlock check: if a handler already started
330
+ // awaiting this loader, calling rendered() would deadlock.
331
+ if (reqCtx._handlerLoaderDeps?.has(currentLoaderId)) {
332
+ throw new Error(
333
+ `Deadlock: loader "${currentLoaderId}" called ctx.rendered() but a handler ` +
334
+ `is already awaiting this loader via ctx.use(). The handler blocks ` +
335
+ `segment resolution, which blocks the barrier, which blocks this loader. ` +
336
+ `Move the data dependency to a loader-to-loader pattern instead.`,
337
+ );
338
+ }
339
+
327
340
  // Register this loader as waiting for the barrier so that
328
341
  // setupLoaderAccess can detect deadlocks when a handler
329
342
  // tries to await the same loader via ctx.use().
@@ -354,25 +367,6 @@ function createLoaderExecutor<TEnv>(
354
367
  return useLoader;
355
368
  }
356
369
 
357
- /**
358
- * Build a HandleData snapshot from the HandleStore using segment ordering.
359
- * Reads data directly from the store for each segment in order.
360
- */
361
- function buildHandleSnapshot(
362
- handleStore: HandleStore,
363
- segmentOrder: string[],
364
- ): HandleData {
365
- const data: HandleData = {};
366
- for (const segmentId of segmentOrder) {
367
- const segData = handleStore.getDataForSegment(segmentId);
368
- for (const handleName in segData) {
369
- if (!data[handleName]) data[handleName] = {};
370
- data[handleName][segmentId] = segData[handleName];
371
- }
372
- }
373
- return data;
374
- }
375
-
376
370
  /**
377
371
  * Set up the use() method on handler context to access loaders and handles.
378
372
  *
@@ -386,15 +380,22 @@ export function setupLoaderAccess<TEnv>(
386
380
  ctx: HandlerContext<any, TEnv>,
387
381
  loaderPromises: Map<string, Promise<any>>,
388
382
  ): void {
389
- // Eagerly capture the HandleStore at setup time (before pipeline async ops).
390
- // In workerd/Cloudflare, dynamic imports and fetch() in the match pipeline
391
- // can disrupt AsyncLocalStorage, causing getRequestContext() to return
392
- // undefined when handlers later call ctx.use(handle). Capturing early
393
- // ensures the store reference survives ALS disruption.
394
- const handleStoreRef = _getRequestContext()?._handleStore;
383
+ // Eagerly capture the request context and HandleStore at setup time
384
+ // (before pipeline async ops). In workerd/Cloudflare, dynamic imports and
385
+ // fetch() in the match pipeline can disrupt AsyncLocalStorage, causing
386
+ // getRequestContext() to return undefined when handlers later call
387
+ // ctx.use(handle). Capturing early ensures references survive ALS disruption.
388
+ const reqCtxRef = _getRequestContext();
389
+ const handleStoreRef = reqCtxRef?._handleStore;
395
390
 
396
391
  const useLoader = createLoaderExecutor(ctx, loaderPromises);
397
392
 
393
+ // Track whether we're inside a handle push callback. Loaders started
394
+ // from push callbacks (e.g. push(async () => ctx.use(Loader))) do NOT
395
+ // block segment resolution, so they must not be registered as handler
396
+ // dependencies for deadlock detection.
397
+ let insideHandlePush = false;
398
+
398
399
  ctx.use = ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
399
400
  if (isHandle(item)) {
400
401
  const handle = item;
@@ -414,27 +415,53 @@ export function setupLoaderAccess<TEnv>(
414
415
  ) => {
415
416
  if (!store) return;
416
417
 
417
- const valueOrPromise =
418
- typeof dataOrFn === "function"
419
- ? (dataOrFn as () => Promise<unknown>)()
420
- : dataOrFn;
418
+ if (typeof dataOrFn === "function") {
419
+ // Mark scope so ctx.use(loader) calls inside the callback
420
+ // are not registered as handler-to-loader deps.
421
+ insideHandlePush = true;
422
+ try {
423
+ const result = (dataOrFn as () => Promise<unknown>)();
424
+ store.push(handle.$$id, segmentId, result);
425
+ } finally {
426
+ insideHandlePush = false;
427
+ }
428
+ return;
429
+ }
421
430
 
422
- store.push(handle.$$id, segmentId, valueOrPromise);
431
+ store.push(handle.$$id, segmentId, dataOrFn);
423
432
  };
424
433
  }
425
434
 
426
- // Deadlock guard: if the loader has called rendered(), it is waiting
427
- // for segment resolution to complete. A handler awaiting that loader
428
- // blocks segment resolution, creating a deadlock.
435
+ // Deadlock guard and handler-to-loader dependency tracking.
436
+ // Skip when inside a DSL loader scope (resolveLoaderData also calls
437
+ // ctx.use() but that's DSL-to-DSL, not handler-to-loader) or when
438
+ // inside a handle push callback (push callbacks don't block segment
439
+ // resolution so they can't cause rendered() deadlocks).
429
440
  const loader = item as LoaderDefinition<any, any>;
430
- if (loaderPromises.has(loader.$$id)) {
431
- const reqCtx = _getRequestContext();
432
- if (reqCtx?._renderBarrierWaiters?.has(loader.$$id)) {
433
- throw new Error(
434
- `Deadlock: handler is awaiting loader "${loader.$$id}" which called ctx.rendered(). ` +
435
- `The loader is waiting for segment resolution, but the handler blocks resolution. ` +
436
- `Move the data dependency to a loader-to-loader pattern instead.`,
437
- );
441
+ if (!isInsideLoaderScope() && !insideHandlePush) {
442
+ const reqCtx = reqCtxRef ?? _getRequestContext();
443
+ if (reqCtx) {
444
+ // Direction 1: handler awaits loader that already called rendered()
445
+ if (
446
+ loaderPromises.has(loader.$$id) &&
447
+ reqCtx._renderBarrierWaiters?.has(loader.$$id)
448
+ ) {
449
+ throw new Error(
450
+ `Deadlock: handler is awaiting loader "${loader.$$id}" which called ctx.rendered(). ` +
451
+ `The loader is waiting for segment resolution, but the handler blocks resolution. ` +
452
+ `Move the data dependency to a loader-to-loader pattern instead.`,
453
+ );
454
+ }
455
+ // Direction 2: track dep so rendered() can detect the deadlock
456
+ // if the loader calls it later. Skip when the barrier has already
457
+ // resolved — no deadlock is possible (rendered() resolves immediately).
458
+ // _renderBarrierSegmentOrder is undefined before resolution, string[]
459
+ // after. This also prevents false positives from handle push callbacks
460
+ // that resume after their first await (post-barrier-resolution).
461
+ if (reqCtx._renderBarrierSegmentOrder === undefined) {
462
+ if (!reqCtx._handlerLoaderDeps) reqCtx._handlerLoaderDeps = new Set();
463
+ reqCtx._handlerLoaderDeps.add(loader.$$id);
464
+ }
438
465
  }
439
466
  }
440
467
 
@@ -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";
@@ -194,11 +194,13 @@ async function* yieldFromStore<TEnv>(
194
194
  state.cachedSegments = segments;
195
195
  state.cachedMatchedIds = segments.map((s) => s.id);
196
196
 
197
- // Set streaming flag and resolve render barrier.
197
+ // Set streaming flag (once) and resolve render barrier.
198
198
  const reqCtx = handleStoreRef ? undefined : _lazyGetRequestContext?.();
199
199
  const barrierReqCtx = reqCtx ?? _getRequestContext();
200
200
  if (barrierReqCtx) {
201
- barrierReqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
201
+ if (barrierReqCtx._treeHasStreaming === undefined) {
202
+ barrierReqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
203
+ }
202
204
  barrierReqCtx._resolveRenderBarrier(segments);
203
205
  }
204
206
 
@@ -249,6 +251,7 @@ async function* yieldFromStore<TEnv>(
249
251
  ctx.url,
250
252
  ctx.routeKey,
251
253
  ctx.actionContext,
254
+ ctx.stale || undefined,
252
255
  ),
253
256
  );
254
257
  state.matchedIds = [
@@ -596,7 +599,7 @@ export function withCacheLookup<TEnv>(
596
599
  routeKey: ctx.routeKey,
597
600
  context: ctx.handlerContext,
598
601
  actionContext: ctx.actionContext,
599
- stale: cacheResult.shouldRevalidate || undefined,
602
+ stale: cacheResult.shouldRevalidate || ctx.stale || undefined,
600
603
  traceSource: "cache-hit",
601
604
  });
602
605
 
@@ -623,10 +626,12 @@ export function withCacheLookup<TEnv>(
623
626
  yield segment;
624
627
  }
625
628
 
626
- // Set streaming flag and resolve render barrier.
629
+ // Set streaming flag (once) and resolve render barrier.
627
630
  const barrierReqCtx = _getRequestContext();
628
631
  if (barrierReqCtx) {
629
- barrierReqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
632
+ if (barrierReqCtx._treeHasStreaming === undefined) {
633
+ barrierReqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
634
+ }
630
635
  barrierReqCtx._resolveRenderBarrier(cacheResult.segments);
631
636
  }
632
637
 
@@ -168,7 +168,7 @@ export function withSegmentResolution<TEnv>(
168
168
  }
169
169
 
170
170
  const reqCtx = _getRequestContext();
171
- if (reqCtx) {
171
+ if (reqCtx && reqCtx._treeHasStreaming === undefined) {
172
172
  reqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
173
173
  }
174
174
 
@@ -125,6 +125,69 @@ export async function collectSegments(
125
125
  return segments;
126
126
  }
127
127
 
128
+ /**
129
+ * Deduplicate inherited loader segments by loaderId.
130
+ *
131
+ * When a route has loaders and a child layout has parallel slots, the same
132
+ * loader is resolved twice: once for the route and once inherited into the
133
+ * layout (tagged with `_inherited`). The inherited copy is only needed when
134
+ * the route uses `loading()` — in that case, the loader data is inside a
135
+ * LoaderBoundary/Suspense that parallel slots can't reach through. Without
136
+ * loading(), useLoader() traverses parent contexts and finds the data.
137
+ */
138
+ function deduplicateLoaderSegments(
139
+ segments: ResolvedSegment[],
140
+ logPrefix: string,
141
+ ): ResolvedSegment[] {
142
+ // First pass: collect loaderIds of original (non-inherited) segments
143
+ // and whether their parent entry uses loading()
144
+ const originalLoaders = new Set<string>();
145
+ const loadersWithLoading = new Set<string>();
146
+ for (const s of segments) {
147
+ if (s.type === "loader" && s.loaderId && !s._inherited) {
148
+ originalLoaders.add(s.loaderId);
149
+ // If the segment has a sibling with loading, the parent uses loading()
150
+ // We detect this by checking if any non-loader segment in the same
151
+ // namespace has loading defined
152
+ }
153
+ }
154
+ // Check if any layout/route segment has loading — if a loader's namespace
155
+ // matches a segment with loading, the inherited copy is needed
156
+ for (const s of segments) {
157
+ if (s.type !== "loader" && s.loading !== undefined && s.loading !== false) {
158
+ // Find loaders in this namespace
159
+ for (const l of segments) {
160
+ if (l.type === "loader" && l.namespace === s.namespace && l.loaderId) {
161
+ loadersWithLoading.add(l.loaderId);
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+ const result: ResolvedSegment[] = [];
168
+ let dedupCount = 0;
169
+
170
+ for (const s of segments) {
171
+ if (
172
+ s.type === "loader" &&
173
+ s.loaderId &&
174
+ s._inherited &&
175
+ originalLoaders.has(s.loaderId) &&
176
+ !loadersWithLoading.has(s.loaderId)
177
+ ) {
178
+ dedupCount++;
179
+ continue;
180
+ }
181
+ result.push(s);
182
+ }
183
+
184
+ if (dedupCount > 0) {
185
+ debugLog(logPrefix, `deduped ${dedupCount} inherited loader segment(s)`);
186
+ }
187
+
188
+ return result;
189
+ }
190
+
128
191
  /**
129
192
  * Build the final MatchResult from collected segments and context
130
193
  */
@@ -181,6 +244,11 @@ export function buildMatchResult<TEnv>(
181
244
  );
182
245
  }
183
246
 
247
+ const dedupedSegments = deduplicateLoaderSegments(
248
+ segmentsToRender,
249
+ logPrefix,
250
+ );
251
+
184
252
  debugLog(logPrefix, "all segments", {
185
253
  segments: allSegments.map((s) => ({
186
254
  id: s.id,
@@ -189,13 +257,23 @@ export function buildMatchResult<TEnv>(
189
257
  })),
190
258
  });
191
259
  debugLog(logPrefix, "segments to render", {
192
- segmentIds: segmentsToRender.map((s) => s.id),
260
+ segmentIds: dedupedSegments.map((s) => s.id),
193
261
  });
194
262
 
263
+ // Remove deduped loader IDs from matched so the client doesn't treat
264
+ // them as missing segments and trigger a fallback refetch.
265
+ const removedIds = new Set(
266
+ segmentsToRender
267
+ .filter((s) => !dedupedSegments.includes(s))
268
+ .map((s) => s.id),
269
+ );
270
+ const matchedIds =
271
+ removedIds.size > 0 ? allIds.filter((id) => !removedIds.has(id)) : allIds;
272
+
195
273
  return {
196
- segments: segmentsToRender,
197
- matched: allIds,
198
- diff: segmentsToRender.map((s) => s.id),
274
+ segments: dedupedSegments,
275
+ matched: matchedIds,
276
+ diff: dedupedSegments.map((s) => s.id),
199
277
  params: ctx.matched.params,
200
278
  routeName: ctx.routeKey,
201
279
  slots: Object.keys(state.slots).length > 0 ? state.slots : undefined,
@@ -14,6 +14,7 @@ import type {
14
14
  import type { ScopedReverseFunction } from "../reverse.js";
15
15
  import type { Theme } from "../theme/types.js";
16
16
  import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
17
+ import type { RequestScope } from "../types/request-scope.js";
17
18
 
18
19
  /**
19
20
  * Get variable function type
@@ -57,28 +58,7 @@ export interface CookieOptions {
57
58
  export interface MiddlewareContext<
58
59
  TEnv = any,
59
60
  TParams = Record<string, string>,
60
- > {
61
- /** Original request */
62
- request: Request;
63
-
64
- /** Parsed URL (with internal `_rsc*` params stripped) */
65
- url: URL;
66
-
67
- /**
68
- * The original request URL with all parameters intact, including
69
- * internal `_rsc*` transport params.
70
- */
71
- originalUrl: URL;
72
-
73
- /** URL pathname */
74
- pathname: string;
75
-
76
- /** URL search params */
77
- searchParams: URLSearchParams;
78
-
79
- /** Platform bindings (Cloudflare, etc.) */
80
- env: TEnv;
81
-
61
+ > extends RequestScope<TEnv> {
82
62
  /** URL params extracted from route/middleware pattern */
83
63
  params: TParams;
84
64
 
@@ -10,6 +10,8 @@
10
10
  */
11
11
 
12
12
  import { contextGet, contextSet } from "../context-var.js";
13
+ import { safeDecodeURIComponent } from "./url-params.js";
14
+ import { fireAndForgetWaitUntil } from "../types/request-scope.js";
13
15
  import type {
14
16
  CollectedMiddleware,
15
17
  MiddlewareCollectableEntry,
@@ -22,6 +24,7 @@ import { _getRequestContext } from "../server/request-context.js";
22
24
  import { isAutoGeneratedRouteName } from "../route-name.js";
23
25
  import { appendMetric, createMetricsStore } from "./metrics.js";
24
26
  import { stripInternalParams } from "./handler-context.js";
27
+ import { isWebSocketUpgradeResponse } from "../response-utils.js";
25
28
 
26
29
  // Re-export types and cookie utilities for backward compatibility
27
30
  export type {
@@ -112,7 +115,12 @@ function escapeRegex(str: string): string {
112
115
  }
113
116
 
114
117
  /**
115
- * Extract params from a pathname using a pattern's regex and param names
118
+ * Extract params from a pathname using a pattern's regex and param names.
119
+ *
120
+ * Values are URL-decoded so apps see the raw string (e.g. "ivo@example.com")
121
+ * instead of the percent-encoded form ("ivo%40example.com"). This matches the
122
+ * contract assumed by ctx.reverse (which re-encodes) and aligns with
123
+ * Express/React Router/Fastify/Koa.
116
124
  */
117
125
  export function extractParams(
118
126
  pathname: string,
@@ -124,7 +132,7 @@ export function extractParams(
124
132
 
125
133
  const params: Record<string, string> = {};
126
134
  for (let i = 0; i < paramNames.length; i++) {
127
- params[paramNames[i]] = match[i + 1] || "";
135
+ params[paramNames[i]] = safeDecodeURIComponent(match[i + 1] || "");
128
136
  }
129
137
  return params;
130
138
  }
@@ -179,14 +187,22 @@ export function createMiddlewareContext<TEnv>(
179
187
  return responseHolder.response;
180
188
  };
181
189
 
190
+ // Capture reqCtx once: the request-scoped platform fields
191
+ // (originalUrl, executionContext, waitUntil) are immutable per request,
192
+ // so snapshotting beats re-reading ALS on every access. The lazy getters
193
+ // below (routeName, theme, setTheme) stay lazy because those can change
194
+ // during `await next()`.
195
+ const reqCtx = _getRequestContext();
182
196
  return {
183
197
  request,
184
198
  url,
185
- originalUrl: new URL(request.url),
199
+ originalUrl: reqCtx?.originalUrl ?? new URL(request.url),
186
200
  pathname: url.pathname,
187
201
  searchParams: url.searchParams,
188
202
  env: env as MiddlewareContext<TEnv>["env"],
189
203
  params,
204
+ executionContext: reqCtx?.executionContext,
205
+ waitUntil: reqCtx ? reqCtx.waitUntil.bind(reqCtx) : fireAndForgetWaitUntil,
190
206
  // Getter: re-derives from request context on each access so that global
191
207
  // middleware sees the matched route name after await next().
192
208
  get routeName(): MiddlewareContext<TEnv>["routeName"] {
@@ -360,6 +376,11 @@ export async function executeMiddleware<TEnv>(
360
376
  });
361
377
  }
362
378
 
379
+ if (isWebSocketUpgradeResponse(response)) {
380
+ responseHolder.response = response;
381
+ return response;
382
+ }
383
+
363
384
  // Clone response with merged headers (mutable for post-next() modifications)
364
385
  responseHolder.response = new Response(response.body, {
365
386
  status: response.status,
@@ -451,6 +472,10 @@ export async function executeMiddleware<TEnv>(
451
472
  // RequestContext stub headers (from ctx.setCookie) into the
452
473
  // returned Response so they are not lost.
453
474
  if (result instanceof Response) {
475
+ if (isWebSocketUpgradeResponse(result)) {
476
+ responseHolder.response = result;
477
+ return result;
478
+ }
454
479
  const mergedHeaders = new Headers(result.headers);
455
480
  stubResponse.headers.forEach((value, name) => {
456
481
  if (name.toLowerCase() === "set-cookie") {
@@ -527,8 +552,11 @@ export async function executeMiddleware<TEnv>(
527
552
  // last merge point (e.g. cookies().set() called after await next()).
528
553
  // The reqCtx stub may have already been partially merged during finalHandler
529
554
  // or early-return paths; only append *new* Set-Cookie entries to avoid dupes.
555
+ //
556
+ // Skip for upgrade responses: upgrade headers are semantically immutable and
557
+ // set-cookie on an upgrade is not meaningful.
530
558
  const reqCtx = _getRequestContext();
531
- if (reqCtx) {
559
+ if (reqCtx && !isWebSocketUpgradeResponse(finalResponse)) {
532
560
  const stubCookies = reqCtx.res.headers.getSetCookie();
533
561
  if (stubCookies.length > 0) {
534
562
  const existingCookies = new Set(finalResponse.headers.getSetCookie());
@@ -7,6 +7,7 @@
7
7
  import type { RouteEntry, TrailingSlashMode } from "../types";
8
8
  import type { EntryData } from "../server/context";
9
9
  import { debugLog, isRouterDebugEnabled } from "./logging.js";
10
+ import { safeDecodeURIComponent } from "./url-params.js";
10
11
 
11
12
  /**
12
13
  * Parsed segment info
@@ -82,6 +83,13 @@ export interface CompiledPattern {
82
83
  paramNames: string[];
83
84
  optionalParams: Set<string>;
84
85
  hasTrailingSlash: boolean;
86
+ /**
87
+ * Param-name → allowed values for constrained params (e.g. `:lang(en|gb)`).
88
+ * Validated against the **decoded** param value after regex extraction so
89
+ * a URL like `/en%20GB` still matches `:lang(en GB)` — matching the trie
90
+ * path's behavior (trie-matching.ts:validateAndBuild).
91
+ */
92
+ constraints?: Record<string, string[]>;
85
93
  }
86
94
 
87
95
  // Module-level cache for compiled patterns. Route patterns are a finite set
@@ -142,6 +150,7 @@ export function compilePattern(pattern: string): CompiledPattern {
142
150
  const segments = parsePattern(normalizedPattern);
143
151
  const paramNames: string[] = [];
144
152
  const optionalParams = new Set<string>();
153
+ let constraints: Record<string, string[]> | undefined;
145
154
 
146
155
  let regexPattern = "";
147
156
 
@@ -152,11 +161,14 @@ export function compilePattern(pattern: string): CompiledPattern {
152
161
  } else if (segment.type === "param") {
153
162
  paramNames.push(segment.value);
154
163
  const suffixPattern = segment.suffix ? escapeRegex(segment.suffix) : "";
155
- const valuePattern = segment.constraint
156
- ? `(${segment.constraint.map(escapeRegex).join("|")})`
157
- : segment.suffix
158
- ? "([^/]+?)"
159
- : "([^/]+)";
164
+ // Constrained params capture anything here; the allowed values are
165
+ // checked post-decode in findMatch so URL-encoded constraint values
166
+ // (e.g. `:lang(en GB)` via `/en%20GB`) still match.
167
+ const valuePattern = segment.suffix ? "([^/]+?)" : "([^/]+)";
168
+
169
+ if (segment.constraint) {
170
+ (constraints ??= {})[segment.value] = segment.constraint;
171
+ }
160
172
 
161
173
  if (segment.optional) {
162
174
  optionalParams.add(segment.value);
@@ -186,9 +198,33 @@ export function compilePattern(pattern: string): CompiledPattern {
186
198
  paramNames,
187
199
  optionalParams,
188
200
  hasTrailingSlash,
201
+ ...(constraints ? { constraints } : {}),
189
202
  };
190
203
  }
191
204
 
205
+ /**
206
+ * Validate decoded params against a compiled pattern's constraints.
207
+ * Returns false if any constrained param has a non-empty value not in the
208
+ * allowed list (empty-string = absent optional, which is allowed).
209
+ */
210
+ function satisfiesConstraints(
211
+ params: Record<string, string>,
212
+ constraints: Record<string, string[]> | undefined,
213
+ ): boolean {
214
+ if (!constraints) return true;
215
+ for (const name in constraints) {
216
+ const value = params[name];
217
+ if (
218
+ value !== undefined &&
219
+ value !== "" &&
220
+ !constraints[name].includes(value)
221
+ ) {
222
+ return false;
223
+ }
224
+ }
225
+ return true;
226
+ }
227
+
192
228
  /**
193
229
  * Escape special regex characters in a string
194
230
  */
@@ -392,8 +428,13 @@ export function findMatch<TEnv>(
392
428
  fullPattern = entry.prefix + pattern;
393
429
  }
394
430
 
395
- const { regex, paramNames, optionalParams, hasTrailingSlash } =
396
- getCompiledPattern(fullPattern);
431
+ const {
432
+ regex,
433
+ paramNames,
434
+ optionalParams,
435
+ hasTrailingSlash,
436
+ constraints,
437
+ } = getCompiledPattern(fullPattern);
397
438
 
398
439
  // Get trailing slash mode for this route (per-route config or pattern-based)
399
440
  const trailingSlashMode: TrailingSlashMode | undefined =
@@ -412,9 +453,15 @@ export function findMatch<TEnv>(
412
453
  if (match) {
413
454
  const params: Record<string, string> = {};
414
455
  paramNames.forEach((name, index) => {
415
- params[name] = match[index + 1] ?? "";
456
+ params[name] = safeDecodeURIComponent(match[index + 1] ?? "");
416
457
  });
417
458
 
459
+ // Validate constraints against decoded values; a failure falls
460
+ // through to the next route so other patterns can still match.
461
+ if (!satisfiesConstraints(params, constraints)) {
462
+ continue;
463
+ }
464
+
418
465
  if (effectiveDebug) {
419
466
  debugLog("findMatch", "matched route", {
420
467
  routeKey,
@@ -467,9 +514,13 @@ export function findMatch<TEnv>(
467
514
  if (altMatch) {
468
515
  const params: Record<string, string> = {};
469
516
  paramNames.forEach((name, index) => {
470
- params[name] = altMatch[index + 1] ?? "";
517
+ params[name] = safeDecodeURIComponent(altMatch[index + 1] ?? "");
471
518
  });
472
519
 
520
+ if (!satisfiesConstraints(params, constraints)) {
521
+ continue;
522
+ }
523
+
473
524
  // Determine redirect behavior based on mode
474
525
  if (trailingSlashMode === "ignore") {
475
526
  // Match without redirect