@rangojs/router 0.23.0 → 0.24.0

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 (128) 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/context-var.d.ts +12 -0
  11. package/dist/types/errors.d.ts +10 -0
  12. package/dist/types/handles/pushed-by.d.ts +8 -0
  13. package/dist/types/index.d.ts +4 -0
  14. package/dist/types/index.rsc.d.ts +2 -0
  15. package/dist/types/response-utils.d.ts +7 -0
  16. package/dist/types/reverse.d.ts +8 -8
  17. package/dist/types/route-definition/rewrite.d.ts +56 -0
  18. package/dist/types/route-map-builder.d.ts +7 -2
  19. package/dist/types/router/basename.d.ts +7 -0
  20. package/dist/types/router/handler-context.d.ts +1 -5
  21. package/dist/types/router/internal-params.d.ts +8 -0
  22. package/dist/types/router/middleware-types.d.ts +15 -0
  23. package/dist/types/router/revalidation.d.ts +1 -2
  24. package/dist/types/router/route-trie-builder.d.ts +10 -0
  25. package/dist/types/router/segment-resolution/mask-nested.d.ts +4 -2
  26. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +36 -13
  27. package/dist/types/rsc/full-payload.d.ts +11 -0
  28. package/dist/types/rsc/handler.d.ts +33 -0
  29. package/dist/types/rsc/helpers.d.ts +8 -0
  30. package/dist/types/rsc/loader-fetch.d.ts +16 -0
  31. package/dist/types/rsc/rewrite-passes.d.ts +74 -0
  32. package/dist/types/rsc/ssr-setup.d.ts +3 -1
  33. package/dist/types/server/request-context.d.ts +39 -2
  34. package/dist/types/server/rewrite-trail.d.ts +49 -0
  35. package/dist/types/server/slot-accessors.d.ts +29 -0
  36. package/dist/types/testing/index.d.ts +1 -1
  37. package/dist/types/testing/render-route.d.ts +8 -0
  38. package/dist/types/testing/run-middleware.d.ts +20 -0
  39. package/dist/types/testing/serve-shell-request.d.ts +2 -1
  40. package/dist/types/thenable-status.d.ts +6 -0
  41. package/dist/types/types/request-scope.d.ts +7 -0
  42. package/dist/types/vite/discovery/state.d.ts +12 -9
  43. package/dist/vite/index.js +164 -103
  44. package/package.json +1 -1
  45. package/skills/caching/SKILL.md +12 -0
  46. package/skills/catalog.json +1 -1
  47. package/skills/client-urls/SKILL.md +84 -0
  48. package/skills/hooks/SKILL.md +11 -6
  49. package/skills/hooks/data.md +3 -1
  50. package/skills/loader/SKILL.md +7 -1
  51. package/skills/middleware/SKILL.md +183 -2
  52. package/skills/ppr/SKILL.md +9 -0
  53. package/skills/rango/SKILL.md +1 -1
  54. package/skills/server-actions/SKILL.md +27 -0
  55. package/skills/testing/client-components.md +1 -0
  56. package/skills/testing/middleware.md +31 -10
  57. package/skills/testing/response-routes.md +1 -0
  58. package/skills/testing/serve-shell-request.md +19 -1
  59. package/skills/use-cache/SKILL.md +4 -2
  60. package/src/__augment-tests__/augmented.check.ts +20 -0
  61. package/src/browser/event-controller.ts +3 -0
  62. package/src/browser/merge-segment-loaders.ts +31 -18
  63. package/src/browser/navigation-bridge.ts +19 -3
  64. package/src/browser/partial-update.ts +27 -12
  65. package/src/browser/react/Link.tsx +7 -9
  66. package/src/browser/react/NavigationProvider.tsx +46 -26
  67. package/src/browser/react/context.ts +3 -2
  68. package/src/browser/react/use-router.ts +2 -4
  69. package/src/browser/server-action-bridge.ts +6 -0
  70. package/src/browser/types.ts +5 -1
  71. package/src/build/route-types/scan-filter.ts +11 -4
  72. package/src/cache/document-cache-policy.ts +131 -0
  73. package/src/cache/document-cache.ts +12 -119
  74. package/src/context-var.ts +32 -2
  75. package/src/errors.ts +19 -0
  76. package/src/handles/pushed-by.ts +26 -0
  77. package/src/index.rsc.ts +8 -0
  78. package/src/index.ts +7 -0
  79. package/src/response-utils.ts +19 -0
  80. package/src/reverse.ts +17 -14
  81. package/src/route-definition/redirect.ts +2 -15
  82. package/src/route-definition/rewrite.ts +146 -0
  83. package/src/route-map-builder.ts +14 -11
  84. package/src/router/basename.ts +20 -0
  85. package/src/router/handler-context.ts +3 -13
  86. package/src/router/intercept-resolution.ts +29 -12
  87. package/src/router/internal-params.ts +16 -0
  88. package/src/router/loader-resolution.ts +2 -0
  89. package/src/router/match-middleware/background-revalidation.ts +11 -9
  90. package/src/router/match-middleware/cache-lookup.ts +20 -15
  91. package/src/router/match-middleware/cache-store.ts +9 -11
  92. package/src/router/match-middleware/intercept-resolution.ts +4 -2
  93. package/src/router/middleware-types.ts +16 -0
  94. package/src/router/middleware.ts +127 -49
  95. package/src/router/revalidation.ts +1 -1
  96. package/src/router/route-trie-builder.ts +28 -1
  97. package/src/router/segment-resolution/loader-snapshot.ts +18 -4
  98. package/src/router/segment-resolution/mask-nested.ts +8 -4
  99. package/src/router/segment-resolution/prefetch-deferral.ts +57 -17
  100. package/src/router/segment-resolution/revalidation.ts +57 -33
  101. package/src/rsc/full-payload.ts +37 -5
  102. package/src/rsc/handler.ts +82 -55
  103. package/src/rsc/helpers.ts +24 -0
  104. package/src/rsc/loader-fetch.ts +79 -24
  105. package/src/rsc/response-cache-serve.ts +18 -2
  106. package/src/rsc/response-route-handler.ts +4 -7
  107. package/src/rsc/rewrite-passes.ts +203 -0
  108. package/src/rsc/rsc-rendering.ts +4 -2
  109. package/src/rsc/ssr-setup.ts +11 -5
  110. package/src/server/cookie-parse.ts +36 -12
  111. package/src/server/cookie-store.ts +7 -3
  112. package/src/server/handle-store.ts +48 -17
  113. package/src/server/request-context.ts +154 -63
  114. package/src/server/request-headers.ts +4 -14
  115. package/src/server/rewrite-trail.ts +190 -0
  116. package/src/server/slot-accessors.ts +57 -0
  117. package/src/ssr/index.tsx +118 -16
  118. package/src/testing/dispatch.ts +138 -67
  119. package/src/testing/index.ts +1 -0
  120. package/src/testing/render-route.tsx +16 -3
  121. package/src/testing/run-loader.ts +2 -0
  122. package/src/testing/run-middleware.ts +56 -11
  123. package/src/testing/serve-shell-request.ts +15 -2
  124. package/src/thenable-status.ts +11 -0
  125. package/src/types/request-scope.ts +8 -0
  126. package/src/vite/discovery/state.ts +21 -13
  127. package/src/vite/plugins/expose-internal-ids.ts +2 -3
  128. package/src/vite/router-discovery.ts +29 -27
@@ -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) */
@@ -153,6 +153,31 @@ function sortSuffixParams(node: TrieNode): void {
153
153
  }
154
154
  }
155
155
 
156
+ /**
157
+ * Give every static-children map in the trie a null prototype, in place, and
158
+ * return the trie (#1109). The walker indexes `node.s` with raw URL segments,
159
+ * so a map with Object.prototype would answer `/constructor` or `/__proto__`
160
+ * with an inherited member. The builder creates null-prototype maps; a trie
161
+ * that arrives as JSON (the build manifest) or across the dev runner boundary
162
+ * gets them here. Object.setPrototypeOf keeps a JSON-parsed map's fast
163
+ * properties, where Object.create(null) starts in dictionary mode.
164
+ */
165
+ export function nullPrototypeStaticChildren(node: TrieNode): TrieNode {
166
+ if (node.s) {
167
+ Object.setPrototypeOf(node.s, null);
168
+ for (const child of Object.values(node.s)) {
169
+ nullPrototypeStaticChildren(child);
170
+ }
171
+ }
172
+ if (node.xp) {
173
+ for (const child of Object.values(node.xp)) {
174
+ nullPrototypeStaticChildren(child.c);
175
+ }
176
+ }
177
+ if (node.p) nullPrototypeStaticChildren(node.p.c);
178
+ return node;
179
+ }
180
+
156
181
  /**
157
182
  * Insert a route into the trie. Optional params expand into two branches at
158
183
  * registration time (skip-first, then present), so each terminal lives at the
@@ -271,7 +296,9 @@ function insertSegments(
271
296
  const segment = segments[index];
272
297
 
273
298
  if (segment.type === "static") {
274
- if (!node.s) node.s = {};
299
+ // Null prototype: a segment named `constructor` or `__proto__` is an own
300
+ // key, never an inherited member to descend into and write a leaf on.
301
+ if (!node.s) node.s = Object.create(null) as Record<string, TrieNode>;
275
302
  if (!node.s[segment.value]) node.s[segment.value] = {};
276
303
  insertSegments(
277
304
  node.s[segment.value],
@@ -26,6 +26,7 @@
26
26
 
27
27
  import { isValidElement, type ReactElement } from "react";
28
28
  import { isThenable } from "../../handles/is-thenable.js";
29
+ import { assignOwn } from "../../context-var.js";
29
30
  import {
30
31
  cloneElementWithProps,
31
32
  isPlainDataObject,
@@ -178,7 +179,7 @@ async function elideNested(
178
179
  for (const key of Object.keys(source)) {
179
180
  const r = await elideNested(source[key], seen);
180
181
  if (r.state === "rejected") return r;
181
- out[key] = r.value;
182
+ assignOwn(out, key, r.value);
182
183
  if (r.value !== source[key]) changed = true;
183
184
  result.hasHole ||= r.hasHole;
184
185
  }
@@ -301,13 +302,21 @@ export function overlayLoaderContainer(
301
302
  // Shape drift (fresh not a plain object): markers fall back to undefined.
302
303
  const freshObj = isPlainDataObject(fresh) ? fresh : undefined;
303
304
  let out: Record<string, unknown> | undefined;
305
+ // Own keys only: a key named like an Object.prototype member is data.
306
+ // The spread copies an own `__proto__` as data, so assigning a recorded
307
+ // key writes it; a fresh-only key goes through assignOwn.
304
308
  for (const key of Object.keys(recorded)) {
305
- const next = overlayLoaderContainer(freshObj?.[key], recorded[key]);
309
+ const next = overlayLoaderContainer(
310
+ freshObj && Object.hasOwn(freshObj, key) ? freshObj[key] : undefined,
311
+ recorded[key],
312
+ );
306
313
  if (next !== recorded[key]) (out ??= { ...recorded })[key] = next;
307
314
  }
308
315
  if (freshObj) {
309
316
  for (const key of Object.keys(freshObj)) {
310
- if (!(key in recorded)) (out ??= { ...recorded })[key] = freshObj[key];
317
+ if (!Object.hasOwn(recorded, key)) {
318
+ assignOwn((out ??= { ...recorded }), key, freshObj[key]);
319
+ }
311
320
  }
312
321
  }
313
322
  return out ?? recorded;
@@ -341,7 +350,12 @@ function overlayProps(
341
350
  const props = recorded.props;
342
351
  let changed: Record<string, unknown> | undefined;
343
352
  for (const key of Object.keys(props)) {
344
- const next = overlayLoaderContainer(freshProps?.[key], props[key]);
353
+ const next = overlayLoaderContainer(
354
+ freshProps && Object.hasOwn(freshProps, key)
355
+ ? freshProps[key]
356
+ : undefined,
357
+ props[key],
358
+ );
345
359
  if (next !== props[key]) (changed ??= {})[key] = next;
346
360
  }
347
361
  return changed ? cloneElementWithProps(recorded, changed) : recorded;
@@ -8,10 +8,12 @@
8
8
  *
9
9
  * Deliberately a LEAF module: loader-mask (the other natural home) imports
10
10
  * request-context, so any funnel importing the mask from there inherits that
11
- * graph. This module imports only is-thenable and react.
11
+ * graph. This module imports only react and two leaves, is-thenable and
12
+ * context-var.
12
13
  */
13
14
 
14
15
  import { cloneElement, isValidElement, type ReactElement } from "react";
16
+ import { assignOwn } from "../../context-var.js";
15
17
  import { isThenable } from "../../handles/is-thenable.js";
16
18
 
17
19
  /**
@@ -52,7 +54,8 @@ type KeyValidated = { _store?: { validated?: unknown } };
52
54
  * keyless child of a static children list (validated at creation) would warn
53
55
  * "Each child in a list should have a unique key" once cloned. Pass only the
54
56
  * props that change: cloneElement re-parents the element (owner) when its
55
- * config carries `ref`.
57
+ * config carries `ref`. cloneElement copies props by assignment, so a prop
58
+ * key named `__proto__` can't survive a clone; prop copies don't use assignOwn.
56
59
  */
57
60
  export function cloneElementWithProps(
58
61
  element: ReactElement,
@@ -206,8 +209,9 @@ export function maskNestedContainerThenables(
206
209
  seen.set(value, out);
207
210
  let changed = false;
208
211
  for (const key of Object.keys(source)) {
209
- out[key] = maskNestedContainerThenables(source[key], seen, report);
210
- if (out[key] !== source[key]) changed = true;
212
+ const masked = maskNestedContainerThenables(source[key], seen, report);
213
+ assignOwn(out, key, masked);
214
+ if (masked !== source[key]) changed = true;
211
215
  }
212
216
  if (changed) return out;
213
217
  seen.set(value, value);