@rangojs/router 0.0.0-experimental.f3c21dba → 0.0.0-experimental.f681f24a
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 +153 -5
- package/package.json +1 -1
- package/skills/cache-guide/SKILL.md +32 -0
- package/skills/caching/SKILL.md +8 -0
- package/skills/loader/SKILL.md +53 -43
- package/skills/parallel/SKILL.md +67 -0
- package/skills/route/SKILL.md +31 -0
- package/skills/router-setup/SKILL.md +52 -2
- package/skills/typesafety/SKILL.md +10 -0
- package/src/browser/debug-channel.ts +93 -0
- package/src/browser/navigation-client.ts +17 -1
- package/src/browser/partial-update.ts +11 -0
- package/src/browser/prefetch/queue.ts +61 -29
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/react/NavigationProvider.tsx +5 -3
- package/src/browser/server-action-bridge.ts +12 -0
- package/src/browser/types.ts +8 -1
- package/src/cache/cache-runtime.ts +15 -11
- package/src/cache/cache-scope.ts +46 -5
- package/src/cache/taint.ts +55 -0
- package/src/context-var.ts +72 -2
- package/src/deps/browser.ts +1 -0
- package/src/route-definition/helpers-types.ts +6 -5
- package/src/router/handler-context.ts +31 -8
- package/src/router/loader-resolution.ts +7 -1
- package/src/router/match-middleware/background-revalidation.ts +12 -1
- package/src/router/match-middleware/cache-lookup.ts +12 -5
- package/src/router/match-middleware/cache-store.ts +21 -4
- package/src/router/match-result.ts +11 -5
- package/src/router/middleware-types.ts +6 -2
- package/src/router/middleware.ts +2 -2
- package/src/router/router-context.ts +1 -0
- package/src/router/segment-resolution/fresh.ts +12 -6
- package/src/router/segment-resolution/helpers.ts +29 -24
- package/src/router/segment-resolution/revalidation.ts +9 -2
- package/src/router/types.ts +1 -0
- package/src/router.ts +1 -0
- package/src/rsc/handler.ts +28 -2
- package/src/rsc/loader-fetch.ts +7 -2
- package/src/rsc/progressive-enhancement.ts +4 -1
- package/src/rsc/rsc-rendering.ts +4 -1
- package/src/rsc/server-action.ts +2 -0
- package/src/rsc/types.ts +7 -1
- package/src/server/context.ts +12 -0
- package/src/server/request-context.ts +49 -8
- package/src/types/handler-context.ts +20 -8
- package/src/types/loader-types.ts +4 -4
- package/src/vite/plugins/performance-tracks.ts +231 -0
- package/src/vite/rango.ts +4 -0
package/src/context-var.ts
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* interface PaginationData { current: number; total: number }
|
|
13
13
|
* export const Pagination = createVar<PaginationData>();
|
|
14
14
|
*
|
|
15
|
+
* // Non-cacheable var — throws if set/get inside cache() or "use cache"
|
|
16
|
+
* export const User = createVar<UserData>({ cache: false });
|
|
17
|
+
*
|
|
15
18
|
* // handler
|
|
16
19
|
* ctx.set(Pagination, { current: 1, total: 4 });
|
|
17
20
|
*
|
|
@@ -23,18 +26,36 @@
|
|
|
23
26
|
export interface ContextVar<T> {
|
|
24
27
|
readonly __brand: "context-var";
|
|
25
28
|
readonly key: symbol;
|
|
29
|
+
/** When false, the var is non-cacheable — throws inside cache() / "use cache" */
|
|
30
|
+
readonly cache: boolean;
|
|
26
31
|
/** Phantom field to carry the type parameter. Never set at runtime. */
|
|
27
32
|
readonly __type?: T;
|
|
28
33
|
}
|
|
29
34
|
|
|
35
|
+
export interface ContextVarOptions {
|
|
36
|
+
/**
|
|
37
|
+
* When false, marks this variable as non-cacheable.
|
|
38
|
+
* Setting or getting this var inside a cache() boundary or "use cache"
|
|
39
|
+
* function will throw. Use for inherently request-specific data (user
|
|
40
|
+
* sessions, auth tokens, etc.) that must never be baked into cached segments.
|
|
41
|
+
*
|
|
42
|
+
* @default true
|
|
43
|
+
*/
|
|
44
|
+
cache?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
30
47
|
/**
|
|
31
48
|
* Create a typed context variable token.
|
|
32
49
|
*
|
|
33
50
|
* The returned object is used with ctx.set(token, value) and ctx.get(token)
|
|
34
51
|
* for compile-time-checked data flow between handlers, layouts, and middleware.
|
|
35
52
|
*/
|
|
36
|
-
export function createVar<T>(): ContextVar<T> {
|
|
37
|
-
return {
|
|
53
|
+
export function createVar<T>(options?: ContextVarOptions): ContextVar<T> {
|
|
54
|
+
return {
|
|
55
|
+
__brand: "context-var" as const,
|
|
56
|
+
key: Symbol(),
|
|
57
|
+
cache: options?.cache !== false,
|
|
58
|
+
};
|
|
38
59
|
}
|
|
39
60
|
|
|
40
61
|
/**
|
|
@@ -49,6 +70,36 @@ export function isContextVar(value: unknown): value is ContextVar<unknown> {
|
|
|
49
70
|
);
|
|
50
71
|
}
|
|
51
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Symbol used as a Set stored on the variables object to track
|
|
75
|
+
* which keys hold non-cacheable values (from write-level { cache: false }).
|
|
76
|
+
*/
|
|
77
|
+
const NON_CACHEABLE_KEYS: unique symbol = Symbol.for(
|
|
78
|
+
"rango:non-cacheable-keys",
|
|
79
|
+
) as any;
|
|
80
|
+
|
|
81
|
+
function getNonCacheableKeys(variables: any): Set<string | symbol> {
|
|
82
|
+
if (!variables[NON_CACHEABLE_KEYS]) {
|
|
83
|
+
variables[NON_CACHEABLE_KEYS] = new Set();
|
|
84
|
+
}
|
|
85
|
+
return variables[NON_CACHEABLE_KEYS];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Check if a variable value is non-cacheable (either var-level or write-level).
|
|
90
|
+
*/
|
|
91
|
+
export function isNonCacheable(
|
|
92
|
+
variables: any,
|
|
93
|
+
keyOrVar: string | ContextVar<any>,
|
|
94
|
+
): boolean {
|
|
95
|
+
if (typeof keyOrVar !== "string" && !keyOrVar.cache) {
|
|
96
|
+
return true; // var-level policy
|
|
97
|
+
}
|
|
98
|
+
const key = typeof keyOrVar === "string" ? keyOrVar : keyOrVar.key;
|
|
99
|
+
const set = variables[NON_CACHEABLE_KEYS] as Set<string | symbol> | undefined;
|
|
100
|
+
return set?.has(key) ?? false; // write-level policy
|
|
101
|
+
}
|
|
102
|
+
|
|
52
103
|
/**
|
|
53
104
|
* Read a variable from the variables store.
|
|
54
105
|
* Accepts either a string key (legacy) or a ContextVar token (typed).
|
|
@@ -64,6 +115,17 @@ export function contextGet(
|
|
|
64
115
|
/** Keys that must never be used as string variable names */
|
|
65
116
|
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
66
117
|
|
|
118
|
+
export interface ContextSetOptions {
|
|
119
|
+
/**
|
|
120
|
+
* When false, marks this specific write as non-cacheable.
|
|
121
|
+
* "Least cacheable wins" — if either the var definition or this option
|
|
122
|
+
* says cache: false, the value is non-cacheable.
|
|
123
|
+
*
|
|
124
|
+
* @default true (inherits from createVar)
|
|
125
|
+
*/
|
|
126
|
+
cache?: boolean;
|
|
127
|
+
}
|
|
128
|
+
|
|
67
129
|
/**
|
|
68
130
|
* Write a variable to the variables store.
|
|
69
131
|
* Accepts either a string key (legacy) or a ContextVar token (typed).
|
|
@@ -72,6 +134,7 @@ export function contextSet(
|
|
|
72
134
|
variables: any,
|
|
73
135
|
keyOrVar: string | ContextVar<any>,
|
|
74
136
|
value: any,
|
|
137
|
+
options?: ContextSetOptions,
|
|
75
138
|
): void {
|
|
76
139
|
if (typeof keyOrVar === "string") {
|
|
77
140
|
if (FORBIDDEN_KEYS.has(keyOrVar)) {
|
|
@@ -80,7 +143,14 @@ export function contextSet(
|
|
|
80
143
|
);
|
|
81
144
|
}
|
|
82
145
|
variables[keyOrVar] = value;
|
|
146
|
+
if (options?.cache === false) {
|
|
147
|
+
getNonCacheableKeys(variables).add(keyOrVar);
|
|
148
|
+
}
|
|
83
149
|
} else {
|
|
84
150
|
variables[keyOrVar.key] = value;
|
|
151
|
+
// Track write-level non-cacheable (var-level is checked via keyOrVar.cache)
|
|
152
|
+
if (options?.cache === false) {
|
|
153
|
+
getNonCacheableKeys(variables).add(keyOrVar.key);
|
|
154
|
+
}
|
|
85
155
|
}
|
|
86
156
|
}
|
package/src/deps/browser.ts
CHANGED
|
@@ -228,11 +228,12 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
|
|
|
228
228
|
* revalidate(({ actionId }) => actionId?.includes("Cart") ?? false),
|
|
229
229
|
* ])
|
|
230
230
|
*
|
|
231
|
-
* //
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
* }
|
|
231
|
+
* // Consume in client components with useLoader()
|
|
232
|
+
* // (preferred — cache-safe, always fresh)
|
|
233
|
+
* function ProductDetails() {
|
|
234
|
+
* const { data } = useLoader(ProductLoader);
|
|
235
|
+
* return <div>{data.name}</div>;
|
|
236
|
+
* }
|
|
236
237
|
* ```
|
|
237
238
|
* @param loaderDef - Loader created with createLoader()
|
|
238
239
|
* @param use - Optional callback for loader-specific revalidation rules
|
|
@@ -8,7 +8,13 @@ import type { HandlerContext, InternalHandlerContext } from "../types";
|
|
|
8
8
|
import { _getRequestContext } from "../server/request-context.js";
|
|
9
9
|
import { getSearchSchema, isRouteRootScoped } from "../route-map-builder.js";
|
|
10
10
|
import { parseSearchParams, serializeSearchParams } from "../search-params.js";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
contextGet,
|
|
13
|
+
contextSet,
|
|
14
|
+
isNonCacheable,
|
|
15
|
+
type ContextSetOptions,
|
|
16
|
+
} from "../context-var.js";
|
|
17
|
+
import { isInsideCacheScope } from "../server/context.js";
|
|
12
18
|
import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
|
|
13
19
|
import { isAutoGeneratedRouteName } from "../route-name.js";
|
|
14
20
|
import { PRERENDER_PASSTHROUGH } from "../prerender.js";
|
|
@@ -213,7 +219,7 @@ export function createHandlerContext<TEnv>(
|
|
|
213
219
|
const stubResponse =
|
|
214
220
|
requestContext?.res ?? new Response(null, { status: 200 });
|
|
215
221
|
|
|
216
|
-
// Guard mutating Headers methods so they throw inside "use cache"
|
|
222
|
+
// Guard mutating Headers methods so they throw inside "use cache" or cache() scope.
|
|
217
223
|
// Uses lazy `ctx` reference (assigned below) — only the specific handler ctx
|
|
218
224
|
// is stamped by cache-runtime, not the shared request context.
|
|
219
225
|
const MUTATING_HEADERS_METHODS = new Set(["set", "append", "delete"]);
|
|
@@ -225,6 +231,13 @@ export function createHandlerContext<TEnv>(
|
|
|
225
231
|
if (MUTATING_HEADERS_METHODS.has(prop as string)) {
|
|
226
232
|
return (...args: any[]) => {
|
|
227
233
|
assertNotInsideCacheExec(ctx, "headers");
|
|
234
|
+
if (isInsideCacheScope()) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`ctx.headers.${String(prop)}() cannot be called inside a cache() boundary. ` +
|
|
237
|
+
`On cache hit the handler is skipped, so this side effect would be lost. ` +
|
|
238
|
+
`Move header mutations to a middleware or layout outside the cache() scope.`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
228
241
|
return value.apply(target, args);
|
|
229
242
|
};
|
|
230
243
|
}
|
|
@@ -245,13 +258,23 @@ export function createHandlerContext<TEnv>(
|
|
|
245
258
|
originalUrl: new URL(request.url),
|
|
246
259
|
env: bindings,
|
|
247
260
|
var: variables,
|
|
248
|
-
get: ((keyOrVar: any) =>
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
261
|
+
get: ((keyOrVar: any) => {
|
|
262
|
+
// Read-time guard: non-cacheable var inside cache() → throw.
|
|
263
|
+
// Works for both ContextVar tokens and string keys.
|
|
264
|
+
if (isNonCacheable(variables, keyOrVar) && isInsideCacheScope()) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`ctx.get() for a non-cacheable variable cannot be called inside a cache() boundary. ` +
|
|
267
|
+
`The variable was created with { cache: false } or set with { cache: false }, ` +
|
|
268
|
+
`and its value would be stale on cache hit. Move the read outside the cached scope.`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return contextGet(variables, keyOrVar);
|
|
272
|
+
}) as HandlerContext<any, TEnv>["get"],
|
|
273
|
+
set: ((keyOrVar: any, value: any, options?: ContextSetOptions) => {
|
|
253
274
|
assertNotInsideCacheExec(ctx, "set");
|
|
254
|
-
|
|
275
|
+
// Write is dumb: store value + non-cacheable metadata.
|
|
276
|
+
// Enforcement happens at read time via ctx.get().
|
|
277
|
+
contextSet(variables, keyOrVar, value, options);
|
|
255
278
|
}) as HandlerContext<any, TEnv>["set"],
|
|
256
279
|
res: stubResponse, // Stub response for setting headers
|
|
257
280
|
headers: guardedHeaders, // Guarded shorthand for res.headers
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { ReactNode } from "react";
|
|
8
8
|
import { track } from "../server/context";
|
|
9
9
|
import type { EntryData } from "../server/context";
|
|
10
|
+
import { contextGet } from "../context-var.js";
|
|
10
11
|
import type {
|
|
11
12
|
ResolvedSegment,
|
|
12
13
|
HandlerContext,
|
|
@@ -241,6 +242,11 @@ function createLoaderExecutor<TEnv>(
|
|
|
241
242
|
pendingLoaders.add(loader.$$id);
|
|
242
243
|
|
|
243
244
|
const currentLoaderId = loader.$$id;
|
|
245
|
+
// Loader functions are always fresh (never cached), so they get an
|
|
246
|
+
// unguarded get that bypasses non-cacheable read guards. This applies
|
|
247
|
+
// to ALL loaders — DSL and handler-called — because the loader
|
|
248
|
+
// function itself always re-executes. Also handles nested deps
|
|
249
|
+
// (loaderA → use(loaderB)) since all share this unguarded get.
|
|
244
250
|
const loaderCtx: LoaderContext<Record<string, string | undefined>, TEnv> = {
|
|
245
251
|
params: ctx.params,
|
|
246
252
|
routeParams: (ctx.params ?? {}) as Record<string, string>,
|
|
@@ -251,7 +257,7 @@ function createLoaderExecutor<TEnv>(
|
|
|
251
257
|
url: ctx.url,
|
|
252
258
|
env: ctx.env,
|
|
253
259
|
var: ctx.var,
|
|
254
|
-
get: ctx.get,
|
|
260
|
+
get: ((keyOrVar: any) => contextGet(ctx.var, keyOrVar)) as typeof ctx.get,
|
|
255
261
|
use: <TDep, TDepParams = any>(
|
|
256
262
|
dep: LoaderDefinition<TDep, TDepParams>,
|
|
257
263
|
): Promise<TDep> => {
|
|
@@ -149,6 +149,13 @@ export function withBackgroundRevalidation<TEnv>(
|
|
|
149
149
|
: undefined;
|
|
150
150
|
|
|
151
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
|
+
|
|
152
159
|
const start = performance.now();
|
|
153
160
|
debugLog("backgroundRevalidation", "revalidating stale route", {
|
|
154
161
|
pathname: ctx.pathname,
|
|
@@ -179,7 +186,9 @@ export function withBackgroundRevalidation<TEnv>(
|
|
|
179
186
|
setupLoaderAccess(freshHandlerContext, freshLoaderPromises);
|
|
180
187
|
|
|
181
188
|
// Resolve all segments fresh (without revalidation logic)
|
|
182
|
-
// 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.
|
|
183
192
|
const freshSegments = await ctx.Store.run(() =>
|
|
184
193
|
resolveAllSegments(
|
|
185
194
|
ctx.entries,
|
|
@@ -187,6 +196,7 @@ export function withBackgroundRevalidation<TEnv>(
|
|
|
187
196
|
ctx.matched.params,
|
|
188
197
|
freshHandlerContext,
|
|
189
198
|
freshLoaderPromises,
|
|
199
|
+
{ skipLoaders: true },
|
|
190
200
|
),
|
|
191
201
|
);
|
|
192
202
|
|
|
@@ -234,6 +244,7 @@ export function withBackgroundRevalidation<TEnv>(
|
|
|
234
244
|
});
|
|
235
245
|
} finally {
|
|
236
246
|
requestCtx._handleStore = originalHandleStore;
|
|
247
|
+
ctx.Store.metrics = savedMetrics;
|
|
237
248
|
}
|
|
238
249
|
});
|
|
239
250
|
};
|
|
@@ -522,18 +522,23 @@ export function withCacheLookup<TEnv>(
|
|
|
522
522
|
const entryInfo = entryRevalidateMap?.get(segment.id);
|
|
523
523
|
|
|
524
524
|
// Even without explicit revalidation rules, route segments and their
|
|
525
|
-
// children must re-render when search params change — the
|
|
526
|
-
// ctx.searchParams so different
|
|
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.
|
|
527
528
|
const searchChanged = ctx.prevUrl.search !== ctx.url.search;
|
|
529
|
+
const routeParamsChanged = !paramsEqual(
|
|
530
|
+
ctx.matched.params,
|
|
531
|
+
ctx.prevParams,
|
|
532
|
+
);
|
|
528
533
|
const shouldDefaultRevalidate =
|
|
529
|
-
searchChanged &&
|
|
534
|
+
(searchChanged || routeParamsChanged) &&
|
|
530
535
|
(segment.type === "route" ||
|
|
531
536
|
(segment.belongsToRoute &&
|
|
532
537
|
(segment.type === "layout" || segment.type === "parallel")));
|
|
533
538
|
|
|
534
539
|
if (!entryInfo || entryInfo.revalidate.length === 0) {
|
|
535
540
|
if (shouldDefaultRevalidate) {
|
|
536
|
-
//
|
|
541
|
+
// Params or search params changed — must re-render even without custom rules
|
|
537
542
|
if (isTraceActive()) {
|
|
538
543
|
pushRevalidationTraceEntry({
|
|
539
544
|
segmentId: segment.id,
|
|
@@ -542,7 +547,9 @@ export function withCacheLookup<TEnv>(
|
|
|
542
547
|
source: "cache-hit",
|
|
543
548
|
defaultShouldRevalidate: true,
|
|
544
549
|
finalShouldRevalidate: true,
|
|
545
|
-
reason:
|
|
550
|
+
reason: routeParamsChanged
|
|
551
|
+
? "cached-params-changed"
|
|
552
|
+
: "cached-search-changed",
|
|
546
553
|
});
|
|
547
554
|
}
|
|
548
555
|
yield segment;
|
|
@@ -165,10 +165,14 @@ export function withCacheStore<TEnv>(
|
|
|
165
165
|
// Combine main segments with intercept segments
|
|
166
166
|
const allSegmentsToCache = [...allSegments, ...state.interceptSegments];
|
|
167
167
|
|
|
168
|
-
// Check if any non-loader segments have null components
|
|
169
|
-
//
|
|
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);
|
|
170
173
|
const hasNullComponents = allSegmentsToCache.some(
|
|
171
|
-
(s) =>
|
|
174
|
+
(s) =>
|
|
175
|
+
s.component === null && s.type !== "loader" && clientIdSet.has(s.id),
|
|
172
176
|
);
|
|
173
177
|
|
|
174
178
|
const requestCtx = getRequestContext();
|
|
@@ -195,6 +199,10 @@ export function withCacheStore<TEnv>(
|
|
|
195
199
|
// Proactive caching: render all segments fresh in background
|
|
196
200
|
// This ensures cache has complete components for future requests
|
|
197
201
|
requestCtx.waitUntil(async () => {
|
|
202
|
+
// Prevent background metrics from polluting foreground timeline.
|
|
203
|
+
const savedMetrics = ctx.Store.metrics;
|
|
204
|
+
ctx.Store.metrics = undefined;
|
|
205
|
+
|
|
198
206
|
const start = performance.now();
|
|
199
207
|
debugLog("cacheStore", "proactive caching started", {
|
|
200
208
|
pathname: ctx.pathname,
|
|
@@ -225,7 +233,9 @@ export function withCacheStore<TEnv>(
|
|
|
225
233
|
// Use normal loader access so handle data is captured
|
|
226
234
|
setupLoaderAccess(proactiveHandlerContext, proactiveLoaderPromises);
|
|
227
235
|
|
|
228
|
-
// 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.
|
|
229
239
|
const Store = ctx.Store;
|
|
230
240
|
const freshSegments = await Store.run(() =>
|
|
231
241
|
resolveAllSegments(
|
|
@@ -234,6 +244,7 @@ export function withCacheStore<TEnv>(
|
|
|
234
244
|
ctx.matched.params,
|
|
235
245
|
proactiveHandlerContext,
|
|
236
246
|
proactiveLoaderPromises,
|
|
247
|
+
{ skipLoaders: true },
|
|
237
248
|
),
|
|
238
249
|
);
|
|
239
250
|
|
|
@@ -285,11 +296,17 @@ export function withCacheStore<TEnv>(
|
|
|
285
296
|
});
|
|
286
297
|
} finally {
|
|
287
298
|
requestCtx._handleStore = originalHandleStore;
|
|
299
|
+
ctx.Store.metrics = savedMetrics;
|
|
288
300
|
}
|
|
289
301
|
});
|
|
290
302
|
} else {
|
|
291
303
|
// All segments have components - cache directly
|
|
292
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
|
+
}
|
|
293
310
|
requestCtx.waitUntil(async () => {
|
|
294
311
|
const start = performance.now();
|
|
295
312
|
await cacheScope.cacheRoute(
|
|
@@ -67,10 +67,11 @@
|
|
|
67
67
|
* Keep if:
|
|
68
68
|
* - component !== null (needs rendering)
|
|
69
69
|
* - type === "loader" (carries data even with null component)
|
|
70
|
+
* - client doesn't have the segment (structurally required parent node)
|
|
70
71
|
*
|
|
71
72
|
* Skip if:
|
|
72
|
-
* - component === null AND type !== "loader"
|
|
73
|
-
* - (
|
|
73
|
+
* - component === null AND type !== "loader" AND client has it cached
|
|
74
|
+
* - (Revalidation skip — client already has this segment's UI)
|
|
74
75
|
*
|
|
75
76
|
*
|
|
76
77
|
* INTERCEPT HANDLING
|
|
@@ -168,10 +169,15 @@ export function buildMatchResult<TEnv>(
|
|
|
168
169
|
// Deduplicate allIds (defense-in-depth for partial match path)
|
|
169
170
|
allIds = [...new Set(allIds)];
|
|
170
171
|
|
|
171
|
-
// Filter out segments
|
|
172
|
-
//
|
|
172
|
+
// Filter out null-component segments only when the client already has
|
|
173
|
+
// them cached (revalidation skip). If the client doesn't have the segment,
|
|
174
|
+
// it must be included even with null component — it's structurally required
|
|
175
|
+
// as a parent node for child layouts/parallels to reconcile against.
|
|
176
|
+
// Loader segments are always included as they carry data.
|
|
177
|
+
const clientIdSet = new Set(ctx.clientSegmentIds);
|
|
173
178
|
segmentsToRender = allSegments.filter(
|
|
174
|
-
(s) =>
|
|
179
|
+
(s) =>
|
|
180
|
+
s.component !== null || s.type === "loader" || !clientIdSet.has(s.id),
|
|
175
181
|
);
|
|
176
182
|
}
|
|
177
183
|
|
|
@@ -27,8 +27,12 @@ type GetVariableFn = {
|
|
|
27
27
|
* Set variable function type
|
|
28
28
|
*/
|
|
29
29
|
type SetVariableFn = {
|
|
30
|
-
<T>(contextVar: ContextVar<T>, value: T): void;
|
|
31
|
-
<K extends keyof DefaultVars>(
|
|
30
|
+
<T>(contextVar: ContextVar<T>, value: T, options?: { cache?: boolean }): void;
|
|
31
|
+
<K extends keyof DefaultVars>(
|
|
32
|
+
key: K,
|
|
33
|
+
value: DefaultVars[K],
|
|
34
|
+
options?: { cache?: boolean },
|
|
35
|
+
): void;
|
|
32
36
|
};
|
|
33
37
|
|
|
34
38
|
/**
|
package/src/router/middleware.ts
CHANGED
|
@@ -204,8 +204,8 @@ export function createMiddlewareContext<TEnv>(
|
|
|
204
204
|
get: ((keyOrVar: any) =>
|
|
205
205
|
contextGet(variables, keyOrVar)) as MiddlewareContext<TEnv>["get"],
|
|
206
206
|
|
|
207
|
-
set: ((keyOrVar: any, value: unknown) => {
|
|
208
|
-
contextSet(variables, keyOrVar, value);
|
|
207
|
+
set: ((keyOrVar: any, value: unknown, options?: any) => {
|
|
208
|
+
contextSet(variables, keyOrVar, value, options);
|
|
209
209
|
}) as MiddlewareContext<TEnv>["set"],
|
|
210
210
|
|
|
211
211
|
var: variables as MiddlewareContext<TEnv>["var"],
|
|
@@ -210,6 +210,7 @@ export interface RouterContext<TEnv = any> {
|
|
|
210
210
|
params: Record<string, string>,
|
|
211
211
|
handlerContext: HandlerContext<any, TEnv>,
|
|
212
212
|
loaderPromises: Map<string, Promise<any>>,
|
|
213
|
+
options?: { skipLoaders?: boolean },
|
|
213
214
|
) => Promise<ResolvedSegment[]>;
|
|
214
215
|
|
|
215
216
|
// Generator-based simple resolution
|
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
} from "./helpers.js";
|
|
31
31
|
import { getRouterContext } from "../router-context.js";
|
|
32
32
|
import { resolveSink, safeEmit } from "../telemetry.js";
|
|
33
|
-
import { track } from "../../server/context.js";
|
|
33
|
+
import { track, RSCRouterContext } from "../../server/context.js";
|
|
34
34
|
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
36
36
|
// Streamed handler telemetry
|
|
@@ -100,9 +100,7 @@ export async function resolveLoaders<TEnv>(
|
|
|
100
100
|
|
|
101
101
|
if (!loadingDisabled) {
|
|
102
102
|
// Streaming loaders: promises kick off now, settle during RSC serialization.
|
|
103
|
-
|
|
104
|
-
// RSC/SSR stream consumption, after the perf timeline is logged.
|
|
105
|
-
return loaderEntries.map((loaderEntry, i) => {
|
|
103
|
+
const segments = loaderEntries.map((loaderEntry, i) => {
|
|
106
104
|
const { loader } = loaderEntry;
|
|
107
105
|
const segmentId = `${shortCode}D${i}.${loader.$$id}`;
|
|
108
106
|
return {
|
|
@@ -122,11 +120,12 @@ export async function resolveLoaders<TEnv>(
|
|
|
122
120
|
belongsToRoute,
|
|
123
121
|
};
|
|
124
122
|
});
|
|
123
|
+
|
|
124
|
+
return segments;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
// Loading disabled: still start all loaders in parallel, but only emit
|
|
128
128
|
// settled promises so handlers don't stream loading placeholders.
|
|
129
|
-
// We can measure actual execution time here since we await all loaders.
|
|
130
129
|
const pendingLoaderData = loaderEntries.map((loaderEntry) => {
|
|
131
130
|
const start = performance.now();
|
|
132
131
|
const promise = resolveLoaderData(loaderEntry, ctx, ctx.pathname);
|
|
@@ -344,7 +343,7 @@ export async function resolveSegment<TEnv>(
|
|
|
344
343
|
namespace: entry.id,
|
|
345
344
|
type: "route",
|
|
346
345
|
index: 0,
|
|
347
|
-
component,
|
|
346
|
+
component: component ?? null,
|
|
348
347
|
loading: entry.loading === false ? null : entry.loading,
|
|
349
348
|
transition: entry.transition,
|
|
350
349
|
params,
|
|
@@ -580,6 +579,13 @@ export async function resolveAllSegments<TEnv>(
|
|
|
580
579
|
} catch {}
|
|
581
580
|
|
|
582
581
|
for (const entry of entries) {
|
|
582
|
+
// Set ALS flag when entering a cache() boundary so that ctx.get()
|
|
583
|
+
// can guard non-cacheable variable reads. Also guards response-level
|
|
584
|
+
// side effects (headers.set). Persists for all descendant entries.
|
|
585
|
+
if (entry.type === "cache") {
|
|
586
|
+
const store = RSCRouterContext.getStore();
|
|
587
|
+
if (store) store.insideCacheScope = true;
|
|
588
|
+
}
|
|
583
589
|
const doneEntry = track(`segment:${entry.id}`, 1);
|
|
584
590
|
const resolvedSegments = await resolveWithErrorBoundary(
|
|
585
591
|
entry,
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - Error boundary segment creation
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type
|
|
11
|
+
import { createElement, type ReactNode } from "react";
|
|
12
12
|
import { DataNotFoundError } from "../../errors";
|
|
13
13
|
import {
|
|
14
14
|
createErrorInfo,
|
|
@@ -180,34 +180,39 @@ export function catchSegmentError<TEnv>(
|
|
|
180
180
|
|
|
181
181
|
if (error instanceof DataNotFoundError) {
|
|
182
182
|
const notFoundFallback = deps.findNearestNotFoundBoundary(entry);
|
|
183
|
+
// Fall back to router's notFound component, then a plain default
|
|
184
|
+
const notFoundOption = deps.notFoundComponent;
|
|
185
|
+
const defaultFallback =
|
|
186
|
+
typeof notFoundOption === "function"
|
|
187
|
+
? notFoundOption({ pathname: pathname ?? "" })
|
|
188
|
+
: (notFoundOption ?? createElement("h1", null, "Not Found"));
|
|
189
|
+
const effectiveNotFoundFallback = notFoundFallback ?? defaultFallback;
|
|
183
190
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
);
|
|
191
|
+
const notFoundInfo = createNotFoundInfo(
|
|
192
|
+
error,
|
|
193
|
+
entry.shortCode,
|
|
194
|
+
entry.type,
|
|
195
|
+
pathname,
|
|
196
|
+
);
|
|
191
197
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
198
|
+
reportError(true, {
|
|
199
|
+
notFound: true,
|
|
200
|
+
message: notFoundInfo.message,
|
|
201
|
+
});
|
|
196
202
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
203
|
+
debugLog("segment", "notFound boundary handled error", {
|
|
204
|
+
segmentId: entry.shortCode,
|
|
205
|
+
message: notFoundInfo.message,
|
|
206
|
+
});
|
|
201
207
|
|
|
202
|
-
|
|
208
|
+
setResponseStatus(404);
|
|
203
209
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
210
|
+
return createNotFoundSegment(
|
|
211
|
+
notFoundInfo,
|
|
212
|
+
effectiveNotFoundFallback,
|
|
213
|
+
entry,
|
|
214
|
+
params,
|
|
215
|
+
);
|
|
211
216
|
}
|
|
212
217
|
|
|
213
218
|
const fallback = deps.findNearestErrorBoundary(entry);
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
import { getRouterContext } from "../router-context.js";
|
|
43
43
|
import { resolveSink, safeEmit } from "../telemetry.js";
|
|
44
44
|
import { track } from "../../server/context.js";
|
|
45
|
+
import { RSCRouterContext } from "../../server/context.js";
|
|
45
46
|
|
|
46
47
|
// ---------------------------------------------------------------------------
|
|
47
48
|
// Telemetry helpers
|
|
@@ -722,10 +723,12 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
|
|
|
722
723
|
() => null,
|
|
723
724
|
);
|
|
724
725
|
|
|
726
|
+
// Normalize void handlers (undefined) to null so the reconciler's
|
|
727
|
+
// component === null checks work consistently for both void and explicit null.
|
|
725
728
|
const resolvedComponent =
|
|
726
729
|
component && typeof component === "object" && "content" in component
|
|
727
|
-
? (component as { content: ReactNode }).content
|
|
728
|
-
: component;
|
|
730
|
+
? ((component as { content: ReactNode }).content ?? null)
|
|
731
|
+
: (component ?? null);
|
|
729
732
|
|
|
730
733
|
const segment: ResolvedSegment = {
|
|
731
734
|
id: entry.shortCode,
|
|
@@ -1246,6 +1249,10 @@ export async function resolveAllSegmentsWithRevalidation<TEnv>(
|
|
|
1246
1249
|
}
|
|
1247
1250
|
|
|
1248
1251
|
const nonParallelEntry = entry as Exclude<EntryData, { type: "parallel" }>;
|
|
1252
|
+
if (entry.type === "cache") {
|
|
1253
|
+
const store = RSCRouterContext.getStore();
|
|
1254
|
+
if (store) store.insideCacheScope = true;
|
|
1255
|
+
}
|
|
1249
1256
|
const doneEntry = track(`segment:${entry.id}`, 1);
|
|
1250
1257
|
const resolved = await resolveWithErrorBoundary(
|
|
1251
1258
|
nonParallelEntry,
|
package/src/router/types.ts
CHANGED
|
@@ -96,6 +96,7 @@ export interface SegmentResolutionDeps<TEnv = any> {
|
|
|
96
96
|
findNearestNotFoundBoundary: (
|
|
97
97
|
entry: EntryData | null,
|
|
98
98
|
) => ReactNode | NotFoundBoundaryHandler | null;
|
|
99
|
+
notFoundComponent?: ReactNode | ((props: { pathname: string }) => ReactNode);
|
|
99
100
|
callOnError: (error: unknown, phase: ErrorPhase, context: any) => void;
|
|
100
101
|
}
|
|
101
102
|
|