@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.
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/src/browser/partial-update.ts +11 -0
- package/src/cache/cache-runtime.ts +15 -11
- package/src/router/logging.ts +1 -1
- package/src/router/match-middleware/background-revalidation.ts +18 -1
- package/src/router/match-middleware/cache-lookup.ts +59 -9
- package/src/router/match-middleware/cache-store.ts +32 -6
- package/src/router/match-middleware/intercept-resolution.ts +9 -7
- package/src/router/match-middleware/segment-resolution.ts +7 -5
- package/src/router/match-result.ts +11 -1
- package/src/router/metrics.ts +6 -1
- package/src/router/segment-resolution/fresh.ts +53 -13
- package/src/router/segment-resolution/loader-cache.ts +1 -0
- package/src/router/segment-resolution/revalidation.ts +34 -17
- package/src/types/handler-context.ts +103 -17
package/dist/vite/index.js
CHANGED
|
@@ -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.
|
|
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
|
@@ -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
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
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;
|
package/src/router/logging.ts
CHANGED
|
@@ -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
|
|
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:
|
|
256
|
-
duration:
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
629
|
-
duration:
|
|
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() -
|
|
148
|
-
startTime:
|
|
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() -
|
|
291
|
-
startTime:
|
|
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() -
|
|
142
|
-
startTime:
|
|
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() -
|
|
167
|
-
startTime:
|
|
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() -
|
|
220
|
-
startTime:
|
|
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() -
|
|
122
|
-
startTime:
|
|
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() -
|
|
189
|
-
startTime:
|
|
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
|
-
|
|
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
|
}
|
package/src/router/metrics.ts
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
562
|
+
|
|
563
|
+
// ── Segment metadata (which segment is being evaluated) ──────────────
|
|
564
|
+
|
|
565
|
+
/** The type of segment being revalidated. */
|
|
530
566
|
segmentType: "layout" | "route" | "parallel";
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|