@rangojs/router 0.0.0-experimental.cb54cbba → 0.0.0-experimental.debug-cache-2383ca26

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 (65) hide show
  1. package/AGENTS.md +4 -0
  2. package/dist/bin/rango.js +8 -3
  3. package/dist/vite/index.js +139 -200
  4. package/package.json +15 -14
  5. package/skills/caching/SKILL.md +37 -4
  6. package/skills/parallel/SKILL.md +126 -0
  7. package/src/browser/event-controller.ts +5 -0
  8. package/src/browser/navigation-bridge.ts +1 -3
  9. package/src/browser/navigation-client.ts +60 -27
  10. package/src/browser/navigation-transaction.ts +11 -9
  11. package/src/browser/partial-update.ts +50 -9
  12. package/src/browser/prefetch/cache.ts +57 -5
  13. package/src/browser/prefetch/fetch.ts +30 -21
  14. package/src/browser/prefetch/queue.ts +53 -13
  15. package/src/browser/react/Link.tsx +9 -1
  16. package/src/browser/react/NavigationProvider.tsx +27 -0
  17. package/src/browser/rsc-router.tsx +109 -57
  18. package/src/browser/scroll-restoration.ts +31 -34
  19. package/src/browser/segment-reconciler.ts +6 -1
  20. package/src/browser/types.ts +9 -0
  21. package/src/build/route-types/router-processing.ts +12 -2
  22. package/src/cache/cache-runtime.ts +15 -11
  23. package/src/cache/cache-scope.ts +43 -3
  24. package/src/cache/cf/cf-cache-store.ts +453 -11
  25. package/src/cache/cf/index.ts +5 -1
  26. package/src/cache/document-cache.ts +17 -7
  27. package/src/cache/index.ts +1 -0
  28. package/src/debug.ts +2 -2
  29. package/src/route-definition/dsl-helpers.ts +32 -7
  30. package/src/route-definition/redirect.ts +2 -2
  31. package/src/route-map-builder.ts +7 -1
  32. package/src/router/find-match.ts +4 -2
  33. package/src/router/intercept-resolution.ts +2 -0
  34. package/src/router/lazy-includes.ts +4 -1
  35. package/src/router/logging.ts +5 -2
  36. package/src/router/manifest.ts +9 -3
  37. package/src/router/match-middleware/background-revalidation.ts +30 -2
  38. package/src/router/match-middleware/cache-lookup.ts +66 -9
  39. package/src/router/match-middleware/cache-store.ts +53 -10
  40. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  41. package/src/router/match-middleware/segment-resolution.ts +8 -5
  42. package/src/router/match-result.ts +22 -6
  43. package/src/router/metrics.ts +6 -1
  44. package/src/router/middleware.ts +2 -1
  45. package/src/router/router-context.ts +6 -1
  46. package/src/router/segment-resolution/fresh.ts +122 -15
  47. package/src/router/segment-resolution/loader-cache.ts +1 -0
  48. package/src/router/segment-resolution/revalidation.ts +347 -290
  49. package/src/router/segment-wrappers.ts +2 -0
  50. package/src/router.ts +5 -1
  51. package/src/segment-system.tsx +140 -4
  52. package/src/server/context.ts +90 -13
  53. package/src/server/request-context.ts +10 -4
  54. package/src/ssr/index.tsx +1 -0
  55. package/src/types/handler-context.ts +103 -17
  56. package/src/types/route-entry.ts +7 -0
  57. package/src/types/segments.ts +2 -0
  58. package/src/urls/path-helper.ts +1 -1
  59. package/src/vite/discovery/state.ts +0 -2
  60. package/src/vite/plugin-types.ts +0 -83
  61. package/src/vite/plugins/expose-action-id.ts +1 -3
  62. package/src/vite/plugins/version-plugin.ts +13 -1
  63. package/src/vite/rango.ts +144 -209
  64. package/src/vite/router-discovery.ts +0 -8
  65. package/src/vite/utils/banner.ts +3 -3
@@ -29,6 +29,7 @@ export { MemorySegmentCacheStore } from "./memory-segment-store.js";
29
29
  export {
30
30
  CFCacheStore,
31
31
  type CFCacheStoreOptions,
32
+ type KVNamespace,
32
33
  CACHE_STALE_AT_HEADER,
33
34
  CACHE_STATUS_HEADER,
34
35
  } from "./cf/index.js";
package/src/debug.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Debug utilities for manifest inspection and comparison
3
3
  */
4
4
 
5
- import type { EntryData } from "./server/context";
5
+ import { getParallelSlotCount, type EntryData } from "./server/context";
6
6
 
7
7
  /**
8
8
  * Serialized entry for debug output
@@ -64,7 +64,7 @@ export function serializeManifest(
64
64
  hasLoader: entry.loader?.length > 0,
65
65
  hasMiddleware: entry.middleware?.length > 0,
66
66
  hasErrorBoundary: entry.errorBoundary?.length > 0,
67
- parallelCount: entry.parallel?.length ?? 0,
67
+ parallelCount: getParallelSlotCount(entry.parallel),
68
68
  interceptCount: entry.intercept?.length ?? 0,
69
69
  };
70
70
 
@@ -282,7 +282,7 @@ const cache: RouteHelpers<any, any>["cache"] = (
282
282
  errorBoundary: [],
283
283
  notFoundBoundary: [],
284
284
  layout: [],
285
- parallel: [],
285
+ parallel: {},
286
286
  intercept: [],
287
287
  loader: [],
288
288
  ...(cacheUrlPrefix ? { mountPath: cacheUrlPrefix } : {}),
@@ -320,7 +320,7 @@ const cache: RouteHelpers<any, any>["cache"] = (
320
320
  errorBoundary: [],
321
321
  notFoundBoundary: [],
322
322
  layout: [],
323
- parallel: [],
323
+ parallel: {},
324
324
  intercept: [],
325
325
  loader: [],
326
326
  ...(cacheUrlPrefix2 ? { mountPath: cacheUrlPrefix2 } : {}),
@@ -393,6 +393,8 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
393
393
  "parallel() cannot be nested inside another parallel()",
394
394
  );
395
395
 
396
+ const slotNames = Object.keys(slots as Record<string, any>) as `@${string}`[];
397
+
396
398
  const namespace = `${ctx.namespace}.$${store.getNextIndex("parallel")}`;
397
399
 
398
400
  // Unwrap any static handler definitions in parallel slots
@@ -431,7 +433,7 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
431
433
  errorBoundary: [],
432
434
  notFoundBoundary: [],
433
435
  layout: [],
434
- parallel: [],
436
+ parallel: {},
435
437
  intercept: [],
436
438
  loader: [],
437
439
  ...(parallelUrlPrefix ? { mountPath: parallelUrlPrefix } : {}),
@@ -454,7 +456,30 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
454
456
  );
455
457
  }
456
458
 
457
- ctx.parent.parallel.push(entry);
459
+ for (const slotName of slotNames) {
460
+ const slotEntry = {
461
+ ...entry,
462
+ handler: { [slotName]: unwrappedSlots[slotName]! },
463
+ middleware: [...entry.middleware],
464
+ revalidate: [...entry.revalidate],
465
+ errorBoundary: [...entry.errorBoundary],
466
+ notFoundBoundary: [...entry.notFoundBoundary],
467
+ layout: [...entry.layout],
468
+ parallel: { ...entry.parallel },
469
+ intercept: [...entry.intercept],
470
+ loader: [...entry.loader],
471
+ ...(entry.staticHandlerIds?.[slotName]
472
+ ? {
473
+ isStaticPrerender: true as const,
474
+ staticHandlerIds: { [slotName]: entry.staticHandlerIds[slotName]! },
475
+ }
476
+ : {
477
+ isStaticPrerender: undefined,
478
+ staticHandlerIds: undefined,
479
+ }),
480
+ } satisfies EntryData;
481
+ ctx.parent.parallel[slotName] = slotEntry;
482
+ }
458
483
  return { name: namespace, type: "parallel" } as ParallelItem;
459
484
  };
460
485
 
@@ -687,7 +712,7 @@ const transitionFn = (
687
712
  errorBoundary: [],
688
713
  notFoundBoundary: [],
689
714
  layout: [],
690
- parallel: [],
715
+ parallel: {},
691
716
  intercept: [],
692
717
  loader: [],
693
718
  } as EntryData;
@@ -734,7 +759,7 @@ const routeFn: RouteHelpers<any, any>["route"] = (name, handler, use) => {
734
759
  errorBoundary: [],
735
760
  notFoundBoundary: [],
736
761
  layout: [],
737
- parallel: [],
762
+ parallel: {},
738
763
  intercept: [],
739
764
  loader: [],
740
765
  } satisfies EntryData;
@@ -791,7 +816,7 @@ const layout: RouteHelpers<any, any>["layout"] = (handler, use) => {
791
816
  revalidate: [],
792
817
  errorBoundary: [],
793
818
  notFoundBoundary: [],
794
- parallel: [],
819
+ parallel: {},
795
820
  intercept: [],
796
821
  layout: [],
797
822
  loader: [],
@@ -71,9 +71,9 @@ export function redirect(
71
71
  // actions both deliver state through Flight payloads, so suppress for those.
72
72
  if (
73
73
  reqCtx &&
74
- !reqCtx.url.searchParams.has("_rsc_partial") &&
74
+ !reqCtx.originalUrl.searchParams.has("_rsc_partial") &&
75
75
  !reqCtx.request.headers.has("rsc-action") &&
76
- !reqCtx.url.searchParams.has("_rsc_action")
76
+ !reqCtx.originalUrl.searchParams.has("_rsc_action")
77
77
  ) {
78
78
  console.warn(
79
79
  `[Router] redirect() with state during a full-page (SSR) request to "${url}". ` +
@@ -199,7 +199,13 @@ export function registerRouterManifestLoader(
199
199
  }
200
200
 
201
201
  export async function ensureRouterManifest(routerId: string): Promise<void> {
202
- if (perRouterManifestMap.has(routerId)) return;
202
+ // Check both manifest AND trie. The virtual module's setRouterManifest()
203
+ // pre-sets the manifest at startup, but the per-router trie is only
204
+ // available from the lazy loader. Without this, the lazy loader never
205
+ // runs and findMatch falls back to the global merged trie — which
206
+ // contains routes from ALL routers and breaks multi-router setups.
207
+ if (perRouterManifestMap.has(routerId) && perRouterTrieMap.has(routerId))
208
+ return;
203
209
  const loader = routerManifestLoaders.get(routerId);
204
210
  if (loader) {
205
211
  const mod = await loader();
@@ -52,8 +52,10 @@ export function createFindMatch<TEnv = any>(
52
52
  : undefined;
53
53
 
54
54
  // Phase 1: Try trie match (O(path_length))
55
- // Prefer per-router trie (isolated) over global trie (merged).
56
- const routeTrie = getRouterTrie(deps.routerId) ?? getRouteTrie();
55
+ // Only use the per-router trie. The global trie merges routes from ALL
56
+ // routers and must not be used — in multi-router setups (host routing)
57
+ // overlapping paths like "/" would match the wrong app's route.
58
+ const routeTrie = getRouterTrie(deps.routerId);
57
59
  if (routeTrie) {
58
60
  const trieStart = performance.now();
59
61
  const trieResult = tryTrieMatch(routeTrie, pathname);
@@ -188,6 +188,7 @@ export async function resolveInterceptEntry<TEnv>(
188
188
  context,
189
189
  actionContext,
190
190
  stale,
191
+ traceSource: "intercept-loader",
191
192
  });
192
193
 
193
194
  if (!shouldRevalidate) {
@@ -355,6 +356,7 @@ export async function resolveInterceptLoadersOnly<TEnv>(
355
356
  context,
356
357
  actionContext,
357
358
  stale,
359
+ traceSource: "intercept-loader",
358
360
  });
359
361
 
360
362
  if (!shouldRevalidate) {
@@ -4,6 +4,7 @@ import {
4
4
  EntryData,
5
5
  RSCRouterContext,
6
6
  runWithPrefixes,
7
+ getIsolatedLazyParent,
7
8
  } from "../server/context";
8
9
  import type { UrlPatterns } from "../urls.js";
9
10
  import type { AllUseItems, IncludeItem } from "../route-types.js";
@@ -14,6 +15,7 @@ export interface LazyEvalDeps<TEnv = any> {
14
15
  mergedRouteMap: Record<string, string>;
15
16
  nextMountIndex: () => number;
16
17
  getPrecomputedByPrefix: () => Map<string, Record<string, string>> | null;
18
+ routerId?: string;
17
19
  }
18
20
 
19
21
  // Detect lazy includes in handler result and create placeholder entries
@@ -137,7 +139,7 @@ export function evaluateLazyEntry<TEnv = any>(
137
139
  patternsByPrefix,
138
140
  trailingSlash: trailingSlashMap,
139
141
  namespace: "lazy",
140
- parent: (lazyContext?.parent as EntryData | null) ?? null,
142
+ parent: getIsolatedLazyParent(lazyContext?.parent as EntryData | null),
141
143
  counters: lazyCounters,
142
144
  cacheProfiles: (lazyContext as any)?.cacheProfiles,
143
145
  rootScoped: (lazyContext as any)?.rootScoped,
@@ -200,6 +202,7 @@ export function evaluateLazyEntry<TEnv = any>(
200
202
  trailingSlash: entry.trailingSlash,
201
203
  handler: (lazyInclude.patterns as UrlPatterns<TEnv>).handler,
202
204
  mountIndex: deps.nextMountIndex(),
205
+ routerId: deps.routerId,
203
206
  // Lazy evaluation fields
204
207
  lazy: true,
205
208
  lazyPatterns: lazyInclude.patterns,
@@ -12,7 +12,10 @@ export interface RevalidationTraceEntry {
12
12
  | "cache-hit"
13
13
  | "loader"
14
14
  | "parallel"
15
- | "orphan-layout";
15
+ | "orphan-layout"
16
+ | "route-handler"
17
+ | "layout-handler"
18
+ | "intercept-loader";
16
19
  defaultShouldRevalidate: boolean;
17
20
  finalShouldRevalidate: boolean;
18
21
  reason: string;
@@ -71,7 +74,7 @@ function getHeaderRequestId(request: Request): string | null {
71
74
  return trimmed.length > 0 ? trimmed : null;
72
75
  }
73
76
 
74
- function getOrCreateRequestId(request: Request): string {
77
+ export function getOrCreateRequestId(request: Request): string {
75
78
  const existing = requestIds.get(request);
76
79
  if (existing) return existing;
77
80
 
@@ -9,6 +9,7 @@ import { createRouteHelpers } from "../route-definition";
9
9
  import {
10
10
  getContext,
11
11
  runWithPrefixes,
12
+ getIsolatedLazyParent,
12
13
  type EntryData,
13
14
  type MetricsStore,
14
15
  } from "../server/context";
@@ -65,7 +66,9 @@ export async function loadManifest(
65
66
  const mountIndex = entry.mountIndex;
66
67
 
67
68
  // Check module-level cache (persists across requests within same isolate)
68
- const cacheKey = `${VERSION}:${mountIndex ?? ""}:${routeKey}:${isSSR ? 1 : 0}`;
69
+ // Include routerId so multi-router setups (host routing) don't share cached
70
+ // EntryData across routers with overlapping mountIndex + routeKey combinations.
71
+ const cacheKey = `${VERSION}:${entry.routerId ?? ""}:${mountIndex ?? ""}:${routeKey}:${isSSR ? 1 : 0}`;
69
72
  const cached = manifestModuleCache.get(cacheKey);
70
73
  if (cached) {
71
74
  const cacheStart = performance.now();
@@ -112,8 +115,11 @@ export async function loadManifest(
112
115
  // This ensures routes are registered under the correct layout hierarchy
113
116
  const lazyContext =
114
117
  entry.lazy && entry.lazyPatterns ? entry.lazyContext : null;
115
- const parentForContext =
116
- (lazyContext?.parent as EntryData | null) ?? Store.parent;
118
+ const parentForContext = lazyContext
119
+ ? getIsolatedLazyParent(
120
+ (lazyContext.parent as EntryData | null) ?? Store.parent,
121
+ )
122
+ : Store.parent;
117
123
 
118
124
  // For lazy entries, merge captured counters from include() so the
119
125
  // handler's entries get shortCode indices after sibling entries that
@@ -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,19 @@ 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
+ // Prevent background metrics from polluting foreground timeline.
153
+ // The foreground uses its own metricsStore reference directly (via
154
+ // appendMetric), so nulling Store.metrics only affects track() calls
155
+ // inside this background Store.run() scope.
156
+ const savedMetrics = ctx.Store.metrics;
157
+ ctx.Store.metrics = undefined;
158
+
159
+ const start = performance.now();
148
160
  debugLog("backgroundRevalidation", "revalidating stale route", {
149
161
  pathname: ctx.pathname,
150
162
  fullMatch: ctx.isFullMatch,
@@ -174,7 +186,9 @@ export function withBackgroundRevalidation<TEnv>(
174
186
  setupLoaderAccess(freshHandlerContext, freshLoaderPromises);
175
187
 
176
188
  // Resolve all segments fresh (without revalidation logic)
177
- // to ensure complete components for caching
189
+ // to ensure complete components for caching.
190
+ // Skip DSL loaders — they are never cached (cacheRoute filters them)
191
+ // and are always resolved fresh on each request.
178
192
  const freshSegments = await ctx.Store.run(() =>
179
193
  resolveAllSegments(
180
194
  ctx.entries,
@@ -182,6 +196,7 @@ export function withBackgroundRevalidation<TEnv>(
182
196
  ctx.matched.params,
183
197
  freshHandlerContext,
184
198
  freshLoaderPromises,
199
+ { skipLoaders: true },
185
200
  ),
186
201
  );
187
202
 
@@ -207,16 +222,29 @@ export function withBackgroundRevalidation<TEnv>(
207
222
  completeSegments,
208
223
  ctx.isIntercept,
209
224
  );
225
+ if (INTERNAL_RANGO_DEBUG) {
226
+ const dur = performance.now() - start;
227
+ console.log(
228
+ `[RSC Background][req:${reqId}] SWR revalidation ${ctx.pathname} (${dur.toFixed(2)}ms) segments=${completeSegments.length}`,
229
+ );
230
+ }
210
231
  debugLog("backgroundRevalidation", "revalidation complete", {
211
232
  pathname: ctx.pathname,
212
233
  });
213
234
  } catch (error) {
235
+ if (INTERNAL_RANGO_DEBUG) {
236
+ const dur = performance.now() - start;
237
+ console.log(
238
+ `[RSC Background][req:${reqId}] SWR revalidation ${ctx.pathname} FAILED (${dur.toFixed(2)}ms) error=${String(error)}`,
239
+ );
240
+ }
214
241
  debugWarn("backgroundRevalidation", "revalidation failed", {
215
242
  pathname: ctx.pathname,
216
243
  error: String(error),
217
244
  });
218
245
  } finally {
219
246
  requestCtx._handleStore = originalHandleStore;
247
+ ctx.Store.metrics = savedMetrics;
220
248
  }
221
249
  });
222
250
  };
@@ -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,41 @@ 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 params or search params change — the
526
+ // handler reads ctx.params/ctx.searchParams so different values produce
527
+ // different content. Matches evaluateRevalidation's default logic.
528
+ const searchChanged = ctx.prevUrl.search !== ctx.url.search;
529
+ const routeParamsChanged = !paramsEqual(
530
+ ctx.matched.params,
531
+ ctx.prevParams,
532
+ );
533
+ const shouldDefaultRevalidate =
534
+ (searchChanged || routeParamsChanged) &&
535
+ (segment.type === "route" ||
536
+ (segment.belongsToRoute &&
537
+ (segment.type === "layout" || segment.type === "parallel")));
538
+
512
539
  if (!entryInfo || entryInfo.revalidate.length === 0) {
540
+ if (shouldDefaultRevalidate) {
541
+ // Params or search params changed — must re-render even without custom rules
542
+ if (isTraceActive()) {
543
+ pushRevalidationTraceEntry({
544
+ segmentId: segment.id,
545
+ segmentType: segment.type,
546
+ belongsToRoute: segment.belongsToRoute ?? false,
547
+ source: "cache-hit",
548
+ defaultShouldRevalidate: true,
549
+ finalShouldRevalidate: true,
550
+ reason: routeParamsChanged
551
+ ? "cached-params-changed"
552
+ : "cached-search-changed",
553
+ });
554
+ }
555
+ yield segment;
556
+ continue;
557
+ }
513
558
  // No revalidation rules, use default behavior (skip if client has)
514
559
  if (isTraceActive()) {
515
560
  pushRevalidationTraceEntry({
@@ -573,6 +618,7 @@ export function withCacheLookup<TEnv>(
573
618
  // Resolve loaders fresh (loaders are NOT cached by default)
574
619
  // This ensures fresh data even on cache hit
575
620
  const Store = ctx.Store;
621
+ const loaderStart = performance.now();
576
622
 
577
623
  if (ctx.isFullMatch) {
578
624
  // Full match (document request) - simple loader resolution without revalidation
@@ -605,7 +651,11 @@ export function withCacheLookup<TEnv>(
605
651
  ctx.url,
606
652
  ctx.routeKey,
607
653
  ctx.actionContext,
608
- cacheResult.shouldRevalidate || undefined,
654
+ // Loaders are never cached in the segment cache, so segment
655
+ // staleness (cacheResult.shouldRevalidate) must not propagate.
656
+ // But browser-sent staleness (ctx.stale) — indicating an action
657
+ // happened in this or another tab — must still reach loaders.
658
+ ctx.stale || undefined,
609
659
  ),
610
660
  );
611
661
 
@@ -624,9 +674,16 @@ export function withCacheLookup<TEnv>(
624
674
  }
625
675
  }
626
676
  if (ms) {
677
+ const loaderEnd = performance.now();
678
+ ms.metrics.push({
679
+ label: "pipeline:loader-resolve",
680
+ duration: loaderEnd - loaderStart,
681
+ startTime: loaderStart - ms.requestStart,
682
+ depth: 1,
683
+ });
627
684
  ms.metrics.push({
628
- label: "pipeline:cache-lookup",
629
- duration: performance.now() - pipelineStart,
685
+ label: "pipeline:cache-hit",
686
+ duration: loaderEnd - pipelineStart,
630
687
  startTime: pipelineStart - ms.requestStart,
631
688
  });
632
689
  }
@@ -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
  };