@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.
@@ -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.788796d8",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.788796d8",
3
+ "version": "0.0.0-experimental.78adc454",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -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:
@@ -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
- // For document requests: only cache if ALL segments have components (complete render)
335
- // For partial requests: null components are expected (client already has them)
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 hasAllComponents = nonLoaderSegments.every(
338
- (s) => s.component !== null,
356
+ const hasIncompleteLayouts = nonLoaderSegments.some(
357
+ (s) => s.component === null && s.type === "layout",
339
358
  );
340
- if (!hasAllComponents) return;
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) {
@@ -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
@@ -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 { __brand: "context-var" as const, key: Symbol() };
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 { contextGet, contextSet } from "../context-var.js";
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" functions.
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) => contextGet(variables, keyOrVar)) as HandlerContext<
249
- any,
250
- TEnv
251
- >["get"],
252
- set: ((keyOrVar: any, value: any) => {
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
- contextSet(variables, keyOrVar, value);
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 handler reads
526
- // ctx.searchParams so different ?page= values produce different content.
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
- // Search params changed — must re-render even without custom rules
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: "cached-search-changed",
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
- // This happens when client already had those segments (partial navigation)
168
+ // Check if any non-loader segments have null components from revalidation
169
+ // skip (client already had them). Segments where the handler intentionally
170
+ // returned null are not revalidation skips — re-rendering them will still
171
+ // produce null, so proactive caching would be wasted work.
172
+ const clientIdSet = new Set(ctx.clientSegmentIds);
170
173
  const hasNullComponents = allSegmentsToCache.some(
171
- (s) => s.component === null && s.type !== "loader",
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>(key: K, value: DefaultVars[K]): void;
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
  /**
@@ -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,
@@ -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 { type ContextVar, contextGet, contextSet } from "../context-var.js";
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>(contextVar: ContextVar<T>, value: T): void;
76
- <K extends string>(key: K, value: any): void;
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
- contextGet(variables, keyOrVar)) as RequestContext<TEnv>["get"],
575
- set: ((keyOrVar: any, value: any) => {
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>(contextVar: ContextVar<T>, value: T): void;
276
- } & (<K extends keyof DefaultVars>(key: K, value: DefaultVars[K]) => void);
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
  *