@rangojs/router 0.0.0-experimental.46 → 0.0.0-experimental.47

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.
@@ -1745,7 +1745,7 @@ import { resolve } from "node:path";
1745
1745
  // package.json
1746
1746
  var package_default = {
1747
1747
  name: "@rangojs/router",
1748
- version: "0.0.0-experimental.46",
1748
+ version: "0.0.0-experimental.47",
1749
1749
  description: "Django-inspired RSC router with composable URL patterns",
1750
1750
  keywords: [
1751
1751
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.46",
3
+ "version": "0.0.0-experimental.47",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -259,6 +259,17 @@ export function createPartialUpdater(
259
259
  existingSegments,
260
260
  );
261
261
 
262
+ // Fix: tx.commit() cached the source page's handleData because
263
+ // eventController hasn't been updated yet. Overwrite with the
264
+ // correct cached handleData to prevent cache corruption on
265
+ // subsequent navigations to this same URL.
266
+ if (mode.targetCacheHandleData) {
267
+ store.updateCacheHandleData(
268
+ store.getHistoryKey(),
269
+ mode.targetCacheHandleData,
270
+ );
271
+ }
272
+
262
273
  // Include cachedHandleData in metadata so NavigationProvider can restore
263
274
  // breadcrumbs and other handle data from cache.
264
275
  // Remove `handles` from metadata to prevent NavigationProvider from
@@ -214,11 +214,21 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
214
214
  bgStopCapture = c.stop;
215
215
  }
216
216
 
217
- // Stamp tainted args and RequestContext so request-scoped
218
- // reads (cookies, headers) and side effects (ctx.set, etc.)
219
- // throw inside background revalidation, same as the miss path.
220
- // Uses ref-counted stamp/unstamp so overlapping executions
221
- // sharing the same ctx don't clear each other's guards.
217
+ // Stamp tainted ARGS only not requestCtx. The args stamp guards
218
+ // direct ctx method calls (ctx.set, ctx.header, ctx.onResponse, etc.)
219
+ // which is sufficient for correctness.
220
+ //
221
+ // We intentionally skip stamping requestCtx here because:
222
+ // 1. runBackground starts the async task synchronously (before the
223
+ // first await), so stampCacheExec would pollute the shared
224
+ // requestCtx while the foreground pipeline is still running.
225
+ // This causes assertNotInsideCacheExec to fire when cache-store
226
+ // later calls requestCtx.onResponse().
227
+ // 2. requestCtx methods are closure-bound to the original ctx, so
228
+ // neither Object.create() nor a proxy can isolate the stamp.
229
+ // 3. The foreground miss path already stamps requestCtx and catches
230
+ // cookies()/headers() misuse on first execution. The background
231
+ // re-runs the same function with the same request.
222
232
  const bgTaintedArgs: unknown[] = [];
223
233
  for (const arg of args) {
224
234
  if (isTainted(arg)) {
@@ -226,9 +236,6 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
226
236
  bgTaintedArgs.push(arg);
227
237
  }
228
238
  }
229
- if (requestCtx) {
230
- stampCacheExec(requestCtx as object);
231
- }
232
239
 
233
240
  try {
234
241
  const freshResult = await fn.apply(this, args);
@@ -249,9 +256,6 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
249
256
  for (const arg of bgTaintedArgs) {
250
257
  unstampCacheExec(arg as object);
251
258
  }
252
- if (requestCtx) {
253
- unstampCacheExec(requestCtx as object);
254
- }
255
259
  // Restore original handle store
256
260
  if (originalHandleStore && requestCtx) {
257
261
  requestCtx._handleStore = originalHandleStore;
@@ -74,7 +74,7 @@ function getHeaderRequestId(request: Request): string | null {
74
74
  return trimmed.length > 0 ? trimmed : null;
75
75
  }
76
76
 
77
- function getOrCreateRequestId(request: Request): string {
77
+ export function getOrCreateRequestId(request: Request): string {
78
78
  const existing = requestIds.get(request);
79
79
  if (existing) return existing;
80
80
 
@@ -103,7 +103,8 @@ import type { ResolvedSegment } from "../../types.js";
103
103
  import type { MatchContext, MatchPipelineState } from "../match-context.js";
104
104
  import { getRouterContext } from "../router-context.js";
105
105
  import type { GeneratorMiddleware } from "./cache-lookup.js";
106
- import { debugLog, debugWarn } from "../logging.js";
106
+ import { debugLog, debugWarn, getOrCreateRequestId } from "../logging.js";
107
+ import { INTERNAL_RANGO_DEBUG } from "../../internal-debug.js";
107
108
 
108
109
  /**
109
110
  * Creates background revalidation middleware
@@ -143,8 +144,12 @@ export function withBackgroundRevalidation<TEnv>(
143
144
 
144
145
  const requestCtx = getRequestContext();
145
146
  const cacheScope = ctx.cacheScope;
147
+ const reqId = INTERNAL_RANGO_DEBUG
148
+ ? getOrCreateRequestId(ctx.request)
149
+ : undefined;
146
150
 
147
151
  requestCtx?.waitUntil(async () => {
152
+ const start = performance.now();
148
153
  debugLog("backgroundRevalidation", "revalidating stale route", {
149
154
  pathname: ctx.pathname,
150
155
  fullMatch: ctx.isFullMatch,
@@ -207,10 +212,22 @@ export function withBackgroundRevalidation<TEnv>(
207
212
  completeSegments,
208
213
  ctx.isIntercept,
209
214
  );
215
+ if (INTERNAL_RANGO_DEBUG) {
216
+ const dur = performance.now() - start;
217
+ console.log(
218
+ `[RSC Background][req:${reqId}] SWR revalidation ${ctx.pathname} (${dur.toFixed(2)}ms) segments=${completeSegments.length}`,
219
+ );
220
+ }
210
221
  debugLog("backgroundRevalidation", "revalidation complete", {
211
222
  pathname: ctx.pathname,
212
223
  });
213
224
  } catch (error) {
225
+ if (INTERNAL_RANGO_DEBUG) {
226
+ const dur = performance.now() - start;
227
+ console.log(
228
+ `[RSC Background][req:${reqId}] SWR revalidation ${ctx.pathname} FAILED (${dur.toFixed(2)}ms) error=${String(error)}`,
229
+ );
230
+ }
214
231
  debugWarn("backgroundRevalidation", "revalidation failed", {
215
232
  pathname: ctx.pathname,
216
233
  error: String(error),
@@ -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
@@ -210,6 +212,9 @@ async function* yieldFromStore<TEnv>(
210
212
  }
211
213
 
212
214
  // Resolve loaders fresh (loaders are never pre-rendered/cached)
215
+ const ms = ctx.metricsStore;
216
+ const loaderStart = performance.now();
217
+
213
218
  if (ctx.isFullMatch) {
214
219
  if (resolveLoadersOnly) {
215
220
  const loaderSegments = await ctx.Store.run(() =>
@@ -249,11 +254,17 @@ async function* yieldFromStore<TEnv>(
249
254
  }
250
255
  }
251
256
 
252
- const ms = ctx.metricsStore;
253
257
  if (ms) {
258
+ const loaderEnd = performance.now();
254
259
  ms.metrics.push({
255
- label: "pipeline:cache-lookup",
256
- duration: performance.now() - pipelineStart,
260
+ label: "pipeline:loader-resolve",
261
+ duration: loaderEnd - loaderStart,
262
+ startTime: loaderStart - ms.requestStart,
263
+ depth: 1,
264
+ });
265
+ ms.metrics.push({
266
+ label: "pipeline:cache-hit",
267
+ duration: loaderEnd - pipelineStart,
257
268
  startTime: pipelineStart - ms.requestStart,
258
269
  });
259
270
  }
@@ -437,7 +448,7 @@ export function withCacheLookup<TEnv>(
437
448
  yield* source;
438
449
  if (ms) {
439
450
  ms.metrics.push({
440
- label: "pipeline:cache-lookup",
451
+ label: "pipeline:cache-miss",
441
452
  duration: performance.now() - pipelineStart,
442
453
  startTime: pipelineStart - ms.requestStart,
443
454
  });
@@ -457,7 +468,7 @@ export function withCacheLookup<TEnv>(
457
468
  yield* source;
458
469
  if (ms) {
459
470
  ms.metrics.push({
460
- label: "pipeline:cache-lookup",
471
+ label: "pipeline:cache-miss",
461
472
  duration: performance.now() - pipelineStart,
462
473
  startTime: pipelineStart - ms.requestStart,
463
474
  });
@@ -509,7 +520,34 @@ export function withCacheLookup<TEnv>(
509
520
 
510
521
  // Look up revalidation rules for this segment
511
522
  const entryInfo = entryRevalidateMap?.get(segment.id);
523
+
524
+ // Even without explicit revalidation rules, route segments and their
525
+ // children must re-render when search params change — the handler reads
526
+ // ctx.searchParams so different ?page= values produce different content.
527
+ const searchChanged = ctx.prevUrl.search !== ctx.url.search;
528
+ const shouldDefaultRevalidate =
529
+ searchChanged &&
530
+ (segment.type === "route" ||
531
+ (segment.belongsToRoute &&
532
+ (segment.type === "layout" || segment.type === "parallel")));
533
+
512
534
  if (!entryInfo || entryInfo.revalidate.length === 0) {
535
+ if (shouldDefaultRevalidate) {
536
+ // Search params changed — must re-render even without custom rules
537
+ if (isTraceActive()) {
538
+ pushRevalidationTraceEntry({
539
+ segmentId: segment.id,
540
+ segmentType: segment.type,
541
+ belongsToRoute: segment.belongsToRoute ?? false,
542
+ source: "cache-hit",
543
+ defaultShouldRevalidate: true,
544
+ finalShouldRevalidate: true,
545
+ reason: "cached-search-changed",
546
+ });
547
+ }
548
+ yield segment;
549
+ continue;
550
+ }
513
551
  // No revalidation rules, use default behavior (skip if client has)
514
552
  if (isTraceActive()) {
515
553
  pushRevalidationTraceEntry({
@@ -573,6 +611,7 @@ export function withCacheLookup<TEnv>(
573
611
  // Resolve loaders fresh (loaders are NOT cached by default)
574
612
  // This ensures fresh data even on cache hit
575
613
  const Store = ctx.Store;
614
+ const loaderStart = performance.now();
576
615
 
577
616
  if (ctx.isFullMatch) {
578
617
  // Full match (document request) - simple loader resolution without revalidation
@@ -605,7 +644,11 @@ export function withCacheLookup<TEnv>(
605
644
  ctx.url,
606
645
  ctx.routeKey,
607
646
  ctx.actionContext,
608
- cacheResult.shouldRevalidate || undefined,
647
+ // Loaders are never cached in the segment cache, so segment
648
+ // staleness (cacheResult.shouldRevalidate) must not propagate.
649
+ // But browser-sent staleness (ctx.stale) — indicating an action
650
+ // happened in this or another tab — must still reach loaders.
651
+ ctx.stale || undefined,
609
652
  ),
610
653
  );
611
654
 
@@ -624,9 +667,16 @@ export function withCacheLookup<TEnv>(
624
667
  }
625
668
  }
626
669
  if (ms) {
670
+ const loaderEnd = performance.now();
671
+ ms.metrics.push({
672
+ label: "pipeline:loader-resolve",
673
+ duration: loaderEnd - loaderStart,
674
+ startTime: loaderStart - ms.requestStart,
675
+ depth: 1,
676
+ });
627
677
  ms.metrics.push({
628
- label: "pipeline:cache-lookup",
629
- duration: performance.now() - pipelineStart,
678
+ label: "pipeline:cache-hit",
679
+ duration: loaderEnd - pipelineStart,
630
680
  startTime: pipelineStart - ms.requestStart,
631
681
  });
632
682
  }
@@ -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;
@@ -172,6 +175,9 @@ export function withCacheStore<TEnv>(
172
175
  if (!requestCtx) return;
173
176
 
174
177
  const cacheScope = ctx.cacheScope;
178
+ const reqId = INTERNAL_RANGO_DEBUG
179
+ ? getOrCreateRequestId(ctx.request)
180
+ : undefined;
175
181
 
176
182
  // Register onResponse callback to skip caching for non-200 responses
177
183
  // Note: error/notFound status codes are set elsewhere (not caching-specific)
@@ -189,6 +195,7 @@ export function withCacheStore<TEnv>(
189
195
  // Proactive caching: render all segments fresh in background
190
196
  // This ensures cache has complete components for future requests
191
197
  requestCtx.waitUntil(async () => {
198
+ const start = performance.now();
192
199
  debugLog("cacheStore", "proactive caching started", {
193
200
  pathname: ctx.pathname,
194
201
  });
@@ -256,10 +263,22 @@ export function withCacheStore<TEnv>(
256
263
  completeSegments,
257
264
  ctx.isIntercept,
258
265
  );
266
+ if (INTERNAL_RANGO_DEBUG) {
267
+ const dur = performance.now() - start;
268
+ console.log(
269
+ `[RSC Background][req:${reqId}] Proactive cache ${ctx.pathname} (${dur.toFixed(2)}ms) segments=${completeSegments.length}`,
270
+ );
271
+ }
259
272
  debugLog("cacheStore", "proactive caching complete", {
260
273
  pathname: ctx.pathname,
261
274
  });
262
275
  } catch (error) {
276
+ if (INTERNAL_RANGO_DEBUG) {
277
+ const dur = performance.now() - start;
278
+ console.log(
279
+ `[RSC Background][req:${reqId}] Proactive cache ${ctx.pathname} FAILED (${dur.toFixed(2)}ms) error=${String(error)}`,
280
+ );
281
+ }
263
282
  debugWarn("cacheStore", "proactive caching failed", {
264
283
  pathname: ctx.pathname,
265
284
  error: String(error),
@@ -272,12 +291,19 @@ export function withCacheStore<TEnv>(
272
291
  // All segments have components - cache directly
273
292
  // Schedule caching in waitUntil since cacheRoute is now async (key resolution)
274
293
  requestCtx.waitUntil(async () => {
294
+ const start = performance.now();
275
295
  await cacheScope.cacheRoute(
276
296
  ctx.pathname,
277
297
  ctx.matched.params,
278
298
  allSegmentsToCache,
279
299
  ctx.isIntercept,
280
300
  );
301
+ if (INTERNAL_RANGO_DEBUG) {
302
+ const dur = performance.now() - start;
303
+ console.log(
304
+ `[RSC Background][req:${reqId}] Cache store ${ctx.pathname} (${dur.toFixed(2)}ms) segments=${allSegmentsToCache.length}`,
305
+ );
306
+ }
281
307
  });
282
308
  }
283
309
 
@@ -287,8 +313,8 @@ export function withCacheStore<TEnv>(
287
313
  if (ms) {
288
314
  ms.metrics.push({
289
315
  label: "pipeline:cache-store",
290
- duration: performance.now() - pipelineStart,
291
- startTime: pipelineStart - ms.requestStart,
316
+ duration: performance.now() - ownStart,
317
+ startTime: ownStart - ms.requestStart,
292
318
  });
293
319
  }
294
320
  };
@@ -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
  };
@@ -104,7 +104,6 @@ export function withSegmentResolution<TEnv>(
104
104
  return async function* (
105
105
  source: AsyncGenerator<ResolvedSegment>,
106
106
  ): AsyncGenerator<ResolvedSegment> {
107
- const pipelineStart = performance.now();
108
107
  const ms = ctx.metricsStore;
109
108
 
110
109
  // IMPORTANT: Always iterate source first to give cache-lookup a chance
@@ -113,13 +112,16 @@ export function withSegmentResolution<TEnv>(
113
112
  yield segment;
114
113
  }
115
114
 
115
+ // Measure own work only (after source iteration completes)
116
+ const ownStart = performance.now();
117
+
116
118
  // If cache hit, segments were already yielded by cache lookup
117
119
  if (state.cacheHit) {
118
120
  if (ms) {
119
121
  ms.metrics.push({
120
122
  label: "pipeline:segment-resolve",
121
- duration: performance.now() - pipelineStart,
122
- startTime: pipelineStart - ms.requestStart,
123
+ duration: performance.now() - ownStart,
124
+ startTime: ownStart - ms.requestStart,
123
125
  });
124
126
  }
125
127
  return;
@@ -185,8 +187,8 @@ export function withSegmentResolution<TEnv>(
185
187
  if (ms) {
186
188
  ms.metrics.push({
187
189
  label: "pipeline:segment-resolve",
188
- duration: performance.now() - pipelineStart,
189
- startTime: pipelineStart - ms.requestStart,
190
+ duration: performance.now() - ownStart,
191
+ startTime: ownStart - ms.requestStart,
190
192
  });
191
193
  }
192
194
  };
@@ -109,6 +109,7 @@
109
109
  import type { MatchResult, ResolvedSegment } from "../types.js";
110
110
  import type { MatchContext, MatchPipelineState } from "./match-context.js";
111
111
  import { debugLog } from "./logging.js";
112
+ import { appendMetric } from "./metrics.js";
112
113
 
113
114
  /**
114
115
  * Collect all segments from an async generator
@@ -210,10 +211,19 @@ export async function collectMatchResult<TEnv>(
210
211
  ): Promise<MatchResult> {
211
212
  const allSegments = await collectSegments(pipeline);
212
213
 
214
+ const buildStart = performance.now();
215
+
213
216
  // Update state with collected segments if not already set
214
217
  if (state.segments.length === 0) {
215
218
  state.segments = allSegments;
216
219
  }
217
220
 
218
- return buildMatchResult(allSegments, ctx, state);
221
+ const result = buildMatchResult(allSegments, ctx, state);
222
+ appendMetric(
223
+ ctx.metricsStore,
224
+ "collect-result",
225
+ buildStart,
226
+ performance.now() - buildStart,
227
+ );
228
+ return result;
219
229
  }
@@ -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 {
@@ -19,6 +19,8 @@ import type {
19
19
  } from "../../types";
20
20
  import type { SegmentResolutionDeps } from "../types.js";
21
21
  import { resolveLoaderData } from "./loader-cache.js";
22
+ import { _getRequestContext } from "../../server/request-context.js";
23
+ import { appendMetric } from "../metrics.js";
22
24
  import {
23
25
  handleHandlerResult,
24
26
  tryStaticHandler,
@@ -94,8 +96,12 @@ export async function resolveLoaders<TEnv>(
94
96
  const shortCode = shortCodeOverride ?? entry.shortCode;
95
97
  const hasLoading = "loading" in entry && entry.loading !== undefined;
96
98
  const loadingDisabled = hasLoading && entry.loading === false;
99
+ const ms = _getRequestContext()?._metricsStore;
97
100
 
98
101
  if (!loadingDisabled) {
102
+ // Streaming loaders: promises kick off now, settle during RSC serialization.
103
+ // No per-loader timing here — settlement happens asynchronously during
104
+ // RSC/SSR stream consumption, after the perf timeline is logged.
99
105
  return loaderEntries.map((loaderEntry, i) => {
100
106
  const { loader } = loaderEntry;
101
107
  const segmentId = `${shortCode}D${i}.${loader.$$id}`;
@@ -120,14 +126,31 @@ export async function resolveLoaders<TEnv>(
120
126
 
121
127
  // Loading disabled: still start all loaders in parallel, but only emit
122
128
  // settled promises so handlers don't stream loading placeholders.
123
- const pendingLoaderData = loaderEntries.map((loaderEntry) =>
124
- resolveLoaderData(loaderEntry, ctx, ctx.pathname),
125
- );
126
- await Promise.all(pendingLoaderData);
129
+ // We can measure actual execution time here since we await all loaders.
130
+ const pendingLoaderData = loaderEntries.map((loaderEntry) => {
131
+ const start = performance.now();
132
+ const promise = resolveLoaderData(loaderEntry, ctx, ctx.pathname);
133
+ return { promise, start, loaderId: loaderEntry.loader.$$id };
134
+ });
135
+ await Promise.all(pendingLoaderData.map((p) => p.promise));
127
136
 
128
137
  return loaderEntries.map((loaderEntry, i) => {
129
138
  const { loader } = loaderEntry;
130
139
  const segmentId = `${shortCode}D${i}.${loader.$$id}`;
140
+ const pending = pendingLoaderData[i]!;
141
+ if (ms && !ms.metrics.some((m) => m.label === `loader:${loader.$$id}`)) {
142
+ // All loaders ran in parallel via Promise.all — each span covers
143
+ // from its own kickoff to the batch settlement, giving a ceiling
144
+ // on that loader's contribution to the overall wait.
145
+ const batchEnd = performance.now();
146
+ appendMetric(
147
+ ms,
148
+ `loader:${loader.$$id}`,
149
+ pending.start,
150
+ batchEnd - pending.start,
151
+ 2,
152
+ );
153
+ }
131
154
  return {
132
155
  id: segmentId,
133
156
  namespace: entry.id,
@@ -137,7 +160,7 @@ export async function resolveLoaders<TEnv>(
137
160
  params: ctx.params,
138
161
  loaderId: loader.$$id,
139
162
  loaderData: deps.wrapLoaderPromise(
140
- pendingLoaderData[i]!,
163
+ pending.promise,
141
164
  entry,
142
165
  segmentId,
143
166
  ctx.pathname,
@@ -601,20 +624,37 @@ export async function resolveLoadersOnly<TEnv>(
601
624
  deps: SegmentResolutionDeps<TEnv>,
602
625
  ): Promise<ResolvedSegment[]> {
603
626
  const loaderSegments: ResolvedSegment[] = [];
627
+ const seenIds = new Set<string>();
604
628
 
605
629
  async function collectEntryLoaders(
606
630
  entry: EntryData,
607
631
  belongsToRoute: boolean,
608
632
  shortCodeOverride?: string,
609
633
  ): Promise<void> {
610
- const segments = await resolveLoaders(
611
- entry,
612
- context,
613
- belongsToRoute,
614
- deps,
615
- shortCodeOverride,
616
- );
617
- loaderSegments.push(...segments);
634
+ // Skip if all loaders from this entry have already been resolved
635
+ // via a parent (e.g., cache boundary wrapping a layout with shared loaders).
636
+ const entryLoaders = entry.loader ?? [];
637
+ const sc = shortCodeOverride ?? entry.shortCode;
638
+ const allAlreadySeen =
639
+ entryLoaders.length > 0 &&
640
+ entryLoaders.every((le, i) =>
641
+ seenIds.has(`${sc}D${i}.${le.loader.$$id}`),
642
+ );
643
+ if (!allAlreadySeen) {
644
+ const segments = await resolveLoaders(
645
+ entry,
646
+ context,
647
+ belongsToRoute,
648
+ deps,
649
+ shortCodeOverride,
650
+ );
651
+ for (const seg of segments) {
652
+ if (!seenIds.has(seg.id)) {
653
+ seenIds.add(seg.id);
654
+ loaderSegments.push(seg);
655
+ }
656
+ }
657
+ }
618
658
 
619
659
  const seenParallelEntryIds = new Set<string>();
620
660
  for (const parallelEntry of getParallelEntries(entry.parallel)) {
@@ -147,6 +147,7 @@ export function resolveLoaderData<TEnv>(
147
147
  }
148
148
 
149
149
  const loaderId = loaderEntry.loader.$$id;
150
+
150
151
  const ttl = resolveTtl(options.ttl, store.defaults, DEFAULT_ROUTE_TTL);
151
152
  const swrWindow = resolveSwrWindow(options.swr, store.defaults);
152
153
  const swr = swrWindow || undefined;
@@ -262,29 +262,46 @@ export async function resolveLoadersOnlyWithRevalidation<TEnv>(
262
262
  ): Promise<{ segments: ResolvedSegment[]; matchedIds: string[] }> {
263
263
  const allLoaderSegments: ResolvedSegment[] = [];
264
264
  const allMatchedIds: string[] = [];
265
+ const seenIds = new Set<string>();
265
266
 
266
267
  async function collectEntryLoaders(
267
268
  entry: EntryData,
268
269
  belongsToRoute: boolean,
269
270
  shortCodeOverride?: string,
270
271
  ): Promise<void> {
271
- const { segments, matchedIds } = await resolveLoadersWithRevalidation(
272
- entry,
273
- context,
274
- belongsToRoute,
275
- clientSegmentIds,
276
- prevParams,
277
- request,
278
- prevUrl,
279
- nextUrl,
280
- routeKey,
281
- deps,
282
- actionContext,
283
- shortCodeOverride,
284
- stale,
285
- );
286
- allLoaderSegments.push(...segments);
287
- allMatchedIds.push(...matchedIds);
272
+ // Skip if all loaders from this entry have already been resolved
273
+ // via a parent (e.g., cache boundary wrapping a layout with shared loaders).
274
+ const loaderEntries = entry.loader ?? [];
275
+ const sc = shortCodeOverride ?? entry.shortCode;
276
+ const allAlreadySeen =
277
+ loaderEntries.length > 0 &&
278
+ loaderEntries.every((le, i) =>
279
+ seenIds.has(`${sc}D${i}.${le.loader.$$id}`),
280
+ );
281
+ if (!allAlreadySeen) {
282
+ const { segments, matchedIds } = await resolveLoadersWithRevalidation(
283
+ entry,
284
+ context,
285
+ belongsToRoute,
286
+ clientSegmentIds,
287
+ prevParams,
288
+ request,
289
+ prevUrl,
290
+ nextUrl,
291
+ routeKey,
292
+ deps,
293
+ actionContext,
294
+ shortCodeOverride,
295
+ stale,
296
+ );
297
+ for (const seg of segments) {
298
+ if (!seenIds.has(seg.id)) {
299
+ seenIds.add(seg.id);
300
+ allLoaderSegments.push(seg);
301
+ }
302
+ }
303
+ allMatchedIds.push(...matchedIds);
304
+ }
288
305
 
289
306
  const seenParallelEntryIds = new Set<string>();
290
307
  for (const parallelEntry of getParallelEntries(entry.parallel)) {
@@ -289,8 +289,12 @@ export type HandlerContext<
289
289
  /**
290
290
  * Access loader data or push handle data.
291
291
  *
292
+ * Available in route handlers, layout handlers, middleware, server actions,
293
+ * and server components rendered within the request context.
294
+ *
292
295
  * For loaders: Returns a promise that resolves to the loader data.
293
- * Loaders are executed in parallel and memoized per request.
296
+ * Loaders are executed in parallel and memoized per request — calling
297
+ * `ctx.use(SameLoader)` multiple times returns the same promise.
294
298
  *
295
299
  * For handles: Returns a push function to add data for this segment.
296
300
  * Handle data accumulates across all matched route segments.
@@ -519,30 +523,112 @@ export type RevalidateParams<TParams = GenericParams, TEnv = any> = Parameters<
519
523
  * })
520
524
  * ```
521
525
  */
526
+ /**
527
+ * Revalidation function called during client-side navigation to decide whether
528
+ * a segment (layout, route, parallel slot, or loader) should be re-rendered.
529
+ *
530
+ * Return `true` to re-render, `false` to skip (keep client's current version),
531
+ * or `{ defaultShouldRevalidate: boolean }` to override the default for
532
+ * downstream segments.
533
+ *
534
+ * @example
535
+ * ```ts
536
+ * // Re-render only when a cart action happened or browser signals staleness
537
+ * revalidate(({ actionId, stale }) =>
538
+ * actionId?.includes("cart") || stale || false
539
+ * )
540
+ *
541
+ * // Always re-render when params change (default behavior made explicit)
542
+ * revalidate(({ defaultShouldRevalidate }) => defaultShouldRevalidate)
543
+ * ```
544
+ */
522
545
  export type ShouldRevalidateFn<TParams = GenericParams, TEnv = any> = (args: {
546
+ /** Route params from the page being navigated away from. */
523
547
  currentParams: TParams;
548
+ /** Full URL of the page being navigated away from. */
524
549
  currentUrl: URL;
550
+ /** Route params for the navigation target. */
525
551
  nextParams: TParams;
552
+ /** Full URL of the navigation target. */
526
553
  nextUrl: URL;
554
+ /**
555
+ * The router's default revalidation decision for this segment.
556
+ * `true` when params changed or the segment is new to the client.
557
+ * Return this when you want default behavior plus your own conditions.
558
+ */
527
559
  defaultShouldRevalidate: boolean;
560
+ /** Full handler context — access to `ctx.use()`, `ctx.env`, `ctx.params`, etc. */
528
561
  context: HandlerContext<TParams, TEnv>;
529
- // Segment metadata (which segment is being evaluated):
562
+
563
+ // ── Segment metadata (which segment is being evaluated) ──────────────
564
+
565
+ /** The type of segment being revalidated. */
530
566
  segmentType: "layout" | "route" | "parallel";
531
- layoutName?: string; // Layout name (e.g., "root", "shop", "auth") - only for layouts
532
- slotName?: string; // Slot name (e.g., "@sidebar", "@modal") - only for parallels
533
- // Action context (populated when revalidation triggered by server action):
534
- actionId?: string; // Action identifier (e.g., "src/actions.ts#addToCart")
535
- actionUrl?: URL; // URL where action was executed
536
- actionResult?: any; // Return value from action execution
537
- formData?: FormData; // FormData from action request
538
- method?: string; // Request method: 'GET' for navigation, 'POST' for actions
539
- routeName?: DefaultRouteName; // Route name of the navigation target (alias for toRouteName)
540
- // Named-route identity for both ends of a navigation transition.
541
- // Undefined for unnamed internal routes (those without a `name` option).
542
- fromRouteName?: DefaultRouteName; // Route name being navigated away from
543
- toRouteName?: DefaultRouteName; // Route name being navigated to
544
- // Stale cache revalidation (SWR pattern):
545
- stale?: boolean; // True if this is a stale cache revalidation request
567
+ /** Layout name (e.g., `"root"`, `"shop"`, `"auth"`). Only set for layout segments. */
568
+ layoutName?: string;
569
+ /** Slot name (e.g., `"@sidebar"`, `"@modal"`). Only set for parallel segments. */
570
+ slotName?: string;
571
+
572
+ // ── Action context (populated when revalidation is triggered by a server action) ──
573
+
574
+ /**
575
+ * Identifier of the server action that triggered revalidation.
576
+ * `undefined` during normal navigation (no action involved).
577
+ *
578
+ * Format: `"src/<path>#<exportName>"` the file path is the source path
579
+ * relative to the project root, followed by `#` and the exported function name.
580
+ *
581
+ * This is stable and can be used for path-based matching to revalidate
582
+ * when any action in a module or directory fires:
583
+ *
584
+ * @example
585
+ * ```ts
586
+ * // Match a specific action
587
+ * revalidate(({ actionId }) => actionId === "src/actions/cart.ts#addToCart")
588
+ *
589
+ * // Match any action in the cart module
590
+ * revalidate(({ actionId }) => actionId?.includes("cart") ?? false)
591
+ *
592
+ * // Match any action under src/apps/store/actions/
593
+ * revalidate(({ actionId }) => actionId?.startsWith("src/apps/store/actions/") ?? false)
594
+ * ```
595
+ */
596
+ actionId?: string;
597
+ /** URL where the action was executed (the page the user was on when they triggered the action). */
598
+ actionUrl?: URL;
599
+ /** Return value from the action execution. Can be used to conditionally revalidate based on the action's outcome. */
600
+ actionResult?: any;
601
+ /** FormData from the action request body. Only set for form-based actions (not inline `"use server"` actions). */
602
+ formData?: FormData;
603
+ /** HTTP method: `"GET"` for navigation, `"POST"` for server actions. */
604
+ method?: string;
605
+
606
+ // ── Route identity ───────────────────────────────────────────────────
607
+
608
+ /** Route name of the navigation target. Alias for `toRouteName`. */
609
+ routeName?: DefaultRouteName;
610
+ /**
611
+ * Route name being navigated away from.
612
+ * `undefined` for unnamed internal routes (those without a `name` option).
613
+ */
614
+ fromRouteName?: DefaultRouteName;
615
+ /**
616
+ * Route name being navigated to.
617
+ * `undefined` for unnamed internal routes (those without a `name` option).
618
+ */
619
+ toRouteName?: DefaultRouteName;
620
+
621
+ // ── Staleness signal ─────────────────────────────────────────────────
622
+
623
+ /**
624
+ * `true` when the browser signals that data may be stale — typically because
625
+ * a server action was executed in this or another tab (`_rsc_stale` header).
626
+ *
627
+ * This is NOT segment cache staleness (loaders are never segment-cached).
628
+ * Use this to decide whether loader data should be re-fetched after an
629
+ * action that may have mutated backend state.
630
+ */
631
+ stale?: boolean;
546
632
  }) => boolean | { defaultShouldRevalidate: boolean };
547
633
 
548
634
  // MiddlewareFn is imported from "../router/middleware.js" and re-exported