@rangojs/router 0.0.0-experimental.56cb65a7 → 0.0.0-experimental.57

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 (74) hide show
  1. package/dist/bin/rango.js +128 -46
  2. package/dist/vite/index.js +211 -47
  3. package/package.json +2 -2
  4. package/skills/cache-guide/SKILL.md +32 -0
  5. package/skills/caching/SKILL.md +8 -0
  6. package/skills/links/SKILL.md +3 -1
  7. package/skills/loader/SKILL.md +53 -43
  8. package/skills/middleware/SKILL.md +2 -0
  9. package/skills/parallel/SKILL.md +67 -0
  10. package/skills/route/SKILL.md +31 -0
  11. package/skills/router-setup/SKILL.md +87 -2
  12. package/skills/typesafety/SKILL.md +10 -0
  13. package/src/browser/app-version.ts +14 -0
  14. package/src/browser/navigation-bridge.ts +16 -3
  15. package/src/browser/navigation-client.ts +64 -40
  16. package/src/browser/navigation-store.ts +43 -8
  17. package/src/browser/partial-update.ts +37 -4
  18. package/src/browser/prefetch/fetch.ts +8 -2
  19. package/src/browser/prefetch/queue.ts +61 -29
  20. package/src/browser/prefetch/resource-ready.ts +77 -0
  21. package/src/browser/react/Link.tsx +44 -8
  22. package/src/browser/react/NavigationProvider.tsx +13 -4
  23. package/src/browser/react/context.ts +7 -2
  24. package/src/browser/react/use-router.ts +21 -8
  25. package/src/browser/rsc-router.tsx +26 -3
  26. package/src/browser/server-action-bridge.ts +8 -6
  27. package/src/browser/types.ts +27 -5
  28. package/src/build/generate-manifest.ts +3 -0
  29. package/src/build/generate-route-types.ts +3 -0
  30. package/src/build/route-types/include-resolution.ts +8 -1
  31. package/src/build/route-types/router-processing.ts +211 -72
  32. package/src/cache/cache-runtime.ts +15 -11
  33. package/src/cache/cache-scope.ts +46 -5
  34. package/src/cache/taint.ts +55 -0
  35. package/src/context-var.ts +72 -2
  36. package/src/route-definition/helpers-types.ts +6 -5
  37. package/src/route-definition/redirect.ts +9 -1
  38. package/src/router/handler-context.ts +36 -17
  39. package/src/router/intercept-resolution.ts +9 -4
  40. package/src/router/loader-resolution.ts +9 -2
  41. package/src/router/match-middleware/background-revalidation.ts +12 -1
  42. package/src/router/match-middleware/cache-lookup.ts +50 -7
  43. package/src/router/match-middleware/cache-store.ts +21 -4
  44. package/src/router/match-result.ts +11 -5
  45. package/src/router/middleware-types.ts +6 -8
  46. package/src/router/middleware.ts +2 -5
  47. package/src/router/prerender-match.ts +2 -2
  48. package/src/router/router-context.ts +1 -0
  49. package/src/router/router-interfaces.ts +25 -4
  50. package/src/router/router-options.ts +37 -11
  51. package/src/router/segment-resolution/fresh.ts +47 -16
  52. package/src/router/segment-resolution/helpers.ts +29 -24
  53. package/src/router/segment-resolution/revalidation.ts +50 -21
  54. package/src/router/types.ts +1 -0
  55. package/src/router.ts +41 -4
  56. package/src/rsc/handler.ts +11 -2
  57. package/src/rsc/manifest-init.ts +5 -1
  58. package/src/rsc/progressive-enhancement.ts +4 -0
  59. package/src/rsc/rsc-rendering.ts +5 -0
  60. package/src/rsc/server-action.ts +2 -0
  61. package/src/rsc/ssr-setup.ts +1 -1
  62. package/src/rsc/types.ts +8 -1
  63. package/src/server/context.ts +36 -0
  64. package/src/server/loader-registry.ts +9 -8
  65. package/src/server/request-context.ts +50 -12
  66. package/src/ssr/index.tsx +3 -0
  67. package/src/types/cache-types.ts +4 -4
  68. package/src/types/handler-context.ts +125 -31
  69. package/src/types/loader-types.ts +4 -5
  70. package/src/urls/pattern-types.ts +12 -0
  71. package/src/vite/discovery/discover-routers.ts +5 -1
  72. package/src/vite/plugins/performance-tracks.ts +88 -0
  73. package/src/vite/rango.ts +17 -1
  74. package/src/vite/utils/shared-utils.ts +3 -2
@@ -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
@@ -2,6 +2,7 @@ import type { LocationStateEntry } from "../browser/react/location-state-shared.
2
2
  import {
3
3
  requireRequestContext,
4
4
  getRequestContext,
5
+ _getRequestContext,
5
6
  } from "../server/request-context.js";
6
7
 
7
8
  /**
@@ -83,10 +84,17 @@ export function redirect(
83
84
  }
84
85
  }
85
86
 
87
+ // Auto-prefix root-relative URLs with basename for app-local redirects.
88
+ const bn = _getRequestContext()?._basename;
89
+ let resolvedUrl = url;
90
+ if (bn && url.startsWith("/") && !url.startsWith(bn + "/") && url !== bn) {
91
+ resolvedUrl = url === "/" ? bn : bn + url;
92
+ }
93
+
86
94
  return new Response(null, {
87
95
  status,
88
96
  headers: {
89
- Location: url,
97
+ Location: resolvedUrl,
90
98
  "X-RSC-Redirect": "soft",
91
99
  },
92
100
  });
@@ -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";
@@ -201,7 +207,7 @@ export function createHandlerContext<TEnv>(
201
207
  // Get variables from request context - this is the unified context
202
208
  // shared between middleware and route handlers
203
209
  const requestContext = _getRequestContext();
204
- const variables: any = requestContext?.var ?? {};
210
+ const variables: any = requestContext?._variables ?? {};
205
211
 
206
212
  // If route has a search schema, parse URLSearchParams into typed object
207
213
  const searchSchema = routeName ? getSearchSchema(routeName) : undefined;
@@ -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
  }
@@ -244,14 +257,24 @@ export function createHandlerContext<TEnv>(
244
257
  url,
245
258
  originalUrl: new URL(request.url),
246
259
  env: bindings,
247
- var: variables,
248
- get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as HandlerContext<
249
- any,
250
- TEnv
251
- >["get"],
252
- set: ((keyOrVar: any, value: any) => {
260
+ _variables: variables,
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
@@ -297,7 +320,7 @@ export function createHandlerContext<TEnv>(
297
320
  *
298
321
  * Returns an InternalHandlerContext where params, pathname, url, searchParams,
299
322
  * search, reverse, and use(handle) work. Request-time properties
300
- * (request, env, headers, cookies, var, get, set, res) throw with a clear error.
323
+ * (request, env, headers, cookies, get, set, res) throw with a clear error.
301
324
  */
302
325
  export function createPrerenderContext<TEnv>(
303
326
  params: Record<string, string>,
@@ -331,9 +354,7 @@ export function createPrerenderContext<TEnv>(
331
354
  get env(): TEnv {
332
355
  return throwUnavailable("env");
333
356
  },
334
- get var(): any {
335
- return throwUnavailable("var");
336
- },
357
+ _variables: variables,
337
358
  get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
338
359
  set: ((keyOrVar: any, value: any) => {
339
360
  contextSet(variables, keyOrVar, value);
@@ -415,9 +436,7 @@ export function createStaticContext<TEnv>(
415
436
  get env(): TEnv {
416
437
  return throwUnavailable("env");
417
438
  },
418
- get var(): any {
419
- return throwUnavailable("var");
420
- },
439
+ _variables: variables,
421
440
  get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
422
441
  set: ((keyOrVar: any, value: any) => {
423
442
  contextSet(variables, keyOrVar, value);
@@ -11,7 +11,11 @@ import type {
11
11
  InterceptEntry,
12
12
  InterceptSelectorContext,
13
13
  } from "../server/context";
14
- import type { HandlerContext, ResolvedSegment } from "../types";
14
+ import type {
15
+ HandlerContext,
16
+ InternalHandlerContext,
17
+ ResolvedSegment,
18
+ } from "../types";
15
19
  import { evaluateRevalidation } from "./revalidation.js";
16
20
  import { getRequestContext } from "../server/request-context.js";
17
21
  import { executeInterceptMiddleware } from "./middleware.js";
@@ -20,6 +24,7 @@ import { getGlobalRouteMap } from "../route-map-builder.js";
20
24
  import { handleHandlerResult } from "./segment-resolution.js";
21
25
  import type { SegmentResolutionDeps } from "./types.js";
22
26
  import { debugLog } from "./logging.js";
27
+ import { runInsideLoaderScope } from "../server/context.js";
23
28
 
24
29
  /**
25
30
  * Check if an intercept's when conditions are satisfied.
@@ -133,7 +138,7 @@ export async function resolveInterceptEntry<TEnv>(
133
138
  context.request,
134
139
  context.env,
135
140
  params,
136
- context.var as Record<string, any>,
141
+ (context as InternalHandlerContext<any, TEnv>)._variables,
137
142
  requestCtx.res,
138
143
  createReverseFunction(getGlobalRouteMap()),
139
144
  );
@@ -207,7 +212,7 @@ export async function resolveInterceptEntry<TEnv>(
207
212
  loaderIds.push(loader.$$id);
208
213
  loaderPromises.push(
209
214
  deps.wrapLoaderPromise(
210
- context.use(loader),
215
+ runInsideLoaderScope(() => context.use(loader)),
211
216
  parentEntry,
212
217
  segmentId,
213
218
  context.pathname,
@@ -374,7 +379,7 @@ export async function resolveInterceptLoadersOnly<TEnv>(
374
379
  loaderIds.push(loader.$$id);
375
380
  loaderPromises.push(
376
381
  deps.wrapLoaderPromise(
377
- context.use(loader),
382
+ runInsideLoaderScope(() => context.use(loader)),
378
383
  parentEntry,
379
384
  segmentId,
380
385
  context.pathname,
@@ -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,12 @@ function createLoaderExecutor<TEnv>(
241
242
  pendingLoaders.add(loader.$$id);
242
243
 
243
244
  const currentLoaderId = loader.$$id;
245
+ const variables = (ctx as InternalHandlerContext<any, TEnv>)._variables;
246
+ // Loader functions are always fresh (never cached), so they get an
247
+ // unguarded get that bypasses non-cacheable read guards. This applies
248
+ // to ALL loaders — DSL and handler-called — because the loader
249
+ // function itself always re-executes. Also handles nested deps
250
+ // (loaderA → use(loaderB)) since all share this unguarded get.
244
251
  const loaderCtx: LoaderContext<Record<string, string | undefined>, TEnv> = {
245
252
  params: ctx.params,
246
253
  routeParams: (ctx.params ?? {}) as Record<string, string>,
@@ -250,8 +257,8 @@ function createLoaderExecutor<TEnv>(
250
257
  pathname: ctx.pathname,
251
258
  url: ctx.url,
252
259
  env: ctx.env,
253
- var: ctx.var,
254
- get: ctx.get,
260
+ get: ((keyOrVar: any) =>
261
+ contextGet(variables, keyOrVar)) as typeof ctx.get,
255
262
  use: <TDep, TDepParams = any>(
256
263
  dep: LoaderDefinition<TDep, TDepParams>,
257
264
  ): 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
  };
@@ -70,9 +70,11 @@
70
70
  * - No segments yielded from this middleware
71
71
  *
72
72
  * Loaders:
73
- * - NEVER cached by design
73
+ * - NEVER cached in the segment cache
74
74
  * - Always resolved fresh on every request
75
75
  * - Ensures data freshness even with cached UI components
76
+ * - Segment cache staleness does NOT propagate to loader revalidation;
77
+ * loaders use their own revalidation rules (actionId, user-defined)
76
78
  *
77
79
  *
78
80
  * REVALIDATION RULES
@@ -261,7 +263,7 @@ async function* yieldFromStore<TEnv>(
261
263
  depth: 1,
262
264
  });
263
265
  ms.metrics.push({
264
- label: "pipeline:cache-lookup",
266
+ label: "pipeline:cache-hit",
265
267
  duration: loaderEnd - pipelineStart,
266
268
  startTime: pipelineStart - ms.requestStart,
267
269
  });
@@ -314,7 +316,10 @@ export function withCacheLookup<TEnv>(
314
316
 
315
317
  // Prerender lookup: check build-time cached data before runtime cache.
316
318
  // Prerender data is available regardless of runtime cache configuration.
317
- if (!ctx.isAction && ctx.matched.pr) {
319
+ // Skip for HMR requests — the dev prerender endpoint reads from a stale
320
+ // RouterRegistry snapshot; rendering fresh ensures edits are visible.
321
+ const isHmr = !!ctx.request.headers.get("X-RSC-HMR");
322
+ if (!ctx.isAction && !isHmr && ctx.matched.pr) {
318
323
  await ensurePrerenderDeps();
319
324
  if (prerenderStoreInstance) {
320
325
  const paramHash = _hashParams!(ctx.matched.params);
@@ -446,7 +451,7 @@ export function withCacheLookup<TEnv>(
446
451
  yield* source;
447
452
  if (ms) {
448
453
  ms.metrics.push({
449
- label: "pipeline:cache-lookup",
454
+ label: "pipeline:cache-miss",
450
455
  duration: performance.now() - pipelineStart,
451
456
  startTime: pipelineStart - ms.requestStart,
452
457
  });
@@ -466,7 +471,7 @@ export function withCacheLookup<TEnv>(
466
471
  yield* source;
467
472
  if (ms) {
468
473
  ms.metrics.push({
469
- label: "pipeline:cache-lookup",
474
+ label: "pipeline:cache-miss",
470
475
  duration: performance.now() - pipelineStart,
471
476
  startTime: pipelineStart - ms.requestStart,
472
477
  });
@@ -518,7 +523,41 @@ export function withCacheLookup<TEnv>(
518
523
 
519
524
  // Look up revalidation rules for this segment
520
525
  const entryInfo = entryRevalidateMap?.get(segment.id);
526
+
527
+ // Even without explicit revalidation rules, route segments and their
528
+ // children must re-render when params or search params change — the
529
+ // handler reads ctx.params/ctx.searchParams so different values produce
530
+ // different content. Matches evaluateRevalidation's default logic.
531
+ const searchChanged = ctx.prevUrl.search !== ctx.url.search;
532
+ const routeParamsChanged = !paramsEqual(
533
+ ctx.matched.params,
534
+ ctx.prevParams,
535
+ );
536
+ const shouldDefaultRevalidate =
537
+ (searchChanged || routeParamsChanged) &&
538
+ (segment.type === "route" ||
539
+ (segment.belongsToRoute &&
540
+ (segment.type === "layout" || segment.type === "parallel")));
541
+
521
542
  if (!entryInfo || entryInfo.revalidate.length === 0) {
543
+ if (shouldDefaultRevalidate) {
544
+ // Params or search params changed — must re-render even without custom rules
545
+ if (isTraceActive()) {
546
+ pushRevalidationTraceEntry({
547
+ segmentId: segment.id,
548
+ segmentType: segment.type,
549
+ belongsToRoute: segment.belongsToRoute ?? false,
550
+ source: "cache-hit",
551
+ defaultShouldRevalidate: true,
552
+ finalShouldRevalidate: true,
553
+ reason: routeParamsChanged
554
+ ? "cached-params-changed"
555
+ : "cached-search-changed",
556
+ });
557
+ }
558
+ yield segment;
559
+ continue;
560
+ }
522
561
  // No revalidation rules, use default behavior (skip if client has)
523
562
  if (isTraceActive()) {
524
563
  pushRevalidationTraceEntry({
@@ -615,7 +654,11 @@ export function withCacheLookup<TEnv>(
615
654
  ctx.url,
616
655
  ctx.routeKey,
617
656
  ctx.actionContext,
618
- cacheResult.shouldRevalidate || undefined,
657
+ // Loaders are never cached in the segment cache, so segment
658
+ // staleness (cacheResult.shouldRevalidate) must not propagate.
659
+ // But browser-sent staleness (ctx.stale) — indicating an action
660
+ // happened in this or another tab — must still reach loaders.
661
+ ctx.stale || undefined,
619
662
  ),
620
663
  );
621
664
 
@@ -642,7 +685,7 @@ export function withCacheLookup<TEnv>(
642
685
  depth: 1,
643
686
  });
644
687
  ms.metrics.push({
645
- label: "pipeline:cache-lookup",
688
+ label: "pipeline:cache-hit",
646
689
  duration: loaderEnd - pipelineStart,
647
690
  startTime: pipelineStart - ms.requestStart,
648
691
  });
@@ -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
  /**
@@ -91,12 +95,6 @@ export interface MiddlewareContext<
91
95
  /** Set a context variable (shared with route handlers) */
92
96
  set: SetVariableFn;
93
97
 
94
- /**
95
- * Middleware-injected variables.
96
- * Same shared dictionary as `ctx.get()`/`ctx.set()`.
97
- */
98
- var: DefaultVars;
99
-
100
98
  /**
101
99
  * Set a response header - can be called before or after `next()`.
102
100
  *
@@ -204,12 +204,9 @@ 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
-
211
- var: variables as MiddlewareContext<TEnv>["var"],
212
-
213
210
  header(name: string, value: string): void {
214
211
  // Before next(): delegate to shared RequestContext stub
215
212
  if (isPreNext()) {
@@ -104,7 +104,7 @@ export async function matchForPrerender<TEnv = any>(
104
104
  originalUrl: new URL("http://prerender" + pathname),
105
105
  pathname,
106
106
  searchParams: new URLSearchParams(),
107
- var: variables,
107
+ _variables: variables,
108
108
  get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
109
109
  set: ((keyOrVar: any, value: any) => {
110
110
  contextSet(variables, keyOrVar, value);
@@ -336,7 +336,7 @@ export async function renderStaticSegment<TEnv = any>(
336
336
  originalUrl: syntheticUrl,
337
337
  pathname: "/",
338
338
  searchParams: syntheticUrl.searchParams,
339
- var: {},
339
+ _variables: {},
340
340
  get: () => undefined as any,
341
341
  set: () => {},
342
342
  params: {},
@@ -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
@@ -2,6 +2,7 @@ import type { ComponentType, ReactNode } from "react";
2
2
  import type { SerializedManifest } from "../debug.js";
3
3
  import type { ReverseFunction } from "../reverse.js";
4
4
  import type { UrlPatterns } from "../urls.js";
5
+ import type { UrlBuilder } from "../urls/pattern-types.js";
5
6
  import type { EntryData } from "../server/context";
6
7
  import type { ErrorInfo, MatchResult } from "../types";
7
8
  import type { NonceProvider } from "../rsc/types.js";
@@ -68,12 +69,24 @@ export interface RSCRouter<
68
69
  readonly id: string;
69
70
 
70
71
  /**
71
- * Register routes using URL patterns from urls()
72
+ * URL prefix applied to all routes. Undefined when no basename is configured.
73
+ */
74
+ readonly basename: string | undefined;
75
+
76
+ /**
77
+ * Register routes using URL patterns from urls() or a builder function
72
78
  *
73
79
  * @example
74
80
  * ```typescript
75
- * createRouter({})
76
- * .routes(urlpatterns)
81
+ * // With urls()
82
+ * createRouter({}).routes(urlpatterns)
83
+ *
84
+ * // With builder function (urls() is implicit)
85
+ * createRouter({}).routes(({ path, layout }) => [
86
+ * layout(RootLayout, () => [
87
+ * path("/", HomePage),
88
+ * ]),
89
+ * ])
77
90
  * ```
78
91
  */
79
92
  routes<T extends UrlPatterns<TEnv, any>>(
@@ -85,6 +98,7 @@ export interface RSCRouter<
85
98
  ? MergeRoutesWithResponses<NonNullable<T["_routes"]>, T["_responses"]>
86
99
  : Record<string, string>)
87
100
  >;
101
+ routes(builder: UrlBuilder<TEnv>): RSCRouter<TEnv, TRoutes>;
88
102
 
89
103
  /**
90
104
  * Add global middleware that runs on all routes
@@ -188,8 +202,11 @@ export interface RSCRouterInternal<
188
202
  */
189
203
  readonly id: string;
190
204
 
205
+ /** URL prefix applied to all routes. */
206
+ readonly basename: string | undefined;
207
+
191
208
  /**
192
- * Register routes using URL patterns from urls()
209
+ * Register routes using URL patterns from urls() or a builder function
193
210
  */
194
211
  routes<T extends UrlPatterns<TEnv, any>>(
195
212
  patterns: T,
@@ -200,6 +217,7 @@ export interface RSCRouterInternal<
200
217
  ? MergeRoutesWithResponses<NonNullable<T["_routes"]>, T["_responses"]>
201
218
  : Record<string, string>)
202
219
  >;
220
+ routes(builder: UrlBuilder<TEnv>): RSCRouter<TEnv, TRoutes>;
203
221
 
204
222
  /**
205
223
  * Add global middleware that runs on all routes
@@ -338,6 +356,9 @@ export interface RSCRouterInternal<
338
356
  */
339
357
  readonly __sourceFile?: string;
340
358
 
359
+ /** @internal basename for runtime manifest generation */
360
+ readonly __basename?: string;
361
+
341
362
  match(
342
363
  request: Request,
343
364
  input?: RouterRequestInput<TEnv>,