@rangojs/router 0.0.0-experimental.18 → 0.0.0-experimental.19

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 (177) hide show
  1. package/README.md +46 -8
  2. package/dist/bin/rango.js +105 -18
  3. package/dist/vite/index.js +227 -93
  4. package/package.json +15 -14
  5. package/skills/hooks/SKILL.md +1 -1
  6. package/skills/intercept/SKILL.md +79 -0
  7. package/skills/layout/SKILL.md +62 -2
  8. package/skills/loader/SKILL.md +94 -1
  9. package/skills/middleware/SKILL.md +81 -0
  10. package/skills/parallel/SKILL.md +57 -2
  11. package/skills/prerender/SKILL.md +187 -17
  12. package/skills/route/SKILL.md +42 -1
  13. package/skills/router-setup/SKILL.md +77 -0
  14. package/src/__internal.ts +1 -1
  15. package/src/bin/rango.ts +38 -19
  16. package/src/browser/action-coordinator.ts +97 -0
  17. package/src/browser/event-controller.ts +25 -27
  18. package/src/browser/history-state.ts +80 -0
  19. package/src/browser/intercept-utils.ts +1 -1
  20. package/src/browser/link-interceptor.ts +0 -3
  21. package/src/browser/merge-segment-loaders.ts +9 -2
  22. package/src/browser/navigation-bridge.ts +46 -13
  23. package/src/browser/navigation-client.ts +32 -61
  24. package/src/browser/navigation-store.ts +1 -31
  25. package/src/browser/navigation-transaction.ts +46 -207
  26. package/src/browser/partial-update.ts +102 -150
  27. package/src/browser/{prefetch-cache.ts → prefetch/cache.ts} +23 -4
  28. package/src/browser/{prefetch-fetch.ts → prefetch/fetch.ts} +36 -8
  29. package/src/browser/prefetch/policy.ts +42 -0
  30. package/src/browser/{prefetch-queue.ts → prefetch/queue.ts} +10 -3
  31. package/src/browser/react/Link.tsx +28 -23
  32. package/src/browser/react/NavigationProvider.tsx +9 -1
  33. package/src/browser/react/index.ts +2 -6
  34. package/src/browser/react/location-state-shared.ts +1 -1
  35. package/src/browser/react/location-state.ts +2 -0
  36. package/src/browser/react/nonce-context.ts +23 -0
  37. package/src/browser/react/use-action.ts +9 -1
  38. package/src/browser/react/use-handle.ts +3 -25
  39. package/src/browser/react/use-params.ts +2 -4
  40. package/src/browser/react/use-pathname.ts +2 -3
  41. package/src/browser/react/use-router.ts +1 -1
  42. package/src/browser/react/use-search-params.ts +2 -1
  43. package/src/browser/react/use-segments.ts +7 -60
  44. package/src/browser/response-adapter.ts +73 -0
  45. package/src/browser/rsc-router.tsx +29 -23
  46. package/src/browser/scroll-restoration.ts +10 -7
  47. package/src/browser/server-action-bridge.ts +115 -96
  48. package/src/browser/types.ts +1 -31
  49. package/src/browser/validate-redirect-origin.ts +29 -0
  50. package/src/build/generate-manifest.ts +5 -0
  51. package/src/build/generate-route-types.ts +2 -0
  52. package/src/build/route-types/codegen.ts +13 -4
  53. package/src/build/route-types/include-resolution.ts +13 -0
  54. package/src/build/route-types/per-module-writer.ts +15 -3
  55. package/src/build/route-types/router-processing.ts +45 -3
  56. package/src/build/runtime-discovery.ts +13 -1
  57. package/src/cache/background-task.ts +34 -0
  58. package/src/cache/cache-key-utils.ts +44 -0
  59. package/src/cache/cache-policy.ts +125 -0
  60. package/src/cache/cache-runtime.ts +132 -96
  61. package/src/cache/cache-scope.ts +71 -73
  62. package/src/cache/cf/cf-cache-store.ts +9 -4
  63. package/src/cache/document-cache.ts +72 -47
  64. package/src/cache/handle-capture.ts +81 -0
  65. package/src/cache/memory-segment-store.ts +18 -7
  66. package/src/cache/profile-registry.ts +43 -8
  67. package/src/cache/read-through-swr.ts +134 -0
  68. package/src/cache/segment-codec.ts +101 -112
  69. package/src/cache/taint.ts +26 -0
  70. package/src/client.tsx +53 -30
  71. package/src/errors.ts +6 -1
  72. package/src/handle.ts +1 -1
  73. package/src/handles/MetaTags.tsx +5 -2
  74. package/src/host/cookie-handler.ts +8 -3
  75. package/src/host/router.ts +14 -1
  76. package/src/href-client.ts +3 -1
  77. package/src/index.rsc.ts +33 -1
  78. package/src/index.ts +27 -0
  79. package/src/loader.rsc.ts +12 -4
  80. package/src/loader.ts +8 -0
  81. package/src/prerender/store.ts +4 -3
  82. package/src/prerender.ts +76 -18
  83. package/src/reverse.ts +11 -7
  84. package/src/root-error-boundary.tsx +30 -26
  85. package/src/route-definition/dsl-helpers.ts +9 -6
  86. package/src/route-definition/redirect.ts +15 -3
  87. package/src/route-map-builder.ts +38 -2
  88. package/src/route-name.ts +53 -0
  89. package/src/route-types.ts +7 -0
  90. package/src/router/content-negotiation.ts +1 -1
  91. package/src/router/debug-manifest.ts +16 -3
  92. package/src/router/handler-context.ts +94 -15
  93. package/src/router/intercept-resolution.ts +6 -4
  94. package/src/router/lazy-includes.ts +4 -0
  95. package/src/router/loader-resolution.ts +1 -0
  96. package/src/router/logging.ts +100 -3
  97. package/src/router/manifest.ts +32 -3
  98. package/src/router/match-api.ts +61 -7
  99. package/src/router/match-context.ts +3 -0
  100. package/src/router/match-handlers.ts +185 -11
  101. package/src/router/match-middleware/background-revalidation.ts +65 -85
  102. package/src/router/match-middleware/cache-lookup.ts +69 -4
  103. package/src/router/match-middleware/cache-store.ts +2 -0
  104. package/src/router/match-pipelines.ts +8 -43
  105. package/src/router/middleware-types.ts +7 -0
  106. package/src/router/middleware.ts +93 -8
  107. package/src/router/pattern-matching.ts +41 -5
  108. package/src/router/prerender-match.ts +34 -6
  109. package/src/router/preview-match.ts +7 -1
  110. package/src/router/revalidation.ts +61 -2
  111. package/src/router/router-context.ts +15 -0
  112. package/src/router/router-interfaces.ts +34 -0
  113. package/src/router/router-options.ts +200 -0
  114. package/src/router/segment-resolution/fresh.ts +123 -30
  115. package/src/router/segment-resolution/helpers.ts +19 -0
  116. package/src/router/segment-resolution/loader-cache.ts +37 -146
  117. package/src/router/segment-resolution/revalidation.ts +358 -94
  118. package/src/router/segment-wrappers.ts +3 -0
  119. package/src/router/telemetry-otel.ts +299 -0
  120. package/src/router/telemetry.ts +300 -0
  121. package/src/router/timeout.ts +148 -0
  122. package/src/router/types.ts +7 -1
  123. package/src/router.ts +155 -11
  124. package/src/rsc/handler-context.ts +11 -0
  125. package/src/rsc/handler.ts +380 -88
  126. package/src/rsc/helpers.ts +25 -16
  127. package/src/rsc/loader-fetch.ts +84 -42
  128. package/src/rsc/origin-guard.ts +141 -0
  129. package/src/rsc/progressive-enhancement.ts +232 -19
  130. package/src/rsc/response-route-handler.ts +37 -26
  131. package/src/rsc/rsc-rendering.ts +12 -5
  132. package/src/rsc/runtime-warnings.ts +42 -0
  133. package/src/rsc/server-action.ts +134 -58
  134. package/src/rsc/types.ts +8 -0
  135. package/src/search-params.ts +22 -10
  136. package/src/server/context.ts +53 -5
  137. package/src/server/fetchable-loader-store.ts +11 -6
  138. package/src/server/handle-store.ts +66 -9
  139. package/src/server/loader-registry.ts +11 -46
  140. package/src/server/request-context.ts +90 -9
  141. package/src/ssr/index.tsx +63 -27
  142. package/src/static-handler.ts +7 -0
  143. package/src/theme/ThemeProvider.tsx +6 -1
  144. package/src/theme/index.ts +1 -6
  145. package/src/theme/theme-context.ts +1 -28
  146. package/src/theme/theme-script.ts +2 -1
  147. package/src/types/cache-types.ts +5 -0
  148. package/src/types/error-types.ts +3 -0
  149. package/src/types/global-namespace.ts +9 -0
  150. package/src/types/handler-context.ts +35 -13
  151. package/src/types/loader-types.ts +7 -0
  152. package/src/types/route-entry.ts +28 -0
  153. package/src/urls/include-helper.ts +49 -8
  154. package/src/urls/index.ts +1 -0
  155. package/src/urls/path-helper-types.ts +30 -12
  156. package/src/urls/path-helper.ts +17 -2
  157. package/src/urls/pattern-types.ts +21 -1
  158. package/src/urls/response-types.ts +27 -2
  159. package/src/urls/type-extraction.ts +23 -15
  160. package/src/use-loader.tsx +12 -4
  161. package/src/vite/discovery/bundle-postprocess.ts +12 -7
  162. package/src/vite/discovery/discover-routers.ts +30 -18
  163. package/src/vite/discovery/prerender-collection.ts +24 -27
  164. package/src/vite/discovery/route-types-writer.ts +7 -7
  165. package/src/vite/discovery/virtual-module-codegen.ts +5 -2
  166. package/src/vite/plugins/client-ref-hashing.ts +3 -3
  167. package/src/vite/plugins/use-cache-transform.ts +91 -3
  168. package/src/vite/rango.ts +3 -3
  169. package/src/vite/router-discovery.ts +99 -36
  170. package/src/vite/utils/prerender-utils.ts +21 -0
  171. package/src/vite/utils/shared-utils.ts +3 -1
  172. package/src/browser/request-controller.ts +0 -164
  173. package/src/href-context.ts +0 -33
  174. package/src/router.gen.ts +0 -6
  175. package/src/static-handler.gen.ts +0 -5
  176. package/src/urls.gen.ts +0 -8
  177. /package/src/browser/{prefetch-observer.ts → prefetch/observer.ts} +0 -0
@@ -86,19 +86,14 @@
86
86
  * -> output: cached segments + fresh loader data
87
87
  *
88
88
  *
89
- * TWO PIPELINE VARIANTS
90
- * =====================
91
- *
92
- * 1. createMatchPipeline (Full Match)
93
- * - Used for document requests (initial page load)
94
- * - No revalidation logic (no previous state to compare)
95
- * - Simpler segment resolution
96
- *
97
- * 2. createMatchPartialPipeline (Partial Match)
98
- * - Used for client-side navigation
99
- * - Includes revalidation for SWR
100
- * - Compares with previous params/URL
101
- * - Supports intercepts (soft navigation modals)
89
+ * PIPELINE VARIANT
90
+ * ================
91
+ *
92
+ * createMatchPartialPipeline handles both full (document) and partial
93
+ * (navigation) requests. The middleware steps adapt based on ctx.isFullMatch:
94
+ * - cache-lookup/store work for both
95
+ * - background-revalidation is a no-op for full matches (no stale state)
96
+ * - intercept-resolution is a no-op for full matches (no previous navigation)
102
97
  */
103
98
  import type { ResolvedSegment } from "../types.js";
104
99
  import type { MatchContext, MatchPipelineState } from "./match-context.js";
@@ -182,33 +177,3 @@ export function createMatchPartialPipeline<TEnv>(
182
177
  // Start with empty source - cache lookup or segment resolution will produce segments
183
178
  return pipeline(empty());
184
179
  }
185
-
186
- /**
187
- * Create the full match pipeline (simpler, no revalidation)
188
- *
189
- * Used for document requests (initial page load) where we don't need
190
- * revalidation logic since there's no previous state to compare against.
191
- */
192
- export function createMatchPipeline<TEnv>(
193
- ctx: MatchContext<TEnv>,
194
- state: MatchPipelineState,
195
- ): AsyncGenerator<ResolvedSegment> {
196
- // For full match, we only need:
197
- // 1. Cache lookup
198
- // 2. Segment resolution (without revalidation)
199
- // 3. Intercept resolution
200
- // 4. Cache store
201
-
202
- // Note: Full match uses different resolution logic (resolveAllSegments instead of
203
- // resolveAllSegmentsWithRevalidation). This will be handled by the segment resolution
204
- // middleware checking ctx.isFullMatch or similar flag.
205
-
206
- const pipeline = compose<ResolvedSegment>(
207
- withCacheStore(ctx, state),
208
- withInterceptResolution(ctx, state),
209
- withSegmentResolution(ctx, state),
210
- withCacheLookup(ctx, state),
211
- );
212
-
213
- return pipeline(empty());
214
- }
@@ -8,6 +8,7 @@
8
8
  import type { ContextVar } from "../context-var.js";
9
9
  import type {
10
10
  DefaultReverseRouteMap,
11
+ DefaultRouteName,
11
12
  DefaultVars,
12
13
  } from "../types/global-namespace.js";
13
14
  import type { ScopedReverseFunction } from "../reverse.js";
@@ -93,6 +94,12 @@ export interface MiddlewareContext<
93
94
  */
94
95
  header(name: string, value: string): void;
95
96
 
97
+ /**
98
+ * The matched route name, if available and the route has an explicit name.
99
+ * Undefined for global middleware (runs before route matching) or unnamed routes.
100
+ */
101
+ routeName?: DefaultRouteName;
102
+
96
103
  /**
97
104
  * Generate URLs from route names.
98
105
  * - `name` — global route, from the named-routes definition
@@ -19,6 +19,7 @@ import type {
19
19
  ResponseHolder,
20
20
  } from "./middleware-types.js";
21
21
  import { _getRequestContext } from "../server/request-context.js";
22
+ import { isAutoGeneratedRouteName } from "../route-name.js";
22
23
 
23
24
  // Re-export types and cookie utilities for backward compatibility
24
25
  export type {
@@ -32,6 +33,27 @@ export type {
32
33
  } from "./middleware-types.js";
33
34
  export { parseCookies, serializeCookie } from "./middleware-cookies.js";
34
35
 
36
+ // W5: Deduplicate by function reference so each distinct middleware warns once,
37
+ // regardless of whether it is named or anonymous.
38
+ let warnedRedirectMiddleware = new WeakSet<Function>();
39
+
40
+ function warnCtxSetBeforeRedirect(handler: Function): void {
41
+ if (warnedRedirectMiddleware.has(handler)) return;
42
+ warnedRedirectMiddleware.add(handler);
43
+ const label = handler.name || "(anonymous)";
44
+ console.warn(
45
+ `[rango] Route middleware "${label}" called ctx.set() then returned a ` +
46
+ `redirect. Context variables are per-request and won't be available ` +
47
+ `on the redirect target. Use cookies to persist state across ` +
48
+ `redirects, or move ctx.set() to the target route's middleware.`,
49
+ );
50
+ }
51
+
52
+ /** Reset W5 deduplication state (for tests only). */
53
+ export function _resetW5Warnings(): void {
54
+ warnedRedirectMiddleware = new WeakSet();
55
+ }
56
+
35
57
  /**
36
58
  * Parse a route pattern into regex and param names
37
59
  * Supports: *, /path, /path/*, /path/:param, /path/:param/*
@@ -143,6 +165,15 @@ export function createMiddlewareContext<TEnv>(
143
165
  searchParams: url.searchParams,
144
166
  env: env as MiddlewareContext<TEnv>["env"],
145
167
  params,
168
+ // Getter: re-derives from request context on each access so that global
169
+ // middleware sees the matched route name after await next().
170
+ get routeName(): MiddlewareContext<TEnv>["routeName"] {
171
+ const reqCtx = _getRequestContext();
172
+ const raw = reqCtx?._routeName;
173
+ return (
174
+ raw && !isAutoGeneratedRouteName(raw) ? raw : undefined
175
+ ) as MiddlewareContext<TEnv>["routeName"];
176
+ },
146
177
 
147
178
  get res(): Response {
148
179
  // Before next(): return shared RequestContext stub so headers
@@ -267,8 +298,8 @@ export async function executeMiddleware<TEnv>(
267
298
  // End of chain - call actual RSC handler
268
299
  const response = await finalHandler();
269
300
 
270
- // Merge headers set on stub into the real response
271
- // Use append for Set-Cookie to preserve multiple cookies
301
+ // Merge headers set on stub into the real response.
302
+ // Use append for Set-Cookie to preserve multiple cookies.
272
303
  const mergedHeaders = new Headers(response.headers);
273
304
  stubResponse.headers.forEach((value, name) => {
274
305
  if (name.toLowerCase() === "set-cookie") {
@@ -277,7 +308,9 @@ export async function executeMiddleware<TEnv>(
277
308
  mergedHeaders.set(name, value);
278
309
  }
279
310
  });
280
- // Also merge shared RequestContext stub (cookies written via cookies().set())
311
+ // Also merge shared RequestContext stub (cookies written via cookies().set()).
312
+ // Set-Cookie duplication is prevented by createResponseWithMergedHeaders
313
+ // draining Set-Cookie from ctx.res after merging (helpers.ts).
281
314
  const reqCtx = _getRequestContext();
282
315
  if (reqCtx) {
283
316
  reqCtx.res.headers.forEach((value, name) => {
@@ -309,14 +342,30 @@ export async function executeMiddleware<TEnv>(
309
342
  reverse,
310
343
  );
311
344
 
312
- // Track if next() was called and capture its Promise
313
- // This handles the case where middleware calls next() synchronously without await
345
+ // Track if next() was called and capture its Promise.
346
+ // Guard against double-calling: a second call would re-enter the
347
+ // downstream chain and overwrite responseHolder.response.
314
348
  let nextPromise: Promise<Response> | null = null;
315
349
  const wrappedNext = (): Promise<Response> => {
350
+ if (nextPromise) {
351
+ throw new Error(
352
+ `[@rangojs/router] Middleware called next() more than once.`,
353
+ );
354
+ }
316
355
  nextPromise = next();
317
356
  return nextPromise;
318
357
  };
319
358
 
359
+ // W5: track whether ctx.set() is called during this middleware
360
+ let ctxSetCalled = false;
361
+ if (process.env.NODE_ENV !== "production") {
362
+ const originalSet = ctx.set;
363
+ ctx.set = ((...args: any[]) => {
364
+ ctxSetCalled = true;
365
+ return (originalSet as Function).apply(ctx, args);
366
+ }) as typeof ctx.set;
367
+ }
368
+
320
369
  const result = await entry.handler(ctx, wrappedNext);
321
370
 
322
371
  // Explicit return takes precedence (middleware short-circuit).
@@ -324,6 +373,16 @@ export async function executeMiddleware<TEnv>(
324
373
  // RequestContext stub headers (from ctx.setCookie) into the
325
374
  // returned Response so they are not lost.
326
375
  if (result instanceof Response) {
376
+ // W5: warn if ctx.set() was called but middleware returned a redirect
377
+ if (
378
+ process.env.NODE_ENV !== "production" &&
379
+ ctxSetCalled &&
380
+ result.status >= 300 &&
381
+ result.status < 400
382
+ ) {
383
+ warnCtxSetBeforeRedirect(entry.handler);
384
+ }
385
+
327
386
  const mergedHeaders = new Headers(result.headers);
328
387
  stubResponse.headers.forEach((value, name) => {
329
388
  if (name.toLowerCase() === "set-cookie") {
@@ -365,6 +424,19 @@ export async function executeMiddleware<TEnv>(
365
424
  // If middleware called next(), await it and return the response
366
425
  if (nextPromise) {
367
426
  await nextPromise;
427
+
428
+ // W5: warn if ctx.set() was called but the downstream response is a redirect.
429
+ // The ctx.set() values will be lost because the redirect navigates away.
430
+ if (
431
+ process.env.NODE_ENV !== "production" &&
432
+ ctxSetCalled &&
433
+ responseHolder.response &&
434
+ responseHolder.response.status >= 300 &&
435
+ responseHolder.response.status < 400
436
+ ) {
437
+ warnCtxSetBeforeRedirect(entry.handler);
438
+ }
439
+
368
440
  return responseHolder.response!;
369
441
  }
370
442
 
@@ -443,7 +515,18 @@ export async function executeInterceptMiddleware<TEnv>(
443
515
  reverse,
444
516
  );
445
517
 
446
- const result = await middleware(ctx, next);
518
+ let nextCalled = false;
519
+ const guardedNext = (): Promise<Response> => {
520
+ if (nextCalled) {
521
+ throw new Error(
522
+ `[@rangojs/router] Intercept middleware called next() more than once.`,
523
+ );
524
+ }
525
+ nextCalled = true;
526
+ return next();
527
+ };
528
+
529
+ const result = await middleware(ctx, guardedNext);
447
530
 
448
531
  if (result instanceof Response) {
449
532
  earlyResponse = result;
@@ -467,12 +550,14 @@ export async function executeInterceptMiddleware<TEnv>(
467
550
  });
468
551
 
469
552
  if (hasStubHeaders) {
470
- // Clone and merge headers from stub into early response
553
+ // Clone and merge headers from stub into early response.
554
+ // Only fill in missing headers — the returned Response's explicit
555
+ // headers take precedence, matching executeMiddleware behavior.
471
556
  const mergedHeaders = new Headers(response.headers);
472
557
  stubResponse.headers.forEach((value, name) => {
473
558
  if (name.toLowerCase() === "set-cookie") {
474
559
  mergedHeaders.append(name, value);
475
- } else {
560
+ } else if (!mergedHeaders.has(name)) {
476
561
  mergedHeaders.set(name, value);
477
562
  }
478
563
  });
@@ -140,7 +140,7 @@ export function compilePattern(pattern: string): CompiledPattern {
140
140
  } else if (segment.type === "param") {
141
141
  paramNames.push(segment.value);
142
142
  const valuePattern = segment.constraint
143
- ? `(${segment.constraint.join("|")})`
143
+ ? `(${segment.constraint.map(escapeRegex).join("|")})`
144
144
  : "([^/]+)";
145
145
 
146
146
  if (segment.optional) {
@@ -388,6 +388,9 @@ export function findMatch<TEnv>(
388
388
  const prFlag = entry.prerenderRouteKeys?.has(routeKey)
389
389
  ? { pr: true as const }
390
390
  : {};
391
+ const ptFlag = entry.passthroughRouteKeys?.has(routeKey)
392
+ ? { pt: true as const }
393
+ : {};
391
394
 
392
395
  // Try exact match first
393
396
  const match = regex.exec(pathname);
@@ -419,6 +422,7 @@ export function findMatch<TEnv>(
419
422
  optionalParams,
420
423
  redirectTo: pathname + "/",
421
424
  ...prFlag,
425
+ ...ptFlag,
422
426
  };
423
427
  } else if (trailingSlashMode === "never" && pathnameHasTrailingSlash) {
424
428
  // Mode says never have trailing slash, but pathname has it
@@ -429,10 +433,18 @@ export function findMatch<TEnv>(
429
433
  optionalParams,
430
434
  redirectTo: pathname.slice(0, -1),
431
435
  ...prFlag,
436
+ ...ptFlag,
432
437
  };
433
438
  }
434
439
 
435
- return { entry, routeKey, params, optionalParams, ...prFlag };
440
+ return {
441
+ entry,
442
+ routeKey,
443
+ params,
444
+ optionalParams,
445
+ ...prFlag,
446
+ ...ptFlag,
447
+ };
436
448
  }
437
449
 
438
450
  // Try alternate pathname (opposite trailing slash)
@@ -446,7 +458,14 @@ export function findMatch<TEnv>(
446
458
  // Determine redirect behavior based on mode
447
459
  if (trailingSlashMode === "ignore") {
448
460
  // Match without redirect
449
- return { entry, routeKey, params, optionalParams, ...prFlag };
461
+ return {
462
+ entry,
463
+ routeKey,
464
+ params,
465
+ optionalParams,
466
+ ...prFlag,
467
+ ...ptFlag,
468
+ };
450
469
  } else if (trailingSlashMode === "never") {
451
470
  // Redirect to no trailing slash
452
471
  if (pathnameHasTrailingSlash) {
@@ -457,9 +476,17 @@ export function findMatch<TEnv>(
457
476
  optionalParams,
458
477
  redirectTo: alternatePathname,
459
478
  ...prFlag,
479
+ ...ptFlag,
460
480
  };
461
481
  }
462
- return { entry, routeKey, params, optionalParams, ...prFlag };
482
+ return {
483
+ entry,
484
+ routeKey,
485
+ params,
486
+ optionalParams,
487
+ ...prFlag,
488
+ ...ptFlag,
489
+ };
463
490
  } else if (trailingSlashMode === "always") {
464
491
  // Redirect to with trailing slash
465
492
  if (!pathnameHasTrailingSlash) {
@@ -470,9 +497,17 @@ export function findMatch<TEnv>(
470
497
  optionalParams,
471
498
  redirectTo: alternatePathname,
472
499
  ...prFlag,
500
+ ...ptFlag,
473
501
  };
474
502
  }
475
- return { entry, routeKey, params, optionalParams, ...prFlag };
503
+ return {
504
+ entry,
505
+ routeKey,
506
+ params,
507
+ optionalParams,
508
+ ...prFlag,
509
+ ...ptFlag,
510
+ };
476
511
  } else {
477
512
  // No explicit mode - use pattern-based detection
478
513
  // Redirect to canonical form (what the pattern defines)
@@ -486,6 +521,7 @@ export function findMatch<TEnv>(
486
521
  optionalParams,
487
522
  redirectTo: canonicalPath,
488
523
  ...prFlag,
524
+ ...ptFlag,
489
525
  };
490
526
  }
491
527
  }
@@ -11,6 +11,8 @@ import {
11
11
  createStaticContext,
12
12
  createReverseFunction,
13
13
  } from "./handler-context.js";
14
+ import { isPrerenderPassthrough } from "../prerender.js";
15
+ import { isRouteRootScoped } from "../route-map-builder.js";
14
16
  import { setupBuildUse } from "./loader-resolution.js";
15
17
  import { loadManifest } from "./manifest.js";
16
18
  import { traverseBack } from "./pattern-matching.js";
@@ -51,6 +53,7 @@ export async function matchForPrerender<TEnv = any>(
51
53
  params: Record<string, string>,
52
54
  deps: PrerenderMatchDeps<TEnv>,
53
55
  buildVars?: Record<string, any>,
56
+ isPassthroughRoute?: boolean,
54
57
  ): Promise<{
55
58
  segments: SerializedSegmentData[];
56
59
  handles: Record<string, SegmentHandleData>;
@@ -58,6 +61,7 @@ export async function matchForPrerender<TEnv = any>(
58
61
  params: Record<string, string>;
59
62
  interceptSegments?: SerializedSegmentData[];
60
63
  interceptHandles?: Record<string, SegmentHandleData>;
64
+ passthrough?: true;
61
65
  } | null> {
62
66
  // 1. Find the matching route entry
63
67
  const matched = deps.findMatch(pathname);
@@ -65,6 +69,7 @@ export async function matchForPrerender<TEnv = any>(
65
69
 
66
70
  // Use params from trie match if available, fall back to provided params
67
71
  const matchedParams = matched.params ?? params;
72
+ const matchedPassthroughRoute = isPassthroughRoute ?? matched.pt === true;
68
73
 
69
74
  // Build RouterContext for loadManifest/traverseBack
70
75
  const routerCtx = deps.buildRouterContext();
@@ -121,10 +126,12 @@ export async function matchForPrerender<TEnv = any>(
121
126
  _onResponseCallbacks: [],
122
127
  setLocationState() {},
123
128
  _locationState: undefined,
129
+ _reportedErrors: new WeakSet<object>(),
124
130
  reverse: createReverseFunction(
125
131
  deps.mergedRouteMap,
126
132
  matched.routeKey,
127
133
  matchedParams,
134
+ matched.routeKey ? isRouteRootScoped(matched.routeKey) : undefined,
128
135
  ),
129
136
  };
130
137
 
@@ -138,6 +145,7 @@ export async function matchForPrerender<TEnv = any>(
138
145
  deps.mergedRouteMap,
139
146
  matched.routeKey,
140
147
  variables,
148
+ matchedPassthroughRoute,
141
149
  );
142
150
 
143
151
  // 7. Wire use() for handles only (loaders throw)
@@ -154,17 +162,31 @@ export async function matchForPrerender<TEnv = any>(
154
162
  { skipLoaders: true },
155
163
  );
156
164
 
157
- // 9. Filter out any loader segments (belt-and-suspenders)
165
+ // 9. Detect passthrough sentinel: handler returned ctx.passthrough()
166
+ for (const seg of allSegments) {
167
+ if (isPrerenderPassthrough(seg.component)) {
168
+ return {
169
+ segments: [],
170
+ handles: {},
171
+ routeName: matched.routeKey,
172
+ params: matchedParams,
173
+ passthrough: true as const,
174
+ };
175
+ }
176
+ }
177
+
178
+ // 10. Filter out any loader segments (belt-and-suspenders)
158
179
  const nonLoaderSegments = allSegments.filter((s) => s.type !== "loader");
159
180
 
160
- // 10. Wait for handles to settle
181
+ // 11. Wait for handles to settle
182
+ handleStore.seal();
161
183
  await handleStore.settled;
162
184
 
163
- // 11. Serialize segments using the cache serializer
185
+ // 12. Serialize segments using the cache serializer
164
186
  const { serializeSegments } = await import("../cache/segment-codec.js");
165
187
  const serializedSegments = await serializeSegments(nonLoaderSegments);
166
188
 
167
- // 12. Collect handle data per segment (skip segments with no handle data)
189
+ // 13. Collect handle data per segment (skip segments with no handle data)
168
190
  const handles: Record<string, SegmentHandleData> = {};
169
191
  for (const seg of nonLoaderSegments) {
170
192
  const segHandles = handleStore.getDataForSegment(seg.id);
@@ -176,7 +198,7 @@ export async function matchForPrerender<TEnv = any>(
176
198
  // Use the trie-level route key (e.g., "docs", "docs.article")
177
199
  const routeName = matched.routeKey;
178
200
 
179
- // 13. Resolve intercept segments for this route (if any ancestor defines
201
+ // 14. Resolve intercept segments for this route (if any ancestor defines
180
202
  // an intercept targeting this route). At build time we skip when()
181
203
  // evaluation -- we pre-render all intercepts unconditionally and let
182
204
  // runtime matching decide which to serve.
@@ -332,7 +354,13 @@ export async function renderStaticSegment<TEnv = any>(
332
354
  _onResponseCallbacks: [],
333
355
  setLocationState() {},
334
356
  _locationState: undefined,
335
- reverse: createReverseFunction(mergedRouteMap, routeName, {}),
357
+ _reportedErrors: new WeakSet<object>(),
358
+ reverse: createReverseFunction(
359
+ mergedRouteMap,
360
+ routeName,
361
+ {},
362
+ routeName ? isRouteRootScoped(routeName) : undefined,
363
+ ),
336
364
  };
337
365
 
338
366
  return runWithRequestContext(minimalRequestContext, async () => {
@@ -122,9 +122,15 @@ export async function previewMatch<TEnv = any>(
122
122
  undefined,
123
123
  false,
124
124
  );
125
+ // Recompute middleware from the selected variant's entry tree
126
+ // since different variants can have different middleware chains.
127
+ const variantMiddleware = collectRouteMiddleware(
128
+ traverseBack(negotiateEntry),
129
+ matched.params,
130
+ );
125
131
  return {
126
132
  routeMiddleware:
127
- routeMiddleware.length > 0 ? routeMiddleware : undefined,
133
+ variantMiddleware.length > 0 ? variantMiddleware : undefined,
128
134
  responseType: variant.responseType,
129
135
  handler:
130
136
  negotiateEntry.type === "route"
@@ -6,7 +6,14 @@
6
6
 
7
7
  import type { ResolvedSegment, HandlerContext } from "../types";
8
8
  import type { ActionContext } from "./types";
9
- import { debugLog } from "./logging.js";
9
+ import {
10
+ debugLog,
11
+ pushRevalidationTraceEntry,
12
+ isTraceActive,
13
+ } from "./logging.js";
14
+ import type { RevalidationTraceEntry } from "./logging.js";
15
+ import { _getRequestContext } from "../server/request-context.js";
16
+ import { isAutoGeneratedRouteName } from "../route-name.js";
10
17
 
11
18
  function paramsEqual(
12
19
  a: Record<string, string>,
@@ -50,6 +57,8 @@ interface EvaluateRevalidationOptions<TEnv> {
50
57
  actionContext?: ActionContext;
51
58
  /** If true, this is a stale cache revalidation request */
52
59
  stale?: boolean;
60
+ /** Trace source hint for the revalidation trace */
61
+ traceSource?: RevalidationTraceEntry["source"];
53
62
  }
54
63
 
55
64
  /**
@@ -71,28 +80,54 @@ export async function evaluateRevalidation<TEnv>(
71
80
  context,
72
81
  actionContext,
73
82
  stale,
83
+ traceSource,
74
84
  } = options;
75
85
  const nextParams = segment.params || {};
76
86
  const paramsChanged = !paramsEqual(nextParams, prevParams);
77
87
 
88
+ // Trace helper: push a structured entry to the request-scoped trace buffer.
89
+ // Guarded by isTraceActive() so object construction is skipped in production.
90
+ function pushTrace(
91
+ defaultVal: boolean,
92
+ finalVal: boolean,
93
+ reason: string,
94
+ ): void {
95
+ if (!isTraceActive()) return;
96
+ pushRevalidationTraceEntry({
97
+ segmentId: segment.id,
98
+ segmentType: segment.type,
99
+ belongsToRoute: segment.belongsToRoute ?? false,
100
+ source: traceSource ?? "segment-resolution",
101
+ defaultShouldRevalidate: defaultVal,
102
+ finalShouldRevalidate: finalVal,
103
+ reason,
104
+ customRevalidators: revalidations.length || undefined,
105
+ });
106
+ }
107
+
78
108
  // Calculate default revalidation based on segment type and request method
79
109
  let defaultShouldRevalidate: boolean;
110
+ let defaultReason: string;
80
111
 
81
112
  if (request.method === "POST") {
82
113
  // Actions: revalidate segments that belong to the route, skip parent chain
83
114
  if (segment.type === "route") {
84
115
  // Route segment always revalidates on actions
85
116
  defaultShouldRevalidate = true;
117
+ defaultReason = "action:route-segment";
86
118
  } else if (segment.type === "loader") {
87
119
  // Loaders always revalidate on actions - they often contain action-sensitive data
88
120
  // (e.g., cart count after add-to-cart action)
89
121
  defaultShouldRevalidate = true;
122
+ defaultReason = "action:loader-segment";
90
123
  } else if (segment.belongsToRoute) {
91
124
  // Segment belongs to route (orphan layouts/parallels) - revalidate
92
125
  defaultShouldRevalidate = true;
126
+ defaultReason = "action:belongs-to-route";
93
127
  } else {
94
128
  // Parent chain segment (shared layouts/parallels) - don't revalidate
95
129
  defaultShouldRevalidate = false;
130
+ defaultReason = "action:parent-chain-skip";
96
131
  }
97
132
  } else {
98
133
  // Navigation (GET): Conservative defaults to minimize unnecessary revalidations
@@ -102,6 +137,9 @@ export async function evaluateRevalidation<TEnv>(
102
137
  // Route segments revalidate when params change
103
138
  // Routes are the primary param-dependent content and always need updates
104
139
  defaultShouldRevalidate = paramsChanged;
140
+ defaultReason = paramsChanged
141
+ ? "nav:params-changed"
142
+ : "nav:params-unchanged";
105
143
  if (paramsChanged) {
106
144
  debugLog("revalidation", "route params changed, revalidating", {
107
145
  segmentId: segment.id,
@@ -112,6 +150,7 @@ export async function evaluateRevalidation<TEnv>(
112
150
  // Cannot assume these segments depend on params without explicit declaration
113
151
  // Use custom revalidation functions to opt-in when needed
114
152
  defaultShouldRevalidate = false;
153
+ defaultReason = "nav:non-route-skip";
115
154
  debugLog("revalidation", "non-route segment skipped by default", {
116
155
  segmentId: segment.id,
117
156
  segmentType: segment.type,
@@ -132,6 +171,7 @@ export async function evaluateRevalidation<TEnv>(
132
171
  segmentId: segment.id,
133
172
  });
134
173
  }
174
+ pushTrace(defaultShouldRevalidate, defaultShouldRevalidate, defaultReason);
135
175
  return defaultShouldRevalidate;
136
176
  }
137
177
 
@@ -142,6 +182,16 @@ export async function evaluateRevalidation<TEnv>(
142
182
  // Execute revalidation functions with soft/hard decision pattern
143
183
  let currentSuggestion = defaultShouldRevalidate;
144
184
 
185
+ // Compute public route names (filtered: undefined for auto-generated routes)
186
+ const toRouteName =
187
+ routeKey && !isAutoGeneratedRouteName(routeKey) ? routeKey : undefined;
188
+ const reqCtx = _getRequestContext();
189
+ const prevRouteKey = reqCtx?._prevRouteKey;
190
+ const fromRouteName =
191
+ prevRouteKey && !isAutoGeneratedRouteName(prevRouteKey)
192
+ ? prevRouteKey
193
+ : undefined;
194
+
145
195
  for (const { name, fn } of revalidations) {
146
196
  const result = fn({
147
197
  currentParams: prevSegment?.params || prevParams, // Use segment params if available, else route params
@@ -160,7 +210,9 @@ export async function evaluateRevalidation<TEnv>(
160
210
  actionResult: actionContext?.actionResult,
161
211
  formData: actionContext?.formData,
162
212
  method: request.method, // GET for navigation, POST for actions
163
- routeName: routeKey, // User-friendly route name (e.g., "products.detail")
213
+ routeName: toRouteName, // Navigation target route name (filtered)
214
+ fromRouteName, // Navigation source route name (filtered)
215
+ toRouteName, // Navigation target route name (filtered)
164
216
  // Stale cache context (only true for background revalidation after stale cache render)
165
217
  stale,
166
218
  });
@@ -176,6 +228,7 @@ export async function evaluateRevalidation<TEnv>(
176
228
  revalidator: name,
177
229
  revalidate: result,
178
230
  });
231
+ pushTrace(defaultShouldRevalidate, result, `hard:${name}`);
179
232
  return result;
180
233
  } else if (
181
234
  result &&
@@ -206,5 +259,11 @@ export async function evaluateRevalidation<TEnv>(
206
259
  segmentId: segment.id,
207
260
  revalidate: currentSuggestion,
208
261
  });
262
+ const softNames = revalidations.map((r) => r.name).join(",");
263
+ pushTrace(
264
+ defaultShouldRevalidate,
265
+ currentSuggestion,
266
+ `soft-chain:${softNames}`,
267
+ );
209
268
  return currentSuggestion;
210
269
  }