@rangojs/router 0.0.0-experimental.1b930379 → 0.0.0-experimental.1fa245e2

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 (136) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +76 -18
  3. package/dist/bin/rango.js +138 -50
  4. package/dist/vite/index.js +558 -319
  5. package/package.json +16 -15
  6. package/skills/cache-guide/SKILL.md +32 -0
  7. package/skills/caching/SKILL.md +45 -4
  8. package/skills/links/SKILL.md +3 -1
  9. package/skills/loader/SKILL.md +53 -43
  10. package/skills/middleware/SKILL.md +2 -0
  11. package/skills/parallel/SKILL.md +126 -0
  12. package/skills/prerender/SKILL.md +110 -68
  13. package/skills/route/SKILL.md +31 -0
  14. package/skills/router-setup/SKILL.md +87 -2
  15. package/skills/typesafety/SKILL.md +10 -0
  16. package/src/__internal.ts +1 -1
  17. package/src/browser/app-version.ts +14 -0
  18. package/src/browser/event-controller.ts +5 -0
  19. package/src/browser/navigation-bridge.ts +19 -13
  20. package/src/browser/navigation-client.ts +115 -58
  21. package/src/browser/navigation-store.ts +43 -8
  22. package/src/browser/navigation-transaction.ts +11 -9
  23. package/src/browser/partial-update.ts +80 -15
  24. package/src/browser/prefetch/cache.ts +57 -5
  25. package/src/browser/prefetch/fetch.ts +38 -23
  26. package/src/browser/prefetch/queue.ts +92 -20
  27. package/src/browser/prefetch/resource-ready.ts +77 -0
  28. package/src/browser/react/Link.tsx +53 -9
  29. package/src/browser/react/NavigationProvider.tsx +40 -4
  30. package/src/browser/react/context.ts +7 -2
  31. package/src/browser/react/use-handle.ts +9 -58
  32. package/src/browser/react/use-router.ts +21 -8
  33. package/src/browser/rsc-router.tsx +134 -59
  34. package/src/browser/scroll-restoration.ts +41 -42
  35. package/src/browser/segment-reconciler.ts +6 -1
  36. package/src/browser/server-action-bridge.ts +8 -6
  37. package/src/browser/types.ts +36 -5
  38. package/src/build/generate-manifest.ts +6 -6
  39. package/src/build/generate-route-types.ts +3 -0
  40. package/src/build/route-types/include-resolution.ts +8 -1
  41. package/src/build/route-types/router-processing.ts +223 -74
  42. package/src/build/route-types/scan-filter.ts +8 -1
  43. package/src/cache/cache-runtime.ts +15 -11
  44. package/src/cache/cache-scope.ts +48 -7
  45. package/src/cache/cf/cf-cache-store.ts +453 -11
  46. package/src/cache/cf/index.ts +5 -1
  47. package/src/cache/document-cache.ts +17 -7
  48. package/src/cache/index.ts +1 -0
  49. package/src/cache/taint.ts +55 -0
  50. package/src/client.tsx +2 -56
  51. package/src/context-var.ts +72 -2
  52. package/src/debug.ts +2 -2
  53. package/src/handle.ts +40 -0
  54. package/src/index.rsc.ts +3 -1
  55. package/src/index.ts +8 -0
  56. package/src/prerender/store.ts +5 -4
  57. package/src/prerender.ts +138 -77
  58. package/src/reverse.ts +22 -1
  59. package/src/route-definition/dsl-helpers.ts +73 -25
  60. package/src/route-definition/helpers-types.ts +10 -6
  61. package/src/route-definition/index.ts +3 -0
  62. package/src/route-definition/redirect.ts +11 -3
  63. package/src/route-definition/resolve-handler-use.ts +149 -0
  64. package/src/route-map-builder.ts +7 -1
  65. package/src/route-types.ts +11 -0
  66. package/src/router/content-negotiation.ts +100 -1
  67. package/src/router/find-match.ts +4 -2
  68. package/src/router/handler-context.ts +79 -23
  69. package/src/router/intercept-resolution.ts +11 -4
  70. package/src/router/lazy-includes.ts +4 -1
  71. package/src/router/loader-resolution.ts +122 -10
  72. package/src/router/logging.ts +5 -2
  73. package/src/router/manifest.ts +9 -3
  74. package/src/router/match-api.ts +124 -189
  75. package/src/router/match-middleware/background-revalidation.ts +30 -2
  76. package/src/router/match-middleware/cache-lookup.ts +88 -16
  77. package/src/router/match-middleware/cache-store.ts +53 -10
  78. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  79. package/src/router/match-middleware/segment-resolution.ts +61 -5
  80. package/src/router/match-result.ts +22 -6
  81. package/src/router/metrics.ts +6 -1
  82. package/src/router/middleware-types.ts +6 -8
  83. package/src/router/middleware.ts +4 -6
  84. package/src/router/navigation-snapshot.ts +182 -0
  85. package/src/router/prerender-match.ts +110 -10
  86. package/src/router/preview-match.ts +30 -102
  87. package/src/router/request-classification.ts +310 -0
  88. package/src/router/route-snapshot.ts +245 -0
  89. package/src/router/router-context.ts +6 -1
  90. package/src/router/router-interfaces.ts +36 -4
  91. package/src/router/router-options.ts +37 -11
  92. package/src/router/segment-resolution/fresh.ts +183 -20
  93. package/src/router/segment-resolution/helpers.ts +29 -24
  94. package/src/router/segment-resolution/loader-cache.ts +1 -0
  95. package/src/router/segment-resolution/revalidation.ts +412 -297
  96. package/src/router/segment-wrappers.ts +2 -0
  97. package/src/router/types.ts +1 -0
  98. package/src/router.ts +59 -6
  99. package/src/rsc/handler.ts +460 -368
  100. package/src/rsc/manifest-init.ts +5 -1
  101. package/src/rsc/progressive-enhancement.ts +4 -0
  102. package/src/rsc/rsc-rendering.ts +5 -0
  103. package/src/rsc/server-action.ts +2 -0
  104. package/src/rsc/ssr-setup.ts +2 -2
  105. package/src/rsc/types.ts +8 -1
  106. package/src/segment-system.tsx +140 -4
  107. package/src/server/context.ts +140 -14
  108. package/src/server/loader-registry.ts +9 -8
  109. package/src/server/request-context.ts +144 -18
  110. package/src/ssr/index.tsx +4 -0
  111. package/src/static-handler.ts +18 -6
  112. package/src/types/cache-types.ts +4 -4
  113. package/src/types/handler-context.ts +137 -33
  114. package/src/types/loader-types.ts +36 -9
  115. package/src/types/route-entry.ts +8 -1
  116. package/src/types/segments.ts +2 -0
  117. package/src/urls/path-helper-types.ts +9 -2
  118. package/src/urls/path-helper.ts +48 -13
  119. package/src/urls/pattern-types.ts +12 -0
  120. package/src/urls/response-types.ts +16 -6
  121. package/src/use-loader.tsx +73 -4
  122. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  123. package/src/vite/discovery/discover-routers.ts +5 -1
  124. package/src/vite/discovery/prerender-collection.ts +14 -1
  125. package/src/vite/discovery/state.ts +13 -6
  126. package/src/vite/index.ts +4 -0
  127. package/src/vite/plugin-types.ts +51 -79
  128. package/src/vite/plugins/expose-action-id.ts +1 -3
  129. package/src/vite/plugins/performance-tracks.ts +88 -0
  130. package/src/vite/plugins/refresh-cmd.ts +88 -26
  131. package/src/vite/plugins/version-plugin.ts +13 -1
  132. package/src/vite/rango.ts +163 -211
  133. package/src/vite/router-discovery.ts +153 -42
  134. package/src/vite/utils/banner.ts +3 -3
  135. package/src/vite/utils/prerender-utils.ts +18 -0
  136. package/src/vite/utils/shared-utils.ts +3 -2
@@ -70,9 +70,11 @@
70
70
  * - No segments yielded from this middleware
71
71
  *
72
72
  * Loaders:
73
- * - NEVER cached by design
73
+ * - NEVER cached in the segment cache
74
74
  * - Always resolved fresh on every request
75
75
  * - Ensures data freshness even with cached UI components
76
+ * - Segment cache staleness does NOT propagate to loader revalidation;
77
+ * loaders use their own revalidation rules (actionId, user-defined)
76
78
  *
77
79
  *
78
80
  * REVALIDATION RULES
@@ -94,6 +96,7 @@ import type { MatchContext, MatchPipelineState } from "../match-context.js";
94
96
  import { getRouterContext } from "../router-context.js";
95
97
  import { resolveSink, safeEmit } from "../telemetry.js";
96
98
  import { pushRevalidationTraceEntry, isTraceActive } from "../logging.js";
99
+ import { treeHasStreaming } from "./segment-resolution.js";
97
100
  import type { PrerenderStore, PrerenderEntry } from "../../prerender/store.js";
98
101
  import type { HandleStore } from "../../server/handle-store.js";
99
102
  import {
@@ -191,6 +194,14 @@ async function* yieldFromStore<TEnv>(
191
194
  state.cachedSegments = segments;
192
195
  state.cachedMatchedIds = segments.map((s) => s.id);
193
196
 
197
+ // Set streaming flag and resolve render barrier.
198
+ const reqCtx = handleStoreRef ? undefined : _lazyGetRequestContext?.();
199
+ const barrierReqCtx = reqCtx ?? _getRequestContext();
200
+ if (barrierReqCtx) {
201
+ barrierReqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
202
+ barrierReqCtx._resolveRenderBarrier(segments);
203
+ }
204
+
194
205
  // For partial navigation, nullify components the client already has
195
206
  // so parent layouts stay live (client keeps its existing versions).
196
207
  // When params changed (e.g., different guide slug), the segments have
@@ -210,6 +221,9 @@ async function* yieldFromStore<TEnv>(
210
221
  }
211
222
 
212
223
  // Resolve loaders fresh (loaders are never pre-rendered/cached)
224
+ const ms = ctx.metricsStore;
225
+ const loaderStart = performance.now();
226
+
213
227
  if (ctx.isFullMatch) {
214
228
  if (resolveLoadersOnly) {
215
229
  const loaderSegments = await ctx.Store.run(() =>
@@ -249,11 +263,17 @@ async function* yieldFromStore<TEnv>(
249
263
  }
250
264
  }
251
265
 
252
- const ms = ctx.metricsStore;
253
266
  if (ms) {
267
+ const loaderEnd = performance.now();
254
268
  ms.metrics.push({
255
- label: "pipeline:cache-lookup",
256
- duration: performance.now() - pipelineStart,
269
+ label: "pipeline:loader-resolve",
270
+ duration: loaderEnd - loaderStart,
271
+ startTime: loaderStart - ms.requestStart,
272
+ depth: 1,
273
+ });
274
+ ms.metrics.push({
275
+ label: "pipeline:cache-hit",
276
+ duration: loaderEnd - pipelineStart,
257
277
  startTime: pipelineStart - ms.requestStart,
258
278
  });
259
279
  }
@@ -305,14 +325,15 @@ export function withCacheLookup<TEnv>(
305
325
 
306
326
  // Prerender lookup: check build-time cached data before runtime cache.
307
327
  // Prerender data is available regardless of runtime cache configuration.
308
- if (!ctx.isAction && ctx.matched.pr) {
328
+ // Skip for HMR requests — the dev prerender endpoint reads from a stale
329
+ // RouterRegistry snapshot; rendering fresh ensures edits are visible.
330
+ const isHmr = !!ctx.request.headers.get("X-RSC-HMR");
331
+ if (!ctx.isAction && !isHmr && ctx.matched.pr) {
309
332
  await ensurePrerenderDeps();
310
333
  if (prerenderStoreInstance) {
311
334
  const paramHash = _hashParams!(ctx.matched.params);
312
335
  const isPassthroughPrerenderRoute = ctx.entries.some(
313
- (entry) =>
314
- entry.type === "route" &&
315
- entry.prerenderDef?.options?.passthrough === true,
336
+ (entry) => entry.type === "route" && entry.isPassthrough === true,
316
337
  );
317
338
 
318
339
  if (ctx.isIntercept) {
@@ -382,9 +403,7 @@ export function withCacheLookup<TEnv>(
382
403
  if (prerenderStoreInstance) {
383
404
  const paramHash = _hashParams!(ctx.matched.params);
384
405
  const isPassthroughPrerenderRoute = ctx.entries.some(
385
- (entry) =>
386
- entry.type === "route" &&
387
- entry.prerenderDef?.options?.passthrough === true,
406
+ (entry) => entry.type === "route" && entry.isPassthrough === true,
388
407
  );
389
408
 
390
409
  if (ctx.isIntercept) {
@@ -437,7 +456,7 @@ export function withCacheLookup<TEnv>(
437
456
  yield* source;
438
457
  if (ms) {
439
458
  ms.metrics.push({
440
- label: "pipeline:cache-lookup",
459
+ label: "pipeline:cache-miss",
441
460
  duration: performance.now() - pipelineStart,
442
461
  startTime: pipelineStart - ms.requestStart,
443
462
  });
@@ -457,7 +476,7 @@ export function withCacheLookup<TEnv>(
457
476
  yield* source;
458
477
  if (ms) {
459
478
  ms.metrics.push({
460
- label: "pipeline:cache-lookup",
479
+ label: "pipeline:cache-miss",
461
480
  duration: performance.now() - pipelineStart,
462
481
  startTime: pipelineStart - ms.requestStart,
463
482
  });
@@ -509,7 +528,41 @@ export function withCacheLookup<TEnv>(
509
528
 
510
529
  // Look up revalidation rules for this segment
511
530
  const entryInfo = entryRevalidateMap?.get(segment.id);
531
+
532
+ // Even without explicit revalidation rules, route segments and their
533
+ // children must re-render when params or search params change — the
534
+ // handler reads ctx.params/ctx.searchParams so different values produce
535
+ // different content. Matches evaluateRevalidation's default logic.
536
+ const searchChanged = ctx.prevUrl.search !== ctx.url.search;
537
+ const routeParamsChanged = !paramsEqual(
538
+ ctx.matched.params,
539
+ ctx.prevParams,
540
+ );
541
+ const shouldDefaultRevalidate =
542
+ (searchChanged || routeParamsChanged) &&
543
+ (segment.type === "route" ||
544
+ (segment.belongsToRoute &&
545
+ (segment.type === "layout" || segment.type === "parallel")));
546
+
512
547
  if (!entryInfo || entryInfo.revalidate.length === 0) {
548
+ if (shouldDefaultRevalidate) {
549
+ // Params or search params changed — must re-render even without custom rules
550
+ if (isTraceActive()) {
551
+ pushRevalidationTraceEntry({
552
+ segmentId: segment.id,
553
+ segmentType: segment.type,
554
+ belongsToRoute: segment.belongsToRoute ?? false,
555
+ source: "cache-hit",
556
+ defaultShouldRevalidate: true,
557
+ finalShouldRevalidate: true,
558
+ reason: routeParamsChanged
559
+ ? "cached-params-changed"
560
+ : "cached-search-changed",
561
+ });
562
+ }
563
+ yield segment;
564
+ continue;
565
+ }
513
566
  // No revalidation rules, use default behavior (skip if client has)
514
567
  if (isTraceActive()) {
515
568
  pushRevalidationTraceEntry({
@@ -570,9 +623,17 @@ export function withCacheLookup<TEnv>(
570
623
  yield segment;
571
624
  }
572
625
 
626
+ // Set streaming flag and resolve render barrier.
627
+ const barrierReqCtx = _getRequestContext();
628
+ if (barrierReqCtx) {
629
+ barrierReqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
630
+ barrierReqCtx._resolveRenderBarrier(cacheResult.segments);
631
+ }
632
+
573
633
  // Resolve loaders fresh (loaders are NOT cached by default)
574
634
  // This ensures fresh data even on cache hit
575
635
  const Store = ctx.Store;
636
+ const loaderStart = performance.now();
576
637
 
577
638
  if (ctx.isFullMatch) {
578
639
  // Full match (document request) - simple loader resolution without revalidation
@@ -605,7 +666,11 @@ export function withCacheLookup<TEnv>(
605
666
  ctx.url,
606
667
  ctx.routeKey,
607
668
  ctx.actionContext,
608
- cacheResult.shouldRevalidate || undefined,
669
+ // Loaders are never cached in the segment cache, so segment
670
+ // staleness (cacheResult.shouldRevalidate) must not propagate.
671
+ // But browser-sent staleness (ctx.stale) — indicating an action
672
+ // happened in this or another tab — must still reach loaders.
673
+ ctx.stale || undefined,
609
674
  ),
610
675
  );
611
676
 
@@ -624,9 +689,16 @@ export function withCacheLookup<TEnv>(
624
689
  }
625
690
  }
626
691
  if (ms) {
692
+ const loaderEnd = performance.now();
693
+ ms.metrics.push({
694
+ label: "pipeline:loader-resolve",
695
+ duration: loaderEnd - loaderStart,
696
+ startTime: loaderStart - ms.requestStart,
697
+ depth: 1,
698
+ });
627
699
  ms.metrics.push({
628
- label: "pipeline:cache-lookup",
629
- duration: performance.now() - pipelineStart,
700
+ label: "pipeline:cache-hit",
701
+ duration: loaderEnd - pipelineStart,
630
702
  startTime: pipelineStart - ms.requestStart,
631
703
  });
632
704
  }
@@ -104,7 +104,8 @@ import type { ResolvedSegment } from "../../types.js";
104
104
  import { getRequestContext } from "../../server/request-context.js";
105
105
  import type { MatchContext, MatchPipelineState } from "../match-context.js";
106
106
  import { getRouterContext } from "../router-context.js";
107
- import { debugLog, debugWarn } from "../logging.js";
107
+ import { debugLog, debugWarn, getOrCreateRequestId } from "../logging.js";
108
+ import { INTERNAL_RANGO_DEBUG } from "../../internal-debug.js";
108
109
  import type { GeneratorMiddleware } from "./cache-lookup.js";
109
110
 
110
111
  /**
@@ -120,7 +121,6 @@ export function withCacheStore<TEnv>(
120
121
  return async function* (
121
122
  source: AsyncGenerator<ResolvedSegment>,
122
123
  ): AsyncGenerator<ResolvedSegment> {
123
- const pipelineStart = performance.now();
124
124
  const ms = ctx.metricsStore;
125
125
 
126
126
  // Collect all segments while passing them through
@@ -130,6 +130,9 @@ export function withCacheStore<TEnv>(
130
130
  yield segment;
131
131
  }
132
132
 
133
+ // Measure own work only (after source iteration completes)
134
+ const ownStart = performance.now();
135
+
133
136
  // Skip caching if:
134
137
  // 1. Cache miss but cache scope is disabled
135
138
  // 2. This is an action (actions don't cache)
@@ -144,8 +147,8 @@ export function withCacheStore<TEnv>(
144
147
  if (ms) {
145
148
  ms.metrics.push({
146
149
  label: "pipeline:cache-store",
147
- duration: performance.now() - pipelineStart,
148
- startTime: pipelineStart - ms.requestStart,
150
+ duration: performance.now() - ownStart,
151
+ startTime: ownStart - ms.requestStart,
149
152
  });
150
153
  }
151
154
  return;
@@ -162,16 +165,23 @@ export function withCacheStore<TEnv>(
162
165
  // Combine main segments with intercept segments
163
166
  const allSegmentsToCache = [...allSegments, ...state.interceptSegments];
164
167
 
165
- // Check if any non-loader segments have null components
166
- // This happens when client already had those segments (partial navigation)
168
+ // Check if any non-loader segments have null components from revalidation
169
+ // skip (client already had them). Segments where the handler intentionally
170
+ // returned null are not revalidation skips — re-rendering them will still
171
+ // produce null, so proactive caching would be wasted work.
172
+ const clientIdSet = new Set(ctx.clientSegmentIds);
167
173
  const hasNullComponents = allSegmentsToCache.some(
168
- (s) => s.component === null && s.type !== "loader",
174
+ (s) =>
175
+ s.component === null && s.type !== "loader" && clientIdSet.has(s.id),
169
176
  );
170
177
 
171
178
  const requestCtx = getRequestContext();
172
179
  if (!requestCtx) return;
173
180
 
174
181
  const cacheScope = ctx.cacheScope;
182
+ const reqId = INTERNAL_RANGO_DEBUG
183
+ ? getOrCreateRequestId(ctx.request)
184
+ : undefined;
175
185
 
176
186
  // Register onResponse callback to skip caching for non-200 responses
177
187
  // Note: error/notFound status codes are set elsewhere (not caching-specific)
@@ -189,6 +199,11 @@ export function withCacheStore<TEnv>(
189
199
  // Proactive caching: render all segments fresh in background
190
200
  // This ensures cache has complete components for future requests
191
201
  requestCtx.waitUntil(async () => {
202
+ // Prevent background metrics from polluting foreground timeline.
203
+ const savedMetrics = ctx.Store.metrics;
204
+ ctx.Store.metrics = undefined;
205
+
206
+ const start = performance.now();
192
207
  debugLog("cacheStore", "proactive caching started", {
193
208
  pathname: ctx.pathname,
194
209
  });
@@ -218,7 +233,9 @@ export function withCacheStore<TEnv>(
218
233
  // Use normal loader access so handle data is captured
219
234
  setupLoaderAccess(proactiveHandlerContext, proactiveLoaderPromises);
220
235
 
221
- // Re-resolve ALL segments without revalidation
236
+ // Re-resolve ALL segments without revalidation.
237
+ // Skip DSL loaders — they are never cached (cacheRoute filters them)
238
+ // and are always resolved fresh on each request.
222
239
  const Store = ctx.Store;
223
240
  const freshSegments = await Store.run(() =>
224
241
  resolveAllSegments(
@@ -227,6 +244,7 @@ export function withCacheStore<TEnv>(
227
244
  ctx.matched.params,
228
245
  proactiveHandlerContext,
229
246
  proactiveLoaderPromises,
247
+ { skipLoaders: true },
230
248
  ),
231
249
  );
232
250
 
@@ -256,28 +274,53 @@ export function withCacheStore<TEnv>(
256
274
  completeSegments,
257
275
  ctx.isIntercept,
258
276
  );
277
+ if (INTERNAL_RANGO_DEBUG) {
278
+ const dur = performance.now() - start;
279
+ console.log(
280
+ `[RSC Background][req:${reqId}] Proactive cache ${ctx.pathname} (${dur.toFixed(2)}ms) segments=${completeSegments.length}`,
281
+ );
282
+ }
259
283
  debugLog("cacheStore", "proactive caching complete", {
260
284
  pathname: ctx.pathname,
261
285
  });
262
286
  } catch (error) {
287
+ if (INTERNAL_RANGO_DEBUG) {
288
+ const dur = performance.now() - start;
289
+ console.log(
290
+ `[RSC Background][req:${reqId}] Proactive cache ${ctx.pathname} FAILED (${dur.toFixed(2)}ms) error=${String(error)}`,
291
+ );
292
+ }
263
293
  debugWarn("cacheStore", "proactive caching failed", {
264
294
  pathname: ctx.pathname,
265
295
  error: String(error),
266
296
  });
267
297
  } finally {
268
298
  requestCtx._handleStore = originalHandleStore;
299
+ ctx.Store.metrics = savedMetrics;
269
300
  }
270
301
  });
271
302
  } else {
272
303
  // All segments have components - cache directly
273
304
  // Schedule caching in waitUntil since cacheRoute is now async (key resolution)
305
+ if (INTERNAL_RANGO_DEBUG) {
306
+ console.log(
307
+ `[RSC CacheStore][req:${reqId}] Direct cache path: scheduling cacheRoute for ${ctx.pathname} (${allSegmentsToCache.length} segments, hasNullComponents=${hasNullComponents})`,
308
+ );
309
+ }
274
310
  requestCtx.waitUntil(async () => {
311
+ const start = performance.now();
275
312
  await cacheScope.cacheRoute(
276
313
  ctx.pathname,
277
314
  ctx.matched.params,
278
315
  allSegmentsToCache,
279
316
  ctx.isIntercept,
280
317
  );
318
+ if (INTERNAL_RANGO_DEBUG) {
319
+ const dur = performance.now() - start;
320
+ console.log(
321
+ `[RSC Background][req:${reqId}] Cache store ${ctx.pathname} (${dur.toFixed(2)}ms) segments=${allSegmentsToCache.length}`,
322
+ );
323
+ }
281
324
  });
282
325
  }
283
326
 
@@ -287,8 +330,8 @@ export function withCacheStore<TEnv>(
287
330
  if (ms) {
288
331
  ms.metrics.push({
289
332
  label: "pipeline:cache-store",
290
- duration: performance.now() - pipelineStart,
291
- startTime: pipelineStart - ms.requestStart,
333
+ duration: performance.now() - ownStart,
334
+ startTime: ownStart - ms.requestStart,
292
335
  });
293
336
  }
294
337
  };
@@ -123,7 +123,6 @@ export function withInterceptResolution<TEnv>(
123
123
  return async function* (
124
124
  source: AsyncGenerator<ResolvedSegment>,
125
125
  ): AsyncGenerator<ResolvedSegment> {
126
- const pipelineStart = performance.now();
127
126
  const ms = ctx.metricsStore;
128
127
 
129
128
  // First, yield all segments from the source (main segment resolution or cache)
@@ -133,13 +132,16 @@ export function withInterceptResolution<TEnv>(
133
132
  yield segment;
134
133
  }
135
134
 
135
+ // Measure own work only (after source iteration completes)
136
+ const ownStart = performance.now();
137
+
136
138
  // Skip intercept resolution for full match (document requests don't have intercepts)
137
139
  if (ctx.isFullMatch) {
138
140
  if (ms) {
139
141
  ms.metrics.push({
140
142
  label: "pipeline:intercept",
141
- duration: performance.now() - pipelineStart,
142
- startTime: pipelineStart - ms.requestStart,
143
+ duration: performance.now() - ownStart,
144
+ startTime: ownStart - ms.requestStart,
143
145
  });
144
146
  }
145
147
  return;
@@ -163,8 +165,8 @@ export function withInterceptResolution<TEnv>(
163
165
  if (ms) {
164
166
  ms.metrics.push({
165
167
  label: "pipeline:intercept",
166
- duration: performance.now() - pipelineStart,
167
- startTime: pipelineStart - ms.requestStart,
168
+ duration: performance.now() - ownStart,
169
+ startTime: ownStart - ms.requestStart,
168
170
  });
169
171
  }
170
172
  return;
@@ -216,8 +218,8 @@ export function withInterceptResolution<TEnv>(
216
218
  if (ms) {
217
219
  ms.metrics.push({
218
220
  label: "pipeline:intercept",
219
- duration: performance.now() - pipelineStart,
220
- startTime: pipelineStart - ms.requestStart,
221
+ duration: performance.now() - ownStart,
222
+ startTime: ownStart - ms.requestStart,
221
223
  });
222
224
  }
223
225
  };
@@ -87,10 +87,49 @@
87
87
  * if (state.cacheHit) return; // Now we can check
88
88
  */
89
89
  import type { ResolvedSegment } from "../../types.js";
90
+ import type { EntryData } from "../../server/context.js";
91
+ import { _getRequestContext } from "../../server/request-context.js";
90
92
  import type { MatchContext, MatchPipelineState } from "../match-context.js";
91
93
  import { getRouterContext } from "../router-context.js";
92
94
  import type { GeneratorMiddleware } from "./cache-lookup.js";
93
95
 
96
+ /**
97
+ * Check whether any entry in the tree uses loading() (streaming).
98
+ * Matches the router's streaming semantics in fresh.ts: streaming is
99
+ * enabled when `loading` is defined AND not `false`. `loading: false`
100
+ * explicitly disables streaming; `undefined` means no loading at all.
101
+ */
102
+ export function treeHasStreaming(entries: EntryData[]): boolean {
103
+ for (const entry of entries) {
104
+ if (
105
+ "loading" in entry &&
106
+ entry.loading !== undefined &&
107
+ entry.loading !== false
108
+ )
109
+ return true;
110
+ if (entry.layout) {
111
+ if (treeHasStreaming(entry.layout)) return true;
112
+ }
113
+ if (entry.parallel) {
114
+ for (const key in entry.parallel) {
115
+ const parallelEntry = entry.parallel[key as `@${string}`];
116
+ if (parallelEntry) {
117
+ if (
118
+ "loading" in parallelEntry &&
119
+ parallelEntry.loading !== undefined &&
120
+ parallelEntry.loading !== false
121
+ )
122
+ return true;
123
+ if (parallelEntry.layout) {
124
+ if (treeHasStreaming(parallelEntry.layout)) return true;
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+ return false;
131
+ }
132
+
94
133
  /**
95
134
  * Creates segment resolution middleware
96
135
  *
@@ -104,7 +143,6 @@ export function withSegmentResolution<TEnv>(
104
143
  return async function* (
105
144
  source: AsyncGenerator<ResolvedSegment>,
106
145
  ): AsyncGenerator<ResolvedSegment> {
107
- const pipelineStart = performance.now();
108
146
  const ms = ctx.metricsStore;
109
147
 
110
148
  // IMPORTANT: Always iterate source first to give cache-lookup a chance
@@ -113,18 +151,27 @@ export function withSegmentResolution<TEnv>(
113
151
  yield segment;
114
152
  }
115
153
 
154
+ // Measure own work only (after source iteration completes)
155
+ const ownStart = performance.now();
156
+
116
157
  // If cache hit, segments were already yielded by cache lookup
158
+ // (render barrier is resolved on the cache-hit path)
117
159
  if (state.cacheHit) {
118
160
  if (ms) {
119
161
  ms.metrics.push({
120
162
  label: "pipeline:segment-resolve",
121
- duration: performance.now() - pipelineStart,
122
- startTime: pipelineStart - ms.requestStart,
163
+ duration: performance.now() - ownStart,
164
+ startTime: ownStart - ms.requestStart,
123
165
  });
124
166
  }
125
167
  return;
126
168
  }
127
169
 
170
+ const reqCtx = _getRequestContext();
171
+ if (reqCtx) {
172
+ reqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
173
+ }
174
+
128
175
  const { resolveAllSegmentsWithRevalidation, resolveAllSegments } =
129
176
  getRouterContext<TEnv>();
130
177
 
@@ -146,6 +193,10 @@ export function withSegmentResolution<TEnv>(
146
193
  state.segments = segments;
147
194
  state.matchedIds = segments.map((s: { id: string }) => s.id);
148
195
 
196
+ if (reqCtx) {
197
+ reqCtx._resolveRenderBarrier(segments);
198
+ }
199
+
149
200
  // Yield all resolved segments
150
201
  for (const segment of segments) {
151
202
  yield segment;
@@ -168,6 +219,7 @@ export function withSegmentResolution<TEnv>(
168
219
  ctx.interceptResult,
169
220
  ctx.localRouteName,
170
221
  ctx.pathname,
222
+ ctx.stale,
171
223
  ),
172
224
  );
173
225
 
@@ -175,6 +227,10 @@ export function withSegmentResolution<TEnv>(
175
227
  state.segments = result.segments;
176
228
  state.matchedIds = result.matchedIds;
177
229
 
230
+ if (reqCtx) {
231
+ reqCtx._resolveRenderBarrier(result.segments);
232
+ }
233
+
178
234
  // Yield all resolved segments
179
235
  for (const segment of result.segments) {
180
236
  yield segment;
@@ -184,8 +240,8 @@ export function withSegmentResolution<TEnv>(
184
240
  if (ms) {
185
241
  ms.metrics.push({
186
242
  label: "pipeline:segment-resolve",
187
- duration: performance.now() - pipelineStart,
188
- startTime: pipelineStart - ms.requestStart,
243
+ duration: performance.now() - ownStart,
244
+ startTime: ownStart - ms.requestStart,
189
245
  });
190
246
  }
191
247
  };
@@ -67,10 +67,11 @@
67
67
  * Keep if:
68
68
  * - component !== null (needs rendering)
69
69
  * - type === "loader" (carries data even with null component)
70
+ * - client doesn't have the segment (structurally required parent node)
70
71
  *
71
72
  * Skip if:
72
- * - component === null AND type !== "loader"
73
- * - (Client already has this segment's UI)
73
+ * - component === null AND type !== "loader" AND client has it cached
74
+ * - (Revalidation skip — client already has this segment's UI)
74
75
  *
75
76
  *
76
77
  * INTERCEPT HANDLING
@@ -109,6 +110,7 @@
109
110
  import type { MatchResult, ResolvedSegment } from "../types.js";
110
111
  import type { MatchContext, MatchPipelineState } from "./match-context.js";
111
112
  import { debugLog } from "./logging.js";
113
+ import { appendMetric } from "./metrics.js";
112
114
 
113
115
  /**
114
116
  * Collect all segments from an async generator
@@ -167,10 +169,15 @@ export function buildMatchResult<TEnv>(
167
169
  // Deduplicate allIds (defense-in-depth for partial match path)
168
170
  allIds = [...new Set(allIds)];
169
171
 
170
- // Filter out segments with null components (client already has them)
171
- // BUT always include loader segments - they carry data even with null component
172
+ // Filter out null-component segments only when the client already has
173
+ // them cached (revalidation skip). If the client doesn't have the segment,
174
+ // it must be included even with null component — it's structurally required
175
+ // as a parent node for child layouts/parallels to reconcile against.
176
+ // Loader segments are always included as they carry data.
177
+ const clientIdSet = new Set(ctx.clientSegmentIds);
172
178
  segmentsToRender = allSegments.filter(
173
- (s) => s.component !== null || s.type === "loader",
179
+ (s) =>
180
+ s.component !== null || s.type === "loader" || !clientIdSet.has(s.id),
174
181
  );
175
182
  }
176
183
 
@@ -210,10 +217,19 @@ export async function collectMatchResult<TEnv>(
210
217
  ): Promise<MatchResult> {
211
218
  const allSegments = await collectSegments(pipeline);
212
219
 
220
+ const buildStart = performance.now();
221
+
213
222
  // Update state with collected segments if not already set
214
223
  if (state.segments.length === 0) {
215
224
  state.segments = allSegments;
216
225
  }
217
226
 
218
- return buildMatchResult(allSegments, ctx, state);
227
+ const result = buildMatchResult(allSegments, ctx, state);
228
+ appendMetric(
229
+ ctx.metricsStore,
230
+ "collect-result",
231
+ buildStart,
232
+ performance.now() - buildStart,
233
+ );
234
+ return result;
219
235
  }
@@ -15,7 +15,12 @@ function formatMs(value: number): string {
15
15
  }
16
16
 
17
17
  function sortMetrics(metrics: PerformanceMetric[]): PerformanceMetric[] {
18
- return [...metrics].sort((a, b) => a.startTime - b.startTime);
18
+ return [...metrics].sort((a, b) => {
19
+ // handler:total always goes last (it wraps everything)
20
+ if (a.label === "handler:total") return 1;
21
+ if (b.label === "handler:total") return -1;
22
+ return a.startTime - b.startTime;
23
+ });
19
24
  }
20
25
 
21
26
  interface Span {
@@ -27,8 +27,12 @@ type GetVariableFn = {
27
27
  * Set variable function type
28
28
  */
29
29
  type SetVariableFn = {
30
- <T>(contextVar: ContextVar<T>, value: T): void;
31
- <K extends keyof DefaultVars>(key: K, value: DefaultVars[K]): void;
30
+ <T>(contextVar: ContextVar<T>, value: T, options?: { cache?: boolean }): void;
31
+ <K extends keyof DefaultVars>(
32
+ key: K,
33
+ value: DefaultVars[K],
34
+ options?: { cache?: boolean },
35
+ ): void;
32
36
  };
33
37
 
34
38
  /**
@@ -91,12 +95,6 @@ export interface MiddlewareContext<
91
95
  /** Set a context variable (shared with route handlers) */
92
96
  set: SetVariableFn;
93
97
 
94
- /**
95
- * Middleware-injected variables.
96
- * Same shared dictionary as `ctx.get()`/`ctx.set()`.
97
- */
98
- var: DefaultVars;
99
-
100
98
  /**
101
99
  * Set a response header - can be called before or after `next()`.
102
100
  *
@@ -21,6 +21,7 @@ import type {
21
21
  import { _getRequestContext } from "../server/request-context.js";
22
22
  import { isAutoGeneratedRouteName } from "../route-name.js";
23
23
  import { appendMetric, createMetricsStore } from "./metrics.js";
24
+ import { stripInternalParams } from "./handler-context.js";
24
25
 
25
26
  // Re-export types and cookie utilities for backward compatibility
26
27
  export type {
@@ -147,7 +148,7 @@ export function createMiddlewareContext<TEnv>(
147
148
  search?: Record<string, unknown>,
148
149
  ) => string,
149
150
  ): MiddlewareContext<TEnv> {
150
- const url = new URL(request.url);
151
+ const url = stripInternalParams(new URL(request.url));
151
152
 
152
153
  // Track the initial response to detect pre/post-next() phase.
153
154
  // Before next(): responseHolder.response === initialResponse (the stub).
@@ -203,12 +204,9 @@ export function createMiddlewareContext<TEnv>(
203
204
  get: ((keyOrVar: any) =>
204
205
  contextGet(variables, keyOrVar)) as MiddlewareContext<TEnv>["get"],
205
206
 
206
- set: ((keyOrVar: any, value: unknown) => {
207
- contextSet(variables, keyOrVar, value);
207
+ set: ((keyOrVar: any, value: unknown, options?: any) => {
208
+ contextSet(variables, keyOrVar, value, options);
208
209
  }) as MiddlewareContext<TEnv>["set"],
209
-
210
- var: variables as MiddlewareContext<TEnv>["var"],
211
-
212
210
  header(name: string, value: string): void {
213
211
  // Before next(): delegate to shared RequestContext stub
214
212
  if (isPreNext()) {