@rangojs/router 0.0.0-experimental.788796d8 → 0.0.0-experimental.78adc454
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 +17 -2
- package/package.json +1 -1
- package/skills/parallel/SKILL.md +67 -0
- package/src/cache/cache-scope.ts +46 -5
- package/src/cache/taint.ts +55 -0
- package/src/context-var.ts +72 -2
- package/src/router/handler-context.ts +31 -8
- package/src/router/match-middleware/cache-lookup.ts +12 -5
- package/src/router/match-middleware/cache-store.ts +12 -3
- package/src/router/middleware-types.ts +6 -2
- package/src/router/middleware.ts +2 -2
- package/src/router/segment-resolution/fresh.ts +8 -1
- package/src/router/segment-resolution/revalidation.ts +5 -0
- package/src/server/context.ts +11 -0
- package/src/server/request-context.ts +43 -7
- package/src/types/handler-context.ts +10 -2
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.78adc454",
|
|
1749
1749
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
1750
1750
|
keywords: [
|
|
1751
1751
|
"react",
|
|
@@ -3274,8 +3274,17 @@ function jsonParseExpression(value) {
|
|
|
3274
3274
|
}
|
|
3275
3275
|
|
|
3276
3276
|
// src/context-var.ts
|
|
3277
|
+
var NON_CACHEABLE_KEYS = /* @__PURE__ */ Symbol.for(
|
|
3278
|
+
"rango:non-cacheable-keys"
|
|
3279
|
+
);
|
|
3280
|
+
function getNonCacheableKeys(variables) {
|
|
3281
|
+
if (!variables[NON_CACHEABLE_KEYS]) {
|
|
3282
|
+
variables[NON_CACHEABLE_KEYS] = /* @__PURE__ */ new Set();
|
|
3283
|
+
}
|
|
3284
|
+
return variables[NON_CACHEABLE_KEYS];
|
|
3285
|
+
}
|
|
3277
3286
|
var FORBIDDEN_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
3278
|
-
function contextSet(variables, keyOrVar, value) {
|
|
3287
|
+
function contextSet(variables, keyOrVar, value, options) {
|
|
3279
3288
|
if (typeof keyOrVar === "string") {
|
|
3280
3289
|
if (FORBIDDEN_KEYS.has(keyOrVar)) {
|
|
3281
3290
|
throw new Error(
|
|
@@ -3283,8 +3292,14 @@ function contextSet(variables, keyOrVar, value) {
|
|
|
3283
3292
|
);
|
|
3284
3293
|
}
|
|
3285
3294
|
variables[keyOrVar] = value;
|
|
3295
|
+
if (options?.cache === false) {
|
|
3296
|
+
getNonCacheableKeys(variables).add(keyOrVar);
|
|
3297
|
+
}
|
|
3286
3298
|
} else {
|
|
3287
3299
|
variables[keyOrVar.key] = value;
|
|
3300
|
+
if (options?.cache === false) {
|
|
3301
|
+
getNonCacheableKeys(variables).add(keyOrVar.key);
|
|
3302
|
+
}
|
|
3288
3303
|
}
|
|
3289
3304
|
}
|
|
3290
3305
|
|
package/package.json
CHANGED
package/skills/parallel/SKILL.md
CHANGED
|
@@ -92,6 +92,73 @@ path("/dashboard/:id", (ctx) => {
|
|
|
92
92
|
])
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
## Setting Handles (Meta, Breadcrumbs)
|
|
96
|
+
|
|
97
|
+
Parallel slot handlers can call `ctx.use(Meta)` or `ctx.use(Breadcrumbs)` to
|
|
98
|
+
push handle data. The data is associated with the **parent** layout or route
|
|
99
|
+
segment, not the parallel segment itself. This is because parallels execute
|
|
100
|
+
after their parent handler and inherit its segment scope.
|
|
101
|
+
|
|
102
|
+
This works well for document-level metadata — the handle data follows the
|
|
103
|
+
parent's lifecycle (appears when the parent is mounted, removed when it
|
|
104
|
+
unmounts).
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
parallel({
|
|
108
|
+
"@meta": (ctx) => {
|
|
109
|
+
const meta = ctx.use(Meta);
|
|
110
|
+
meta({ title: "Product Detail" });
|
|
111
|
+
meta({ name: "description", content: "..." });
|
|
112
|
+
return null; // UI-less slot, only sets metadata
|
|
113
|
+
},
|
|
114
|
+
"@sidebar": (ctx) => <Sidebar />,
|
|
115
|
+
})
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Multiple parallels on the same parent can each push handle data — they all
|
|
119
|
+
accumulate under the parent segment ID.
|
|
120
|
+
|
|
121
|
+
### Pattern: `@meta` slot for per-route metadata overrides
|
|
122
|
+
|
|
123
|
+
A dedicated `@meta` parallel slot lets routes define metadata separately from
|
|
124
|
+
their handler logic. The layout sets defaults via a title template, and each
|
|
125
|
+
route overrides via its own `@meta` slot. Since child segments push after
|
|
126
|
+
parents and `collectMeta` uses last-wins deduplication, overrides work
|
|
127
|
+
naturally.
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
// Layout sets defaults
|
|
131
|
+
layout((ctx) => {
|
|
132
|
+
ctx.use(Meta)({ title: { template: "%s | Store", default: "Store" } });
|
|
133
|
+
return <StoreLayout />;
|
|
134
|
+
}, () => [
|
|
135
|
+
// Route with @meta override — decoupled from handler rendering
|
|
136
|
+
path("/:slug", ProductPage, { name: "product" }, () => [
|
|
137
|
+
parallel({
|
|
138
|
+
"@meta": async (ctx) => {
|
|
139
|
+
const product = await ctx.use(ProductLoader);
|
|
140
|
+
const meta = ctx.use(Meta);
|
|
141
|
+
meta({ title: product.name });
|
|
142
|
+
meta({ name: "description", content: product.description });
|
|
143
|
+
meta({
|
|
144
|
+
"script:ld+json": {
|
|
145
|
+
"@context": "https://schema.org",
|
|
146
|
+
"@type": "Product",
|
|
147
|
+
name: product.name,
|
|
148
|
+
description: product.description,
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
return null; // UI-less slot
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
]),
|
|
155
|
+
])
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
This keeps the route handler focused on rendering UI while metadata
|
|
159
|
+
(title, description, Open Graph, JSON-LD) lives in a composable slot that
|
|
160
|
+
can be added, removed, or swapped per route without touching the handler.
|
|
161
|
+
|
|
95
162
|
## Parallel Routes with Loaders
|
|
96
163
|
|
|
97
164
|
Add loaders and loading states to parallel routes:
|
package/src/cache/cache-scope.ts
CHANGED
|
@@ -328,22 +328,59 @@ export class CacheScope {
|
|
|
328
328
|
// Check if this is a partial request (navigation) vs document request
|
|
329
329
|
const isPartial = requestCtx.originalUrl.searchParams.has("_rsc_partial");
|
|
330
330
|
|
|
331
|
+
if (INTERNAL_RANGO_DEBUG) {
|
|
332
|
+
debugCacheLog(
|
|
333
|
+
`[CacheScope] cacheRoute: scheduling waitUntil for ${key} (${nonLoaderSegments.length} segments, isPartial=${isPartial})`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
331
337
|
requestCtx.waitUntil(async () => {
|
|
338
|
+
if (INTERNAL_RANGO_DEBUG) {
|
|
339
|
+
debugCacheLog(
|
|
340
|
+
`[CacheScope] waitUntil: awaiting handleStore.settled for ${key}`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
332
344
|
await handleStore.settled;
|
|
333
345
|
|
|
334
|
-
|
|
335
|
-
|
|
346
|
+
if (INTERNAL_RANGO_DEBUG) {
|
|
347
|
+
debugCacheLog(`[CacheScope] waitUntil: handleStore settled for ${key}`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// For document requests: only cache if layout segments have components
|
|
351
|
+
// (complete render). Parallel and route segments may legitimately have
|
|
352
|
+
// null components — UI-less @meta parallels return null, and void route
|
|
353
|
+
// handlers produce null when the UI lives in parallel slots/layouts.
|
|
354
|
+
// Partial requests always allow null components (client already has them).
|
|
336
355
|
if (!isPartial) {
|
|
337
|
-
const
|
|
338
|
-
(s) => s.component
|
|
356
|
+
const hasIncompleteLayouts = nonLoaderSegments.some(
|
|
357
|
+
(s) => s.component === null && s.type === "layout",
|
|
339
358
|
);
|
|
340
|
-
if (
|
|
359
|
+
if (hasIncompleteLayouts) {
|
|
360
|
+
const nullSegments = nonLoaderSegments
|
|
361
|
+
.filter((s) => s.component === null && s.type === "layout")
|
|
362
|
+
.map((s) => s.id);
|
|
363
|
+
const error = new Error(
|
|
364
|
+
`[CacheScope] Cache write skipped: layout segments have null components ` +
|
|
365
|
+
`(${nullSegments.join(", ")}). This indicates an incomplete render — ` +
|
|
366
|
+
`layout handlers must return JSX for document requests to be cacheable.`,
|
|
367
|
+
);
|
|
368
|
+
error.name = "CacheScopeInvariantError";
|
|
369
|
+
console.error(error.message);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
341
372
|
}
|
|
342
373
|
|
|
343
374
|
// Collect handle data for non-loader segments only
|
|
344
375
|
const handles = captureHandles(nonLoaderSegments, handleStore);
|
|
345
376
|
|
|
346
377
|
try {
|
|
378
|
+
if (INTERNAL_RANGO_DEBUG) {
|
|
379
|
+
debugCacheLog(
|
|
380
|
+
`[CacheScope] waitUntil: serializing ${nonLoaderSegments.length} segments for ${key}`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
347
384
|
// Serialize non-loader segments only
|
|
348
385
|
const serializedSegments = await serializeSegments(nonLoaderSegments);
|
|
349
386
|
|
|
@@ -353,6 +390,10 @@ export class CacheScope {
|
|
|
353
390
|
expiresAt: Date.now() + ttl * 1000,
|
|
354
391
|
};
|
|
355
392
|
|
|
393
|
+
if (INTERNAL_RANGO_DEBUG) {
|
|
394
|
+
debugCacheLog(`[CacheScope] waitUntil: calling store.set for ${key}`);
|
|
395
|
+
}
|
|
396
|
+
|
|
356
397
|
await store.set(key, data, ttl, swr);
|
|
357
398
|
|
|
358
399
|
if (INTERNAL_RANGO_DEBUG) {
|
package/src/cache/taint.ts
CHANGED
|
@@ -81,6 +81,61 @@ export function assertNotInsideCacheExec(
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Symbol stamped on ctx when resolving handlers inside a cache() DSL boundary.
|
|
86
|
+
* Separate from INSIDE_CACHE_EXEC ("use cache") because cache() allows
|
|
87
|
+
* ctx.set() (children are also cached) but blocks response-level side effects
|
|
88
|
+
* (headers, cookies, status) which are lost on cache hit.
|
|
89
|
+
*/
|
|
90
|
+
export const INSIDE_CACHE_SCOPE: unique symbol = Symbol.for(
|
|
91
|
+
"rango:inside-cache-scope",
|
|
92
|
+
) as any;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Mark ctx as inside a cache() scope. Must be paired with unstampCacheScope.
|
|
96
|
+
*/
|
|
97
|
+
export function stampCacheScope(obj: object): void {
|
|
98
|
+
const current = (obj as any)[INSIDE_CACHE_SCOPE] ?? 0;
|
|
99
|
+
(obj as any)[INSIDE_CACHE_SCOPE] = current + 1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Remove cache() scope mark.
|
|
104
|
+
*/
|
|
105
|
+
export function unstampCacheScope(obj: object): void {
|
|
106
|
+
const current = (obj as any)[INSIDE_CACHE_SCOPE] ?? 0;
|
|
107
|
+
if (current <= 1) {
|
|
108
|
+
delete (obj as any)[INSIDE_CACHE_SCOPE];
|
|
109
|
+
} else {
|
|
110
|
+
(obj as any)[INSIDE_CACHE_SCOPE] = current - 1;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Throw if ctx is inside a cache() DSL boundary.
|
|
116
|
+
* Call from response-level side effects (header, setCookie, setStatus, etc.)
|
|
117
|
+
* which are lost on cache hit because the handler body is skipped.
|
|
118
|
+
* ctx.set() is allowed inside cache() — children are also cached and can
|
|
119
|
+
* read the value.
|
|
120
|
+
*/
|
|
121
|
+
export function assertNotInsideCacheScope(
|
|
122
|
+
ctx: unknown,
|
|
123
|
+
methodName: string,
|
|
124
|
+
): void {
|
|
125
|
+
if (
|
|
126
|
+
ctx !== null &&
|
|
127
|
+
ctx !== undefined &&
|
|
128
|
+
typeof ctx === "object" &&
|
|
129
|
+
(INSIDE_CACHE_SCOPE as symbol) in (ctx as Record<symbol, unknown>)
|
|
130
|
+
) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`ctx.${methodName}() cannot be called inside a cache() boundary. ` +
|
|
133
|
+
`On cache hit the handler is skipped, so this side effect would be lost. ` +
|
|
134
|
+
`Move ctx.${methodName}() to a middleware or layout outside the cache() scope.`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
84
139
|
/**
|
|
85
140
|
* Brand symbol for functions wrapped by registerCachedFunction().
|
|
86
141
|
* Used at runtime to detect when a "use cache" function is misused
|
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
|
}
|
|
@@ -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
|
|
@@ -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();
|
|
@@ -298,6 +302,11 @@ export function withCacheStore<TEnv>(
|
|
|
298
302
|
} else {
|
|
299
303
|
// All segments have components - cache directly
|
|
300
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
|
+
}
|
|
301
310
|
requestCtx.waitUntil(async () => {
|
|
302
311
|
const start = performance.now();
|
|
303
312
|
await cacheScope.cacheRoute(
|
|
@@ -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"],
|
|
@@ -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
|
|
@@ -580,6 +580,13 @@ export async function resolveAllSegments<TEnv>(
|
|
|
580
580
|
} catch {}
|
|
581
581
|
|
|
582
582
|
for (const entry of entries) {
|
|
583
|
+
// Set ALS flag when entering a cache() boundary so that ctx.get()
|
|
584
|
+
// can guard non-cacheable variable reads. Also guards response-level
|
|
585
|
+
// side effects (headers.set). Persists for all descendant entries.
|
|
586
|
+
if (entry.type === "cache") {
|
|
587
|
+
const store = RSCRouterContext.getStore();
|
|
588
|
+
if (store) store.insideCacheScope = true;
|
|
589
|
+
}
|
|
583
590
|
const doneEntry = track(`segment:${entry.id}`, 1);
|
|
584
591
|
const resolvedSegments = await resolveWithErrorBoundary(
|
|
585
592
|
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
|
|
@@ -1248,6 +1249,10 @@ export async function resolveAllSegmentsWithRevalidation<TEnv>(
|
|
|
1248
1249
|
}
|
|
1249
1250
|
|
|
1250
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
|
+
}
|
|
1251
1256
|
const doneEntry = track(`segment:${entry.id}`, 1);
|
|
1252
1257
|
const resolved = await resolveWithErrorBoundary(
|
|
1253
1258
|
nonParallelEntry,
|
package/src/server/context.ts
CHANGED
|
@@ -273,6 +273,9 @@ interface HelperContext {
|
|
|
273
273
|
string,
|
|
274
274
|
import("../cache/profile-registry.js").CacheProfile
|
|
275
275
|
>;
|
|
276
|
+
/** True when resolving handlers inside a cache() DSL boundary.
|
|
277
|
+
* Read by ctx.get() to guard non-cacheable variable reads. */
|
|
278
|
+
insideCacheScope?: boolean;
|
|
276
279
|
}
|
|
277
280
|
// Use a global symbol key so the AsyncLocalStorage instance survives HMR
|
|
278
281
|
// module re-evaluation. Without this, Vite's RSC module runner may create
|
|
@@ -666,3 +669,11 @@ export function track(label: string, depth?: number): () => void {
|
|
|
666
669
|
});
|
|
667
670
|
};
|
|
668
671
|
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Check if the current execution is inside a cache() DSL boundary.
|
|
675
|
+
* Reads from the RSCRouterContext ALS store (set during segment resolution).
|
|
676
|
+
*/
|
|
677
|
+
export function isInsideCacheScope(): boolean {
|
|
678
|
+
return RSCRouterContext.getStore()?.insideCacheScope === true;
|
|
679
|
+
}
|
|
@@ -20,7 +20,13 @@ import type {
|
|
|
20
20
|
DefaultRouteName,
|
|
21
21
|
} from "../types/global-namespace.js";
|
|
22
22
|
import type { Handle } from "../handle.js";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
type ContextVar,
|
|
25
|
+
contextGet,
|
|
26
|
+
contextSet,
|
|
27
|
+
isContextVar,
|
|
28
|
+
isNonCacheable,
|
|
29
|
+
} from "../context-var.js";
|
|
24
30
|
import { createHandleStore, type HandleStore } from "./handle-store.js";
|
|
25
31
|
import { isHandle } from "../handle.js";
|
|
26
32
|
import { track, type MetricsStore } from "./context.js";
|
|
@@ -30,6 +36,7 @@ import type { Theme, ResolvedThemeConfig } from "../theme/types.js";
|
|
|
30
36
|
import { THEME_COOKIE } from "../theme/constants.js";
|
|
31
37
|
import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
|
|
32
38
|
import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
|
|
39
|
+
import { isInsideCacheScope } from "./context.js";
|
|
33
40
|
import {
|
|
34
41
|
createReverseFunction,
|
|
35
42
|
stripInternalParams,
|
|
@@ -72,8 +79,12 @@ export interface RequestContext<
|
|
|
72
79
|
};
|
|
73
80
|
/** Set a variable (shared with middleware and handlers) */
|
|
74
81
|
set: {
|
|
75
|
-
<T>(
|
|
76
|
-
|
|
82
|
+
<T>(
|
|
83
|
+
contextVar: ContextVar<T>,
|
|
84
|
+
value: T,
|
|
85
|
+
options?: { cache?: boolean },
|
|
86
|
+
): void;
|
|
87
|
+
<K extends string>(key: K, value: any, options?: { cache?: boolean }): void;
|
|
77
88
|
};
|
|
78
89
|
/**
|
|
79
90
|
* Route params (populated after route matching)
|
|
@@ -506,6 +517,18 @@ export function createRequestContext<TEnv>(
|
|
|
506
517
|
responseCookieCache = null;
|
|
507
518
|
};
|
|
508
519
|
|
|
520
|
+
// Guard: throw if a response-level side effect is called inside a cache() scope.
|
|
521
|
+
// Uses ALS to detect the scope (set during segment resolution).
|
|
522
|
+
function assertNotInsideCacheScopeALS(methodName: string): void {
|
|
523
|
+
if (isInsideCacheScope()) {
|
|
524
|
+
throw new Error(
|
|
525
|
+
`ctx.${methodName}() cannot be called inside a cache() boundary. ` +
|
|
526
|
+
`On cache hit the handler is skipped, so this side effect would be lost. ` +
|
|
527
|
+
`Move ctx.${methodName}() to a middleware or layout outside the cache() scope.`,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
509
532
|
// Effective cookie read: response stub Set-Cookie wins, then original header.
|
|
510
533
|
// The stub IS the source of truth for same-request mutations.
|
|
511
534
|
const effectiveCookie = (name: string): string | undefined => {
|
|
@@ -570,11 +593,19 @@ export function createRequestContext<TEnv>(
|
|
|
570
593
|
pathname: url.pathname,
|
|
571
594
|
searchParams: cleanUrl.searchParams,
|
|
572
595
|
var: variables,
|
|
573
|
-
get: ((keyOrVar: any) =>
|
|
574
|
-
|
|
575
|
-
|
|
596
|
+
get: ((keyOrVar: any) => {
|
|
597
|
+
if (isNonCacheable(variables, keyOrVar) && isInsideCacheScope()) {
|
|
598
|
+
throw new Error(
|
|
599
|
+
`ctx.get() for a non-cacheable variable cannot be called inside a cache() boundary. ` +
|
|
600
|
+
`The variable was created with { cache: false } or set with { cache: false }, ` +
|
|
601
|
+
`and its value would be stale on cache hit. Move the read outside the cached scope.`,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
return contextGet(variables, keyOrVar);
|
|
605
|
+
}) as RequestContext<TEnv>["get"],
|
|
606
|
+
set: ((keyOrVar: any, value: any, options?: any) => {
|
|
576
607
|
assertNotInsideCacheExec(ctx, "set");
|
|
577
|
-
contextSet(variables, keyOrVar, value);
|
|
608
|
+
contextSet(variables, keyOrVar, value, options);
|
|
578
609
|
}) as RequestContext<TEnv>["set"],
|
|
579
610
|
params: {} as Record<string, string>,
|
|
580
611
|
|
|
@@ -612,6 +643,7 @@ export function createRequestContext<TEnv>(
|
|
|
612
643
|
|
|
613
644
|
setCookie(name: string, value: string, options?: CookieOptions): void {
|
|
614
645
|
assertNotInsideCacheExec(ctx, "setCookie");
|
|
646
|
+
assertNotInsideCacheScopeALS("setCookie");
|
|
615
647
|
stubResponse.headers.append(
|
|
616
648
|
"Set-Cookie",
|
|
617
649
|
serializeCookieValue(name, value, options),
|
|
@@ -624,6 +656,7 @@ export function createRequestContext<TEnv>(
|
|
|
624
656
|
options?: Pick<CookieOptions, "domain" | "path">,
|
|
625
657
|
): void {
|
|
626
658
|
assertNotInsideCacheExec(ctx, "deleteCookie");
|
|
659
|
+
assertNotInsideCacheScopeALS("deleteCookie");
|
|
627
660
|
stubResponse.headers.append(
|
|
628
661
|
"Set-Cookie",
|
|
629
662
|
serializeCookieValue(name, "", { ...options, maxAge: 0 }),
|
|
@@ -633,11 +666,13 @@ export function createRequestContext<TEnv>(
|
|
|
633
666
|
|
|
634
667
|
header(name: string, value: string): void {
|
|
635
668
|
assertNotInsideCacheExec(ctx, "header");
|
|
669
|
+
assertNotInsideCacheScopeALS("header");
|
|
636
670
|
stubResponse.headers.set(name, value);
|
|
637
671
|
},
|
|
638
672
|
|
|
639
673
|
setStatus(status: number): void {
|
|
640
674
|
assertNotInsideCacheExec(ctx, "setStatus");
|
|
675
|
+
assertNotInsideCacheScopeALS("setStatus");
|
|
641
676
|
stubResponse = new Response(null, {
|
|
642
677
|
status,
|
|
643
678
|
headers: stubResponse.headers,
|
|
@@ -676,6 +711,7 @@ export function createRequestContext<TEnv>(
|
|
|
676
711
|
|
|
677
712
|
onResponse(callback: (response: Response) => Response): void {
|
|
678
713
|
assertNotInsideCacheExec(ctx, "onResponse");
|
|
714
|
+
assertNotInsideCacheScopeALS("onResponse");
|
|
679
715
|
this._onResponseCallbacks.push(callback);
|
|
680
716
|
},
|
|
681
717
|
|
|
@@ -272,8 +272,16 @@ export type HandlerContext<
|
|
|
272
272
|
* ```
|
|
273
273
|
*/
|
|
274
274
|
set: {
|
|
275
|
-
<T>(
|
|
276
|
-
|
|
275
|
+
<T>(
|
|
276
|
+
contextVar: ContextVar<T>,
|
|
277
|
+
value: T,
|
|
278
|
+
options?: { cache?: boolean },
|
|
279
|
+
): void;
|
|
280
|
+
} & ((
|
|
281
|
+
key: keyof DefaultVars,
|
|
282
|
+
value: DefaultVars[keyof DefaultVars],
|
|
283
|
+
options?: { cache?: boolean },
|
|
284
|
+
) => void);
|
|
277
285
|
/**
|
|
278
286
|
* Response headers. Headers set here are merged into the final response.
|
|
279
287
|
*
|