@rangojs/router 0.23.0 → 0.24.1

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 (132) hide show
  1. package/dist/bin/rango.js +4 -3
  2. package/dist/types/browser/merge-segment-loaders.d.ts +5 -2
  3. package/dist/types/browser/partial-update.d.ts +6 -4
  4. package/dist/types/browser/react/NavigationProvider.d.ts +11 -7
  5. package/dist/types/browser/react/context.d.ts +3 -2
  6. package/dist/types/browser/types.d.ts +5 -1
  7. package/dist/types/build/route-types/scan-filter.d.ts +7 -1
  8. package/dist/types/cache/document-cache-policy.d.ts +24 -0
  9. package/dist/types/cache/document-cache.d.ts +2 -10
  10. package/dist/types/client-urls/types.d.ts +8 -0
  11. package/dist/types/context-var.d.ts +12 -0
  12. package/dist/types/errors.d.ts +10 -0
  13. package/dist/types/handles/pushed-by.d.ts +8 -0
  14. package/dist/types/index.d.ts +4 -0
  15. package/dist/types/index.rsc.d.ts +2 -0
  16. package/dist/types/response-utils.d.ts +7 -0
  17. package/dist/types/reverse.d.ts +8 -8
  18. package/dist/types/route-definition/rewrite.d.ts +56 -0
  19. package/dist/types/route-map-builder.d.ts +7 -2
  20. package/dist/types/router/basename.d.ts +7 -0
  21. package/dist/types/router/handler-context.d.ts +1 -5
  22. package/dist/types/router/internal-params.d.ts +8 -0
  23. package/dist/types/router/middleware-types.d.ts +15 -0
  24. package/dist/types/router/revalidation.d.ts +1 -2
  25. package/dist/types/router/route-trie-builder.d.ts +10 -0
  26. package/dist/types/router/segment-resolution/mask-nested.d.ts +4 -2
  27. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +36 -13
  28. package/dist/types/rsc/full-payload.d.ts +11 -0
  29. package/dist/types/rsc/handler.d.ts +33 -0
  30. package/dist/types/rsc/helpers.d.ts +8 -0
  31. package/dist/types/rsc/loader-fetch.d.ts +16 -0
  32. package/dist/types/rsc/rewrite-passes.d.ts +74 -0
  33. package/dist/types/rsc/ssr-setup.d.ts +3 -1
  34. package/dist/types/server/request-context.d.ts +39 -2
  35. package/dist/types/server/rewrite-trail.d.ts +49 -0
  36. package/dist/types/server/slot-accessors.d.ts +29 -0
  37. package/dist/types/testing/index.d.ts +1 -1
  38. package/dist/types/testing/render-route.d.ts +8 -0
  39. package/dist/types/testing/run-middleware.d.ts +20 -0
  40. package/dist/types/testing/serve-shell-request.d.ts +2 -1
  41. package/dist/types/thenable-status.d.ts +6 -0
  42. package/dist/types/types/request-scope.d.ts +7 -0
  43. package/dist/types/vite/discovery/state.d.ts +12 -9
  44. package/dist/vite/index.js +164 -103
  45. package/package.json +1 -1
  46. package/skills/caching/SKILL.md +12 -0
  47. package/skills/catalog.json +1 -1
  48. package/skills/client-urls/SKILL.md +86 -1
  49. package/skills/hooks/SKILL.md +11 -6
  50. package/skills/hooks/data.md +3 -1
  51. package/skills/loader/SKILL.md +7 -1
  52. package/skills/middleware/SKILL.md +183 -2
  53. package/skills/ppr/SKILL.md +9 -0
  54. package/skills/rango/SKILL.md +1 -1
  55. package/skills/server-actions/SKILL.md +27 -0
  56. package/skills/testing/client-components.md +1 -0
  57. package/skills/testing/middleware.md +31 -10
  58. package/skills/testing/response-routes.md +1 -0
  59. package/skills/testing/serve-shell-request.md +19 -1
  60. package/skills/use-cache/SKILL.md +4 -2
  61. package/src/__augment-tests__/augmented.check.ts +20 -0
  62. package/src/browser/event-controller.ts +3 -0
  63. package/src/browser/merge-segment-loaders.ts +31 -18
  64. package/src/browser/navigation-bridge.ts +19 -3
  65. package/src/browser/partial-update.ts +27 -12
  66. package/src/browser/react/Link.tsx +7 -9
  67. package/src/browser/react/NavigationProvider.tsx +46 -26
  68. package/src/browser/react/context.ts +3 -2
  69. package/src/browser/react/use-router.ts +2 -4
  70. package/src/browser/server-action-bridge.ts +6 -0
  71. package/src/browser/types.ts +5 -1
  72. package/src/build/route-types/scan-filter.ts +11 -4
  73. package/src/cache/document-cache-policy.ts +131 -0
  74. package/src/cache/document-cache.ts +12 -119
  75. package/src/client-urls/client-root.tsx +3 -3
  76. package/src/client-urls/client-urls.ts +8 -1
  77. package/src/client-urls/types.ts +8 -0
  78. package/src/context-var.ts +32 -2
  79. package/src/errors.ts +19 -0
  80. package/src/handles/pushed-by.ts +26 -0
  81. package/src/index.rsc.ts +8 -0
  82. package/src/index.ts +7 -0
  83. package/src/response-utils.ts +19 -0
  84. package/src/reverse.ts +17 -14
  85. package/src/route-definition/redirect.ts +2 -15
  86. package/src/route-definition/rewrite.ts +146 -0
  87. package/src/route-map-builder.ts +14 -11
  88. package/src/router/basename.ts +20 -0
  89. package/src/router/handler-context.ts +3 -13
  90. package/src/router/intercept-resolution.ts +29 -12
  91. package/src/router/internal-params.ts +16 -0
  92. package/src/router/loader-resolution.ts +2 -0
  93. package/src/router/match-middleware/background-revalidation.ts +11 -9
  94. package/src/router/match-middleware/cache-lookup.ts +20 -15
  95. package/src/router/match-middleware/cache-store.ts +9 -11
  96. package/src/router/match-middleware/intercept-resolution.ts +4 -2
  97. package/src/router/middleware-types.ts +16 -0
  98. package/src/router/middleware.ts +127 -49
  99. package/src/router/revalidation.ts +1 -1
  100. package/src/router/route-trie-builder.ts +28 -1
  101. package/src/router/segment-resolution/loader-snapshot.ts +18 -4
  102. package/src/router/segment-resolution/mask-nested.ts +8 -4
  103. package/src/router/segment-resolution/prefetch-deferral.ts +57 -17
  104. package/src/router/segment-resolution/revalidation.ts +57 -33
  105. package/src/rsc/full-payload.ts +37 -5
  106. package/src/rsc/handler.ts +82 -55
  107. package/src/rsc/helpers.ts +24 -0
  108. package/src/rsc/loader-fetch.ts +79 -24
  109. package/src/rsc/response-cache-serve.ts +18 -2
  110. package/src/rsc/response-route-handler.ts +4 -7
  111. package/src/rsc/rewrite-passes.ts +203 -0
  112. package/src/rsc/rsc-rendering.ts +4 -2
  113. package/src/rsc/ssr-setup.ts +11 -5
  114. package/src/server/cookie-parse.ts +36 -12
  115. package/src/server/cookie-store.ts +7 -3
  116. package/src/server/handle-store.ts +48 -17
  117. package/src/server/request-context.ts +154 -63
  118. package/src/server/request-headers.ts +4 -14
  119. package/src/server/rewrite-trail.ts +190 -0
  120. package/src/server/slot-accessors.ts +57 -0
  121. package/src/ssr/index.tsx +118 -16
  122. package/src/testing/dispatch.ts +138 -67
  123. package/src/testing/index.ts +1 -0
  124. package/src/testing/render-route.tsx +16 -3
  125. package/src/testing/run-loader.ts +2 -0
  126. package/src/testing/run-middleware.ts +56 -11
  127. package/src/testing/serve-shell-request.ts +15 -2
  128. package/src/thenable-status.ts +11 -0
  129. package/src/types/request-scope.ts +8 -0
  130. package/src/vite/discovery/state.ts +21 -13
  131. package/src/vite/plugins/expose-internal-ids.ts +2 -3
  132. package/src/vite/router-discovery.ts +29 -27
@@ -262,13 +262,23 @@ export async function resolveInterceptEntry<TEnv>(
262
262
 
263
263
  latchPprHeaderScopeForInterceptTarget(interceptEntry);
264
264
 
265
+ // The intercept segment's id: the tag scope its handler and layout record in
266
+ // (#957, cache-tag.ts runInSegmentTagScope) and the owner of its handler's
267
+ // and loaders' handle pushes, pinned before the loader kickoffs as
268
+ // fresh.ts/revalidation.ts pin theirs (createLoaderExecutor captures it at
269
+ // kickoff; a handler's ctx.use(Handle) reads it when called).
270
+ const slotSegmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}`;
271
+ (context as InternalHandlerContext<any, TEnv>)._currentSegmentId =
272
+ slotSegmentId;
273
+ const hasLoaders = interceptEntry.loader.length > 0;
274
+
265
275
  const loaderPromises: Promise<any>[] = [];
266
276
  const loaderIds: string[] = [];
267
277
 
268
278
  for (let i = 0; i < interceptEntry.loader.length; i++) {
269
279
  const loaderEntry = interceptEntry.loader[i];
270
280
  const { loader, revalidate: loaderRevalidateFns } = loaderEntry;
271
- const segmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}D${i}.${loader.$$id}`;
281
+ const segmentId = `${slotSegmentId}D${i}.${loader.$$id}`;
272
282
 
273
283
  if (revalidationContext) {
274
284
  const {
@@ -282,8 +292,7 @@ export async function resolveInterceptEntry<TEnv>(
282
292
  stale,
283
293
  } = revalidationContext;
284
294
 
285
- const interceptSegmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}`;
286
- if (clientSegmentIds.has(interceptSegmentId)) {
295
+ if (clientSegmentIds.has(slotSegmentId)) {
287
296
  const dummySegment: ResolvedSegment = {
288
297
  id: segmentId,
289
298
  namespace: `intercept:${interceptEntry.routeName}`,
@@ -345,9 +354,6 @@ export async function resolveInterceptEntry<TEnv>(
345
354
  );
346
355
  }
347
356
 
348
- // The intercept segment's id, and the tag scope its handler and layout
349
- // record in (#957, cache-tag.ts runInSegmentTagScope).
350
- const slotSegmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}`;
351
357
  let handlerResult: ReactNode;
352
358
  try {
353
359
  const handler = interceptEntry.handler;
@@ -399,7 +405,7 @@ export async function resolveInterceptEntry<TEnv>(
399
405
  const streamedComponent = (promise: Promise<unknown>): ReactNode =>
400
406
  trackStreamedHandler(deps, promise, {
401
407
  boundaryEntry: parentEntry,
402
- segmentId: `${parentEntry.shortCode}.${interceptEntry.slotName}`,
408
+ segmentId: slotSegmentId,
403
409
  segmentType: "parallel",
404
410
  context,
405
411
  routeKey: interceptEntry.routeName,
@@ -442,6 +448,13 @@ export async function resolveInterceptEntry<TEnv>(
442
448
  }
443
449
  }
444
450
 
451
+ // The aggregate lists the loaders that ran; empty, not absent, when the
452
+ // client holds the slot and all skipped. Absent replaces the client's
453
+ // aggregate, empty merges into it (merge-segment-loaders.ts).
454
+ if (hasLoaders && loaderPromises.length === 0) {
455
+ loaderDataPromise = [];
456
+ }
457
+
445
458
  const interceptSegment = {
446
459
  id: slotSegmentId,
447
460
  namespace: `intercept:${interceptEntry.routeName}`,
@@ -455,7 +468,7 @@ export async function resolveInterceptEntry<TEnv>(
455
468
  belongsToRoute,
456
469
  parallelName: `intercept:${interceptEntry.routeName}.${interceptEntry.slotName}`,
457
470
  loaderDataPromise,
458
- loaderIds: loaderIds.length > 0 ? loaderIds : undefined,
471
+ loaderIds: hasLoaders ? loaderIds : undefined,
459
472
  };
460
473
  segments.push(interceptSegment);
461
474
 
@@ -498,6 +511,10 @@ export async function resolveInterceptLoadersOnly<TEnv>(
498
511
 
499
512
  latchPprHeaderScopeForInterceptTarget(interceptEntry);
500
513
 
514
+ // The loaders' handle push owner, as in resolveInterceptEntry.
515
+ const slotSegmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}`;
516
+ (context as InternalHandlerContext<any, TEnv>)._currentSegmentId =
517
+ slotSegmentId;
501
518
  const loaderPromises: Promise<any>[] = [];
502
519
  const loaderIds: string[] = [];
503
520
 
@@ -515,10 +532,9 @@ export async function resolveInterceptLoadersOnly<TEnv>(
515
532
  for (let i = 0; i < interceptEntry.loader.length; i++) {
516
533
  const loaderEntry = interceptEntry.loader[i];
517
534
  const { loader, revalidate: loaderRevalidateFns } = loaderEntry;
518
- const segmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}D${i}.${loader.$$id}`;
535
+ const segmentId = `${slotSegmentId}D${i}.${loader.$$id}`;
519
536
 
520
- const interceptSegmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}`;
521
- if (clientSegmentIds.has(interceptSegmentId)) {
537
+ if (clientSegmentIds.has(slotSegmentId)) {
522
538
  const dummySegment: ResolvedSegment = {
523
539
  id: segmentId,
524
540
  namespace: `intercept:${interceptEntry.routeName}`,
@@ -581,8 +597,9 @@ export async function resolveInterceptLoadersOnly<TEnv>(
581
597
  );
582
598
  }
583
599
 
600
+ // Every loader held: the empty aggregate (see resolveInterceptEntry).
584
601
  if (loaderPromises.length === 0) {
585
- return null;
602
+ return { loaderDataPromise: [], loaderIds };
586
603
  }
587
604
 
588
605
  // Match fresh-path semantics: only defer (no await) when loading is truthy.
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Strip internal _rsc* query params from a URL.
3
+ * Returns a new URL with only user-facing params.
4
+ *
5
+ * A leaf, so request-scoped modules (server/rewrite-trail.ts) can strip
6
+ * without importing the handler context's graph.
7
+ */
8
+ export function stripInternalParams(url: URL): URL {
9
+ const clean = new URL(url);
10
+ for (const key of [...clean.searchParams.keys()]) {
11
+ if (key.startsWith("_rsc")) {
12
+ clean.searchParams.delete(key);
13
+ }
14
+ }
15
+ return clean;
16
+ }
@@ -55,6 +55,7 @@ import {
55
55
  } from "../server/handle-store.js";
56
56
  import { getFetchableLoader } from "../server/fetchable-loader-store.js";
57
57
  import { _getRequestContext } from "../server/request-context.js";
58
+ import { defineRewrittenFrom } from "../server/rewrite-trail.js";
58
59
  import {
59
60
  firstUnitCandidate,
60
61
  loaderOwner,
@@ -777,6 +778,7 @@ function createLoaderExecutor<TEnv>(
777
778
  return renderedPromise;
778
779
  },
779
780
  };
781
+ defineRewrittenFrom(loaderCtx, reqCtxRef ?? _getRequestContext());
780
782
  (loaderCtx as any)[NOCACHE_SYMBOL] = true;
781
783
 
782
784
  // Meter this loader once via observePhase (loader:<id> perf metric +
@@ -109,11 +109,12 @@ import { INTERNAL_RANGO_DEBUG } from "../../internal-debug.js";
109
109
  import { getContext } from "../../server/context.js";
110
110
  import { executionStart } from "../../cache/tag-invalidation.js";
111
111
  import {
112
+ bindRes,
112
113
  createRequestContext,
113
114
  runWithRequestContext,
114
115
  type RequestContext,
115
116
  } from "../../server/request-context.js";
116
- import { isFillRequest } from "../segment-resolution/prefetch-deferral.js";
117
+ import { recordUse } from "../segment-resolution/prefetch-deferral.js";
117
118
 
118
119
  /**
119
120
  * Re-render a cached route in the background and write it with
@@ -166,9 +167,7 @@ export async function rerenderAndCacheRoute<TEnv>(
166
167
  themeConfig: requestCtx._themeConfig,
167
168
  });
168
169
  const renderCtx: RequestContext<TEnv> = Object.assign(
169
- Object.create(requestCtx, {
170
- res: Object.getOwnPropertyDescriptor(sink, "res")!,
171
- }),
170
+ Object.create(requestCtx),
172
171
  {
173
172
  _handleStore: handleStore,
174
173
  _metricsStore: undefined,
@@ -185,6 +184,8 @@ export async function rerenderAndCacheRoute<TEnv>(
185
184
  _setKeepCacheDirective: sink._setKeepCacheDirective,
186
185
  },
187
186
  );
187
+ // ctx.res reads the sink's stub, not the live request's.
188
+ bindRes(renderCtx, () => sink.res);
188
189
  const store = Object.assign(Object.create(ctx.Store), { metrics: undefined });
189
190
  const runInStore = <T>(fn: () => T): T =>
190
191
  getContext().runWithStore(
@@ -282,15 +283,16 @@ export function withBackgroundRevalidation<TEnv>(
282
283
  // Only trigger background revalidation if:
283
284
  // 1. Cache was hit and stale
284
285
  // 2. Cache scope exists
285
- // 3. The request is not a fill (prefetch: false). A fill runs no handler
286
- // of a segment the client holds, and the re-render runs every handler
287
- // of the route. The record stays stale until a request that reads it
288
- // arrives: the prefetch before the fill already was one.
286
+ // 3. The request may refresh the record (RecordUse.refresh): a fill may
287
+ // not (prefetch: false). It runs no handler of a segment the client
288
+ // holds, and the re-render runs every handler of the route. The record
289
+ // stays stale until a request that reads it arrives: the prefetch
290
+ // before the fill already was one.
289
291
  if (
290
292
  !state.shouldRevalidate ||
291
293
  !state.cacheHit ||
292
294
  !ctx.cacheScope ||
293
- isFillRequest(ctx.handlerContext)
295
+ !recordUse(ctx.handlerContext).refresh
294
296
  ) {
295
297
  return;
296
298
  }
@@ -100,8 +100,8 @@ import { pushRevalidationTraceEntry, isTraceActive } from "../logging.js";
100
100
  import { treeHasStreaming } from "./segment-resolution.js";
101
101
  import { loaderPins } from "../segment-resolution/loader-cache.js";
102
102
  import {
103
- defersAboveRecord,
104
- isFillRequest,
103
+ fillSends,
104
+ recordUse,
105
105
  } from "../segment-resolution/prefetch-deferral.js";
106
106
  import type { PrerenderStore, PrerenderEntry } from "../../prerender/store.js";
107
107
  import {
@@ -346,20 +346,23 @@ async function* yieldFromStore<TEnv>(
346
346
  const paramsChanged =
347
347
  !ctx.isFullMatch && !paramsEqual(ctx.matched.params, ctx.prevParams);
348
348
  const resendRouteSegments = replaceable === true && ctx.isAction;
349
- // A fill (prefetch-deferral.ts) keeps every segment the client holds.
350
- const fill = isFillRequest(ctx.handlerContext);
351
349
  for (const segment of segments) {
352
350
  if (
353
351
  !ctx.isFullMatch &&
354
352
  ctx.clientSegmentSet.has(segment.id) &&
355
- (fill ||
356
- (!paramsChanged &&
357
- !(
358
- resendRouteSegments &&
359
- (segment.type === "route" || segment.belongsToRoute === true)
360
- )))
353
+ // A stored intercept segment is re-sent, as on the runtime cache path:
354
+ // its loaders run (handleCacheHitIntercept), and a kept (null) one would
355
+ // be left out of the response with its loaders' new data.
356
+ !segment.namespace?.startsWith("intercept:")
361
357
  ) {
362
- keepClientSegment(segment);
358
+ // A fill keeps it (fillSends); otherwise it is sent again when the
359
+ // params changed or an action re-sends the route's own segments.
360
+ const sends =
361
+ fillSends(ctx.handlerContext, segment.id, ctx.clientSegmentSet) ??
362
+ (paramsChanged ||
363
+ (resendRouteSegments &&
364
+ (segment.type === "route" || segment.belongsToRoute === true)));
365
+ if (!sends) keepClientSegment(segment);
363
366
  }
364
367
  yield segment;
365
368
  }
@@ -739,7 +742,7 @@ export function withCacheLookup<TEnv>(
739
742
  if (
740
743
  ctx.isAction ||
741
744
  !ctx.cacheScope?.enabled ||
742
- defersAboveRecord(ctx.handlerContext)
745
+ !recordUse(ctx.handlerContext).read
743
746
  ) {
744
747
  yield* source;
745
748
  if (ms) {
@@ -912,7 +915,6 @@ export function withCacheLookup<TEnv>(
912
915
 
913
916
  yield* liveSegments;
914
917
 
915
- const fill = isFillRequest(ctx.handlerContext);
916
918
  for (const segment of cacheResult.segments) {
917
919
  if (!ctx.clientSegmentSet.has(segment.id)) {
918
920
  if (isTraceActive()) {
@@ -935,8 +937,11 @@ export function withCacheLookup<TEnv>(
935
937
  continue;
936
938
  }
937
939
 
938
- // A fill (prefetch-deferral.ts) skips a held segment outright.
939
- if (fill) {
940
+ // A fill keeps a held segment outright (fillSends).
941
+ if (
942
+ fillSends(ctx.handlerContext, segment.id, ctx.clientSegmentSet) ===
943
+ false
944
+ ) {
940
945
  keepClientSegment(segment);
941
946
  yield segment;
942
947
  continue;
@@ -114,10 +114,7 @@ import { debugLog, debugWarn, getOrCreateRequestId } from "../logging.js";
114
114
  import { INTERNAL_RANGO_DEBUG } from "../../internal-debug.js";
115
115
  import type { GeneratorMiddleware } from "./cache-lookup.js";
116
116
  import { rerenderAndCacheRoute } from "./background-revalidation.js";
117
- import {
118
- defersAboveRecord,
119
- isFillRequest,
120
- } from "../segment-resolution/prefetch-deferral.js";
117
+ import { recordUse } from "../segment-resolution/prefetch-deferral.js";
121
118
 
122
119
  /**
123
120
  * Creates cache store middleware
@@ -161,7 +158,7 @@ export function withCacheStore<TEnv>(
161
158
  ctx.isAction ||
162
159
  state.cacheHit ||
163
160
  ctx.request.method !== "GET" ||
164
- defersAboveRecord(ctx.handlerContext)
161
+ !recordUse(ctx.handlerContext).write
165
162
  ) {
166
163
  if (ms) {
167
164
  ms.metrics.push({
@@ -200,13 +197,14 @@ export function withCacheStore<TEnv>(
200
197
  cacheScope.covers(s.id, s.namespace),
201
198
  );
202
199
 
203
- // A fill renders what the client is missing and nothing else. When that
204
- // is everything the record covers, it writes like any other request.
205
- // When the client holds part of it, the write would be partial, and the
206
- // proactive re-render below would run handlers a fill must not run. The
207
- // stale-hit refresh skips a fill for the same reason
200
+ // The client holds part of what the record covers, so writing it takes
201
+ // the proactive re-render below, which a request that may not refresh
202
+ // the record (RecordUse.refresh) does not start. That is a fill: it
203
+ // renders what the client is missing and nothing else, and writes like
204
+ // any other request only when that is everything the record covers. The
205
+ // stale-hit refresh skips it for the same reason
208
206
  // (withBackgroundRevalidation).
209
- if (hasNullComponents && isFillRequest(ctx.handlerContext)) return;
207
+ if (hasNullComponents && !recordUse(ctx.handlerContext).refresh) return;
210
208
 
211
209
  const requestCtx = getRequestContext();
212
210
  if (!requestCtx) return;
@@ -260,7 +260,9 @@ async function handleCacheHitIntercept<TEnv>(
260
260
  interceptMainSegment.loaderIds = freshLoaderResult.loaderIds;
261
261
  debugLog(
262
262
  "matchPartial.intercept",
263
- "cache hit with fresh intercept loaders",
263
+ freshLoaderResult.loaderIds.length > 0
264
+ ? "cache hit with fresh intercept loaders"
265
+ : "cache hit, every intercept loader held",
264
266
  {
265
267
  routeName: ctx.localRouteName,
266
268
  slotName,
@@ -270,7 +272,7 @@ async function handleCacheHitIntercept<TEnv>(
270
272
  } else {
271
273
  debugLog(
272
274
  "matchPartial.intercept",
273
- "cache hit without intercept loader revalidation",
275
+ "cache hit, intercept has no loaders",
274
276
  {
275
277
  routeName: ctx.localRouteName,
276
278
  slotName,
@@ -8,6 +8,7 @@ import type { GlobalReverseFunction } from "../reverse.js";
8
8
  import type { Theme } from "../theme/types.js";
9
9
  import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
10
10
  import type { RequestScope } from "../types/request-scope.js";
11
+ import type { Rewrite } from "../route-definition/rewrite.js";
11
12
 
12
13
  type GetVariableFn = {
13
14
  <T>(contextVar: ContextVar<T>): T | undefined;
@@ -81,6 +82,21 @@ export interface MiddlewareContext<
81
82
 
82
83
  /** Global route names only; see {@link GlobalReverseFunction}. */
83
84
  reverse: GlobalReverseFunction<DefaultReverseRouteMap>;
85
+
86
+ /**
87
+ * Whether `value` is a `rewrite()` thrown downstream. A middleware that
88
+ * wraps `next()` in try/catch rethrows it, so the request re-dispatches:
89
+ *
90
+ * ```ts
91
+ * try {
92
+ * return await next();
93
+ * } catch (error) {
94
+ * if (ctx.isRewrite(error)) throw error;
95
+ * return new Response("Error", { status: 500 });
96
+ * }
97
+ * ```
98
+ */
99
+ isRewrite(value: unknown): value is Rewrite;
84
100
  }
85
101
 
86
102
  export type MiddlewareFn<
@@ -32,7 +32,14 @@ import {
32
32
  import { getCacheExecScope } from "../cache/cache-exec-scope.js";
33
33
  import { assertNonCacheableReadAllowed } from "../server/context.js";
34
34
  import { readGuardedTheme } from "../server/cookie-store.js";
35
+ import { defineSlot, slotAccessor } from "../server/slot-accessors.js";
35
36
  import type { Theme } from "../theme/types.js";
37
+ import {
38
+ isRewrite,
39
+ rewriteNotSupported,
40
+ type Rewrite,
41
+ } from "../route-definition/rewrite.js";
42
+ import { defineRewrittenFrom } from "../server/rewrite-trail.js";
36
43
 
37
44
  // Re-export types consumed through this module's path.
38
45
  export type {
@@ -43,19 +50,54 @@ export type {
43
50
  } from "./middleware-types.js";
44
51
 
45
52
  /**
46
- * Middleware ctx.theme: the visitor's theme cookie, guarded like the handler
47
- * ctx.theme and cookies() (#971, #946), so a read inside a "use cache"
48
- * function the middleware calls throws. Non-enumerable like the handler and
49
- * request-context getters: a spread of the ctx (inside a "use cache"
50
- * function, say) does not read it. One shared descriptor, so every ctx keeps
51
- * the same property shape.
53
+ * The middleware ctx's accessors: shared descriptors (slot-accessors.ts).
54
+ * routeName, _routeName, theme and setTheme read only the request context;
55
+ * headers calls the per-ctx closure in HEADERS_SLOT.
52
56
  */
53
- const MIDDLEWARE_THEME_DESCRIPTOR: PropertyDescriptor = {
54
- get(): Theme | undefined {
55
- return readGuardedTheme(_getRequestContext(), "ctx.theme");
57
+ const HEADERS_SLOT: unique symbol = Symbol("rango.middlewareContext.headers");
58
+
59
+ const MIDDLEWARE_ACCESSORS: Record<
60
+ "routeName" | "_routeName" | "headers" | "setTheme" | "theme",
61
+ PropertyDescriptor
62
+ > = {
63
+ // Re-derived from the request context on each access, so global middleware
64
+ // sees the matched route name after await next().
65
+ routeName: {
66
+ get(): string | undefined {
67
+ const raw = _getRequestContext()?._routeName;
68
+ return raw && !isAutoGeneratedRouteName(raw) ? raw : undefined;
69
+ },
70
+ enumerable: true,
71
+ configurable: true,
72
+ },
73
+ // The matched route key, auto-generated names included (the handler ctx's
74
+ // _routeName); undefined before routing.
75
+ _routeName: {
76
+ get(): string | undefined {
77
+ return _getRequestContext()?._routeName;
78
+ },
79
+ enumerable: true,
80
+ configurable: true,
81
+ },
82
+ headers: slotAccessor(HEADERS_SLOT, true),
83
+ setTheme: {
84
+ get(): MiddlewareContext<unknown>["setTheme"] {
85
+ return _getRequestContext()?.setTheme;
86
+ },
87
+ enumerable: true,
88
+ configurable: true,
89
+ },
90
+ // The visitor's theme cookie, guarded like the handler ctx.theme and
91
+ // cookies() (#971, #946), so a read inside a "use cache" function the
92
+ // middleware calls throws. Non-enumerable like the handler and
93
+ // request-context getters: a spread of the ctx does not read it.
94
+ theme: {
95
+ get(): Theme | undefined {
96
+ return readGuardedTheme(_getRequestContext(), "ctx.theme");
97
+ },
98
+ enumerable: false,
99
+ configurable: true,
56
100
  },
57
- enumerable: false,
58
- configurable: true,
59
101
  };
60
102
 
61
103
  const MIDDLEWARE_METRIC_DEPTH = 1;
@@ -252,15 +294,20 @@ export function createMiddlewareContext<TEnv>(
252
294
 
253
295
  // reqCtx captured once above: the request-scoped platform fields
254
296
  // (originalUrl, executionContext, waitUntil) are immutable per request,
255
- // so snapshotting beats re-reading ALS on every access. The lazy getters
256
- // below (routeName, theme, setTheme) stay lazy because those can change
257
- // during `await next()`.
297
+ // so snapshotting beats re-reading ALS on every access. The accessors
298
+ // (routeName, _routeName, theme, setTheme: MIDDLEWARE_ACCESSORS) read the
299
+ // request context on each access because those can change during
300
+ // `await next()`.
258
301
  //
259
302
  // Request-scoped like the handler ctx (issue #940): the NOCACHE_SYMBOL brand
260
303
  // below makes a "use cache" function taking it key by host, _routeName,
261
304
  // pathname, params and search; set/header/headers throw inside that
262
305
  // function, and non-cacheable get reads run the shared guard.
263
- const ctx: MiddlewareContext<TEnv> & { readonly _routeName?: string } = {
306
+ // Data members only: the accessors are MIDDLEWARE_ACCESSORS.
307
+ const fields: Omit<
308
+ MiddlewareContext<TEnv>,
309
+ "routeName" | "headers" | "setTheme"
310
+ > = {
264
311
  request,
265
312
  url,
266
313
  originalUrl: reqCtx?.originalUrl ?? new URL(request.url),
@@ -269,39 +316,12 @@ export function createMiddlewareContext<TEnv>(
269
316
  env: env as MiddlewareContext<TEnv>["env"],
270
317
  params,
271
318
  build: reqCtx?.build ?? false,
319
+ isRewrite,
272
320
  dynamic(): void {
273
321
  reqCtx?.dynamic();
274
322
  },
275
323
  executionContext: reqCtx?.executionContext,
276
324
  waitUntil: reqCtx ? reqCtx.waitUntil.bind(reqCtx) : fireAndForgetWaitUntil,
277
- // Getter: re-derives from request context on each access so that global
278
- // middleware sees the matched route name after await next().
279
- get routeName(): MiddlewareContext<TEnv>["routeName"] {
280
- const reqCtx = _getRequestContext();
281
- const raw = reqCtx?._routeName;
282
- return (
283
- raw && !isAutoGeneratedRouteName(raw) ? raw : undefined
284
- ) as MiddlewareContext<TEnv>["routeName"];
285
- },
286
-
287
- // The matched route key, auto-generated names included (the handler
288
- // ctx's _routeName); undefined before routing.
289
- get _routeName(): string | undefined {
290
- return _getRequestContext()?._routeName;
291
- },
292
-
293
- // Guarded only inside a "use cache" body: the stamp covers a miss that
294
- // took this ctx as an argument, the exec scope a stale refresh (which does
295
- // not stamp its args, cache-runtime.ts). A write there is lost on a hit.
296
- get headers(): Headers {
297
- const headers = getResponse().headers;
298
- return (INSIDE_CACHE_EXEC as symbol) in ctx ||
299
- getCacheExecScope() !== undefined
300
- ? guardHeaderWrites(headers, () =>
301
- assertNotInsideCacheExec(ctx, "headers"),
302
- )
303
- : headers;
304
- },
305
325
 
306
326
  get: ((keyOrVar: any) => {
307
327
  // "use cache" only: intercept middleware runs inside a cache()
@@ -338,11 +358,6 @@ export function createMiddlewareContext<TEnv>(
338
358
  responseHolder.response.headers.set(name, value);
339
359
  },
340
360
 
341
- // theme: MIDDLEWARE_THEME_DESCRIPTOR, defined after this literal.
342
- get setTheme(): MiddlewareContext<TEnv>["setTheme"] {
343
- return _getRequestContext()?.setTheme;
344
- },
345
-
346
361
  setLocationState(entries) {
347
362
  const reqCtx = _getRequestContext();
348
363
  if (!reqCtx) {
@@ -376,11 +391,46 @@ export function createMiddlewareContext<TEnv>(
376
391
  }
377
392
  },
378
393
  };
379
- Object.defineProperty(ctx, "theme", MIDDLEWARE_THEME_DESCRIPTOR);
394
+ const ctx = fields as MiddlewareContext<TEnv>;
395
+ // Guarded only inside a "use cache" body: the stamp covers a miss that
396
+ // took this ctx as an argument, the exec scope a stale refresh (which does
397
+ // not stamp its args, cache-runtime.ts). A write there is lost on a hit.
398
+ defineSlot(ctx, HEADERS_SLOT, (): Headers => {
399
+ const headers = getResponse().headers;
400
+ return (INSIDE_CACHE_EXEC as symbol) in ctx ||
401
+ getCacheExecScope() !== undefined
402
+ ? guardHeaderWrites(headers, () =>
403
+ assertNotInsideCacheExec(ctx, "headers"),
404
+ )
405
+ : headers;
406
+ });
407
+ Object.defineProperty(ctx, "routeName", MIDDLEWARE_ACCESSORS.routeName);
408
+ Object.defineProperty(ctx, "_routeName", MIDDLEWARE_ACCESSORS._routeName);
409
+ Object.defineProperty(ctx, "headers", MIDDLEWARE_ACCESSORS.headers);
410
+ Object.defineProperty(ctx, "setTheme", MIDDLEWARE_ACCESSORS.setTheme);
411
+ Object.defineProperty(ctx, "theme", MIDDLEWARE_ACCESSORS.theme);
412
+ defineRewrittenFrom(ctx, reqCtx, "middleware");
380
413
  (ctx as any)[NOCACHE_SYMBOL] = true;
381
414
  return ctx;
382
415
  }
383
416
 
417
+ /** A middleware threw a Rewrite after its next() started the downstream. */
418
+ function rewriteAfterNextError(handler: Function, rewrite: Rewrite): Error {
419
+ return new Error(
420
+ `Middleware "${handler.name || "(anonymous)"}" threw rewrite() after ` +
421
+ `calling next(). Throw rewrite() before await next().`,
422
+ { cause: rewrite },
423
+ );
424
+ }
425
+
426
+ /** A middleware returned a Rewrite: only a thrown one re-dispatches. */
427
+ function returnedRewriteError(handler: Function): Error {
428
+ return new Error(
429
+ `Middleware "${handler.name || "(anonymous)"}" returned a rewrite(). ` +
430
+ `Throw it instead: \`throw rewrite(...)\`, don't return it.`,
431
+ );
432
+ }
433
+
384
434
  /**
385
435
  * Match middleware entries against a pathname
386
436
  * Returns entries that match, with extracted params
@@ -584,6 +634,12 @@ export async function executeMiddleware<TEnv>(
584
634
  // downstream chain and overwrite responseHolder.response.
585
635
  let nextPromise: Promise<Response> | null = null;
586
636
  let nextResolvedAt: number | undefined;
637
+ // Whether next() rejected with a Rewrite: only then did nothing render
638
+ // (a downstream middleware rewrote before its own next()). Any other
639
+ // rejection can come after the route rendered (a route middleware that
640
+ // throws after its next(), the render's own error), so a rewrite thrown
641
+ // after it would re-dispatch onto the used handle store.
642
+ let nextRejectedWithRewrite = false;
587
643
  const wrappedNext = (): Promise<Response> => {
588
644
  if (nextPromise) {
589
645
  throw new Error(
@@ -599,6 +655,7 @@ export async function executeMiddleware<TEnv>(
599
655
  },
600
656
  (err) => {
601
657
  nextResolvedAt = performance.now();
658
+ nextRejectedWithRewrite = isRewrite(err);
602
659
  throw err;
603
660
  },
604
661
  );
@@ -612,6 +669,11 @@ export async function executeMiddleware<TEnv>(
612
669
  // middleware-over-middleware and the core handler underneath. Pass-through
613
670
  // when neither surface is active.
614
671
  let result: Response | void;
672
+ // A thrown rewrite() is control flow like a thrown Response: held past
673
+ // the span (which settles as success) and rethrown below, so it unwinds
674
+ // to the dispatcher (rsc/handler.ts) with no further code of this
675
+ // middleware run.
676
+ let rewrite: Rewrite | undefined;
615
677
  try {
616
678
  result = await observePhase(PHASES.middleware(metricLabel), async () => {
617
679
  try {
@@ -627,6 +689,10 @@ export async function executeMiddleware<TEnv>(
627
689
  // this convention (segment-resolution/helpers.ts keeps result handling
628
690
  // outside the span). Real errors propagate past the span.
629
691
  if (error instanceof Response) return error;
692
+ if (isRewrite(error)) {
693
+ rewrite = error;
694
+ return undefined;
695
+ }
630
696
  throw error;
631
697
  }
632
698
  });
@@ -635,6 +701,16 @@ export async function executeMiddleware<TEnv>(
635
701
  // error paths (idempotent guard in finishMiddleware).
636
702
  finishMiddleware();
637
703
  }
704
+ // A rewrite after this middleware's next() started the downstream chain
705
+ // (resolved, still running, or failed) would leave that render running
706
+ // against the context the next pass moves, or re-dispatch onto what it
707
+ // already pushed, and drop the response it built. Only a rewrite after
708
+ // next() rejected with a Rewrite (nothing rendered) unwinds.
709
+ if (rewrite && nextPromise && !nextRejectedWithRewrite) {
710
+ throw rewriteAfterNextError(entry.handler, rewrite);
711
+ }
712
+ if (rewrite) throw rewrite;
713
+ if (isRewrite(result)) throw returnedRewriteError(entry.handler);
638
714
 
639
715
  // Record post-next() processing time when middleware did work after
640
716
  // the downstream chain resolved (e.g. adding headers, logging).
@@ -805,10 +881,12 @@ export async function executeInterceptMiddleware<TEnv>(
805
881
  // runner settles rango.middleware as success, not STATUS_ERROR (same
806
882
  // reasoning as executeMiddleware's main chain). Real errors propagate.
807
883
  if (error instanceof Response) return error;
884
+ if (isRewrite(error)) throw rewriteNotSupported(error, "intercept");
808
885
  throw error;
809
886
  }
810
887
  },
811
888
  );
889
+ if (isRewrite(result)) throw returnedRewriteError(middleware);
812
890
 
813
891
  if (result instanceof Response) {
814
892
  earlyResponse = result;
@@ -20,7 +20,7 @@ import { makeIsAction } from "./is-action.js";
20
20
  /**
21
21
  * Options for revalidation evaluation
22
22
  */
23
- interface EvaluateRevalidationOptions<TEnv> {
23
+ export interface EvaluateRevalidationOptions<TEnv> {
24
24
  /** Current segment to evaluate */
25
25
  segment: ResolvedSegment;
26
26
  /** Previous route params (from route match, not segment) */