@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.
Files changed (49) hide show
  1. package/dist/vite/index.js +153 -5
  2. package/package.json +1 -1
  3. package/skills/cache-guide/SKILL.md +32 -0
  4. package/skills/caching/SKILL.md +8 -0
  5. package/skills/loader/SKILL.md +53 -43
  6. package/skills/parallel/SKILL.md +67 -0
  7. package/skills/route/SKILL.md +31 -0
  8. package/skills/router-setup/SKILL.md +52 -2
  9. package/skills/typesafety/SKILL.md +10 -0
  10. package/src/browser/debug-channel.ts +93 -0
  11. package/src/browser/navigation-client.ts +17 -1
  12. package/src/browser/partial-update.ts +11 -0
  13. package/src/browser/prefetch/queue.ts +61 -29
  14. package/src/browser/prefetch/resource-ready.ts +77 -0
  15. package/src/browser/react/NavigationProvider.tsx +5 -3
  16. package/src/browser/server-action-bridge.ts +12 -0
  17. package/src/browser/types.ts +8 -1
  18. package/src/cache/cache-runtime.ts +15 -11
  19. package/src/cache/cache-scope.ts +46 -5
  20. package/src/cache/taint.ts +55 -0
  21. package/src/context-var.ts +72 -2
  22. package/src/deps/browser.ts +1 -0
  23. package/src/route-definition/helpers-types.ts +6 -5
  24. package/src/router/handler-context.ts +31 -8
  25. package/src/router/loader-resolution.ts +7 -1
  26. package/src/router/match-middleware/background-revalidation.ts +12 -1
  27. package/src/router/match-middleware/cache-lookup.ts +12 -5
  28. package/src/router/match-middleware/cache-store.ts +21 -4
  29. package/src/router/match-result.ts +11 -5
  30. package/src/router/middleware-types.ts +6 -2
  31. package/src/router/middleware.ts +2 -2
  32. package/src/router/router-context.ts +1 -0
  33. package/src/router/segment-resolution/fresh.ts +12 -6
  34. package/src/router/segment-resolution/helpers.ts +29 -24
  35. package/src/router/segment-resolution/revalidation.ts +9 -2
  36. package/src/router/types.ts +1 -0
  37. package/src/router.ts +1 -0
  38. package/src/rsc/handler.ts +28 -2
  39. package/src/rsc/loader-fetch.ts +7 -2
  40. package/src/rsc/progressive-enhancement.ts +4 -1
  41. package/src/rsc/rsc-rendering.ts +4 -1
  42. package/src/rsc/server-action.ts +2 -0
  43. package/src/rsc/types.ts +7 -1
  44. package/src/server/context.ts +12 -0
  45. package/src/server/request-context.ts +49 -8
  46. package/src/types/handler-context.ts +20 -8
  47. package/src/types/loader-types.ts +4 -4
  48. package/src/vite/plugins/performance-tracks.ts +231 -0
  49. package/src/vite/rango.ts +4 -0
@@ -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
  }
@@ -5,4 +5,5 @@ export {
5
5
  setServerCallback,
6
6
  encodeReply,
7
7
  createTemporaryReferenceSet,
8
+ findSourceMapURL,
8
9
  } from "@vitejs/plugin-rsc/browser";
@@ -228,11 +228,12 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
228
228
  * revalidate(({ actionId }) => actionId?.includes("Cart") ?? false),
229
229
  * ])
230
230
  *
231
- * // Access loader data in handlers via ctx.use()
232
- * route("products.detail", async (ctx) => {
233
- * const product = await ctx.use(ProductLoader);
234
- * return <ProductPage product={product} />;
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 { 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
@@ -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 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();
@@ -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
- * - (Client already has this segment's UI)
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 with null components (client already has them)
172
- // BUT always include loader segments - they carry data even with null component
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) => s.component !== null || s.type === "loader",
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>(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"],
@@ -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
- // No per-loader timing here settlement happens asynchronously during
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 { ReactNode } from "react";
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
- if (notFoundFallback) {
185
- const notFoundInfo = createNotFoundInfo(
186
- error,
187
- entry.shortCode,
188
- entry.type,
189
- pathname,
190
- );
191
+ const notFoundInfo = createNotFoundInfo(
192
+ error,
193
+ entry.shortCode,
194
+ entry.type,
195
+ pathname,
196
+ );
191
197
 
192
- reportError(true, {
193
- notFound: true,
194
- message: notFoundInfo.message,
195
- });
198
+ reportError(true, {
199
+ notFound: true,
200
+ message: notFoundInfo.message,
201
+ });
196
202
 
197
- debugLog("segment", "notFound boundary handled error", {
198
- segmentId: entry.shortCode,
199
- message: notFoundInfo.message,
200
- });
203
+ debugLog("segment", "notFound boundary handled error", {
204
+ segmentId: entry.shortCode,
205
+ message: notFoundInfo.message,
206
+ });
201
207
 
202
- setResponseStatus(404);
208
+ setResponseStatus(404);
203
209
 
204
- return createNotFoundSegment(
205
- notFoundInfo,
206
- notFoundFallback,
207
- entry,
208
- params,
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,
@@ -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
 
package/src/router.ts CHANGED
@@ -526,6 +526,7 @@ export function createRouter<TEnv = any>(
526
526
  trackHandler,
527
527
  findNearestErrorBoundary,
528
528
  findNearestNotFoundBoundary,
529
+ notFoundComponent: notFound,
529
530
  callOnError,
530
531
  };
531
532