@rangojs/router 0.0.0-experimental.debug-cache-fix → 0.0.0-experimental.dfdb0387

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 (115) hide show
  1. package/README.md +76 -18
  2. package/dist/bin/rango.js +130 -47
  3. package/dist/vite/index.js +702 -231
  4. package/package.json +2 -2
  5. package/skills/cache-guide/SKILL.md +32 -0
  6. package/skills/caching/SKILL.md +8 -0
  7. package/skills/links/SKILL.md +3 -1
  8. package/skills/loader/SKILL.md +53 -43
  9. package/skills/middleware/SKILL.md +2 -0
  10. package/skills/prerender/SKILL.md +110 -68
  11. package/skills/route/SKILL.md +31 -0
  12. package/skills/router-setup/SKILL.md +87 -2
  13. package/skills/typesafety/SKILL.md +10 -0
  14. package/src/__internal.ts +1 -1
  15. package/src/browser/app-version.ts +14 -0
  16. package/src/browser/navigation-bridge.ts +16 -3
  17. package/src/browser/navigation-client.ts +98 -46
  18. package/src/browser/navigation-store.ts +43 -8
  19. package/src/browser/partial-update.ts +32 -5
  20. package/src/browser/prefetch/cache.ts +16 -6
  21. package/src/browser/prefetch/fetch.ts +52 -6
  22. package/src/browser/prefetch/queue.ts +61 -29
  23. package/src/browser/prefetch/resource-ready.ts +77 -0
  24. package/src/browser/react/Link.tsx +67 -8
  25. package/src/browser/react/NavigationProvider.tsx +13 -4
  26. package/src/browser/react/context.ts +7 -2
  27. package/src/browser/react/use-handle.ts +9 -58
  28. package/src/browser/react/use-router.ts +21 -8
  29. package/src/browser/rsc-router.tsx +26 -3
  30. package/src/browser/scroll-restoration.ts +10 -8
  31. package/src/browser/segment-reconciler.ts +26 -0
  32. package/src/browser/server-action-bridge.ts +8 -6
  33. package/src/browser/types.ts +27 -5
  34. package/src/build/generate-manifest.ts +6 -6
  35. package/src/build/generate-route-types.ts +3 -0
  36. package/src/build/route-types/include-resolution.ts +8 -1
  37. package/src/build/route-types/router-processing.ts +211 -72
  38. package/src/build/route-types/scan-filter.ts +8 -1
  39. package/src/cache/cache-scope.ts +12 -14
  40. package/src/cache/taint.ts +55 -0
  41. package/src/client.tsx +2 -56
  42. package/src/context-var.ts +72 -2
  43. package/src/handle.ts +40 -0
  44. package/src/index.rsc.ts +3 -1
  45. package/src/index.ts +12 -0
  46. package/src/prerender/store.ts +5 -4
  47. package/src/prerender.ts +138 -77
  48. package/src/reverse.ts +22 -1
  49. package/src/route-definition/dsl-helpers.ts +42 -19
  50. package/src/route-definition/helpers-types.ts +10 -6
  51. package/src/route-definition/index.ts +3 -0
  52. package/src/route-definition/redirect.ts +9 -1
  53. package/src/route-definition/resolve-handler-use.ts +149 -0
  54. package/src/route-types.ts +11 -0
  55. package/src/router/content-negotiation.ts +100 -1
  56. package/src/router/handler-context.ts +79 -23
  57. package/src/router/intercept-resolution.ts +9 -4
  58. package/src/router/loader-resolution.ts +156 -21
  59. package/src/router/match-api.ts +124 -189
  60. package/src/router/match-middleware/cache-lookup.ts +26 -7
  61. package/src/router/match-middleware/segment-resolution.ts +53 -0
  62. package/src/router/match-result.ts +82 -4
  63. package/src/router/middleware-types.ts +6 -8
  64. package/src/router/middleware.ts +2 -5
  65. package/src/router/navigation-snapshot.ts +182 -0
  66. package/src/router/prerender-match.ts +110 -10
  67. package/src/router/preview-match.ts +30 -102
  68. package/src/router/request-classification.ts +310 -0
  69. package/src/router/route-snapshot.ts +245 -0
  70. package/src/router/router-interfaces.ts +36 -4
  71. package/src/router/router-options.ts +37 -11
  72. package/src/router/segment-resolution/fresh.ts +80 -9
  73. package/src/router/segment-resolution/helpers.ts +29 -24
  74. package/src/router/segment-resolution/revalidation.ts +91 -8
  75. package/src/router/types.ts +1 -0
  76. package/src/router.ts +54 -5
  77. package/src/rsc/handler.ts +472 -372
  78. package/src/rsc/loader-fetch.ts +23 -3
  79. package/src/rsc/manifest-init.ts +5 -1
  80. package/src/rsc/progressive-enhancement.ts +14 -2
  81. package/src/rsc/rsc-rendering.ts +10 -1
  82. package/src/rsc/server-action.ts +8 -0
  83. package/src/rsc/ssr-setup.ts +2 -2
  84. package/src/rsc/types.ts +9 -1
  85. package/src/server/context.ts +50 -1
  86. package/src/server/handle-store.ts +19 -0
  87. package/src/server/loader-registry.ts +9 -8
  88. package/src/server/request-context.ts +175 -15
  89. package/src/ssr/index.tsx +3 -0
  90. package/src/static-handler.ts +18 -6
  91. package/src/types/cache-types.ts +4 -4
  92. package/src/types/handler-context.ts +37 -19
  93. package/src/types/loader-types.ts +36 -9
  94. package/src/types/route-entry.ts +1 -1
  95. package/src/types/segments.ts +1 -0
  96. package/src/urls/path-helper-types.ts +9 -2
  97. package/src/urls/path-helper.ts +47 -12
  98. package/src/urls/pattern-types.ts +12 -0
  99. package/src/urls/response-types.ts +16 -6
  100. package/src/use-loader.tsx +77 -5
  101. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  102. package/src/vite/discovery/discover-routers.ts +5 -1
  103. package/src/vite/discovery/prerender-collection.ts +128 -74
  104. package/src/vite/discovery/state.ts +13 -4
  105. package/src/vite/index.ts +4 -0
  106. package/src/vite/plugin-types.ts +60 -5
  107. package/src/vite/plugins/expose-id-utils.ts +12 -0
  108. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  109. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  110. package/src/vite/plugins/performance-tracks.ts +88 -0
  111. package/src/vite/plugins/refresh-cmd.ts +88 -26
  112. package/src/vite/rango.ts +19 -2
  113. package/src/vite/router-discovery.ts +178 -37
  114. package/src/vite/utils/prerender-utils.ts +18 -0
  115. package/src/vite/utils/shared-utils.ts +3 -2
@@ -168,8 +168,19 @@ export async function handleLoaderFetch<TEnv>(
168
168
  loaderResult: unknown;
169
169
  }
170
170
  const loaderPayload: LoaderPayload = { loaderResult: result };
171
- const rscStream =
172
- ctx.renderToReadableStream<LoaderPayload>(loaderPayload);
171
+ const rscStream = ctx.renderToReadableStream<LoaderPayload>(
172
+ loaderPayload,
173
+ {
174
+ onError: (error: unknown) => {
175
+ ctx.callOnError(error, "rendering", {
176
+ request,
177
+ url,
178
+ env,
179
+ loaderName: loaderId,
180
+ });
181
+ },
182
+ },
183
+ );
173
184
 
174
185
  return createResponseWithMergedHeaders(rscStream, {
175
186
  headers: { "content-type": "text/x-component;charset=utf-8" },
@@ -199,7 +210,16 @@ export async function handleLoaderFetch<TEnv>(
199
210
  name: err.name,
200
211
  },
201
212
  };
202
- const rscStream = ctx.renderToReadableStream(errorPayload);
213
+ const rscStream = ctx.renderToReadableStream(errorPayload, {
214
+ onError: (error: unknown) => {
215
+ ctx.callOnError(error, "rendering", {
216
+ request,
217
+ url,
218
+ env,
219
+ loaderName: loaderId,
220
+ });
221
+ },
222
+ });
203
223
 
204
224
  return createResponseWithMergedHeaders(rscStream, {
205
225
  status: 500,
@@ -31,7 +31,11 @@ export async function buildRouterTrieFromUrlpatterns(
31
31
  ): Promise<void> {
32
32
  const { generateManifestFull } =
33
33
  await import("../build/generate-manifest.js");
34
- const generated = generateManifestFull(router.urlpatterns);
34
+ const generated = generateManifestFull(
35
+ router.urlpatterns,
36
+ undefined,
37
+ router.basename ? { urlPrefix: router.basename } : undefined,
38
+ );
35
39
  if (
36
40
  generated._routeAncestry &&
37
41
  Object.keys(generated._routeAncestry).length > 0
@@ -243,6 +243,8 @@ export async function handleProgressiveEnhancement<TEnv>(
243
243
  const payload: RscPayload = {
244
244
  metadata: {
245
245
  pathname: url.pathname,
246
+ routerId: ctx.router.id,
247
+ basename: ctx.router.basename,
246
248
  segments: match.segments,
247
249
  matched: match.matched,
248
250
  diff: match.diff,
@@ -257,7 +259,11 @@ export async function handleProgressiveEnhancement<TEnv>(
257
259
  formState: actionResult,
258
260
  };
259
261
 
260
- const rscStream = ctx.renderToReadableStream<RscPayload>(payload);
262
+ const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
263
+ onError: (error: unknown) => {
264
+ ctx.callOnError(error, "rendering", { request, url, env });
265
+ },
266
+ });
261
267
  // metricsStore=undefined is safe: the handler already stashed the early
262
268
  // SSR setup promise on request variables, so getSSRSetup returns it
263
269
  // without falling back to a fresh startSSRSetup.
@@ -342,6 +348,8 @@ async function renderPeErrorBoundary<TEnv>(
342
348
  const payload: RscPayload = {
343
349
  metadata: {
344
350
  pathname: url.pathname,
351
+ routerId: ctx.router.id,
352
+ basename: ctx.router.basename,
345
353
  segments: errorResult.segments,
346
354
  matched: errorResult.matched,
347
355
  diff: errorResult.diff,
@@ -356,7 +364,11 @@ async function renderPeErrorBoundary<TEnv>(
356
364
  },
357
365
  };
358
366
 
359
- const rscStream = ctx.renderToReadableStream<RscPayload>(payload);
367
+ const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
368
+ onError: (error: unknown) => {
369
+ ctx.callOnError(error, "rendering", { request, url, env });
370
+ },
371
+ });
360
372
  // metricsStore=undefined is safe: the handler already stashed the early
361
373
  // SSR setup promise on request variables, so getSSRSetup returns it
362
374
  // without falling back to a fresh startSSRSetup.
@@ -54,6 +54,8 @@ export async function handleRscRendering<TEnv>(
54
54
  payload = {
55
55
  metadata: {
56
56
  pathname: url.pathname,
57
+ routerId: ctx.router.id,
58
+ basename: ctx.router.basename,
57
59
  segments: match.segments,
58
60
  matched: match.matched,
59
61
  diff: match.diff,
@@ -75,6 +77,7 @@ export async function handleRscRendering<TEnv>(
75
77
  payload = {
76
78
  metadata: {
77
79
  pathname: url.pathname,
80
+ routerId: ctx.router.id,
78
81
  segments: result.segments,
79
82
  matched: result.matched,
80
83
  diff: result.diff,
@@ -136,6 +139,8 @@ export async function handleRscRendering<TEnv>(
136
139
 
137
140
  metadata: {
138
141
  pathname: url.pathname,
142
+ routerId: ctx.router.id,
143
+ basename: ctx.router.basename,
139
144
  segments: match.segments,
140
145
  matched: match.matched,
141
146
  diff: match.diff,
@@ -168,7 +173,11 @@ export async function handleRscRendering<TEnv>(
168
173
 
169
174
  // Serialize to RSC stream
170
175
  const rscSerializeStart = performance.now();
171
- const rscStream = ctx.renderToReadableStream<RscPayload>(payload);
176
+ const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
177
+ onError: (error: unknown) => {
178
+ ctx.callOnError(error, "rendering", { request, url, env });
179
+ },
180
+ });
172
181
  const rscSerializeDur = performance.now() - rscSerializeStart;
173
182
  // This measures synchronous stream creation, not end-to-end stream consumption.
174
183
  appendMetric(
@@ -208,6 +208,7 @@ export async function executeServerAction<TEnv>(
208
208
  const payload: RscPayload = {
209
209
  metadata: {
210
210
  pathname: url.pathname,
211
+ routerId: ctx.router.id,
211
212
  segments: errorResult.segments,
212
213
  isPartial: true,
213
214
  matched: errorResult.matched,
@@ -225,6 +226,9 @@ export async function executeServerAction<TEnv>(
225
226
 
226
227
  const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
227
228
  temporaryReferences,
229
+ onError: (error: unknown) => {
230
+ ctx.callOnError(error, "rendering", { request, url, env });
231
+ },
228
232
  });
229
233
 
230
234
  return createResponseWithMergedHeaders(rscStream, {
@@ -314,6 +318,7 @@ export async function revalidateAfterAction<TEnv>(
314
318
  const payload: RscPayload = {
315
319
  metadata: {
316
320
  pathname: url.pathname,
321
+ routerId: ctx.router.id,
317
322
  segments: matchResult.segments,
318
323
  isPartial: true,
319
324
  matched: matchResult.matched,
@@ -330,6 +335,9 @@ export async function revalidateAfterAction<TEnv>(
330
335
  const renderStart = performance.now();
331
336
  const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
332
337
  temporaryReferences,
338
+ onError: (error: unknown) => {
339
+ ctx.callOnError(error, "rendering", { request, url, env });
340
+ },
333
341
  });
334
342
  const rscSerializeDur = performance.now() - renderStart;
335
343
  // This measures synchronous stream creation, not end-to-end stream consumption.
@@ -77,7 +77,7 @@ export function getSSRSetup<TEnv>(
77
77
  url: URL,
78
78
  metricsStore: MetricsStore | undefined,
79
79
  ): Promise<SSRSetup> {
80
- const early = _getRequestContext()?.var?.[SSR_SETUP_VAR] as
80
+ const early = _getRequestContext()?._variables?.[SSR_SETUP_VAR] as
81
81
  | Promise<SSRSetup>
82
82
  | undefined;
83
83
  if (early) return early;
@@ -98,7 +98,7 @@ export function getSSRSetup<TEnv>(
98
98
  * the isRscRequest decision in rsc-rendering.ts.
99
99
  *
100
100
  * Note: response/mime routes are excluded by the caller — this function
101
- * runs after previewMatch() classifies the route type.
101
+ * runs after classifyRequest() determines the request mode.
102
102
  */
103
103
  export function mayNeedSSR(request: Request, url: URL): boolean {
104
104
  if (
package/src/rsc/types.ts CHANGED
@@ -19,6 +19,9 @@ export interface RscPayload {
19
19
  metadata?: {
20
20
  pathname: string;
21
21
  segments: ResolvedSegment[];
22
+ /** Router instance ID. When this changes between navigations, the client
23
+ * discards cached segments and does a full tree replacement (app switch). */
24
+ routerId?: string;
22
25
  isPartial?: boolean;
23
26
  isError?: boolean;
24
27
  matched?: string[];
@@ -38,6 +41,8 @@ export interface RscPayload {
38
41
  themeConfig?: ResolvedThemeConfig | null;
39
42
  /** Initial theme from cookie (for SSR hydration) */
40
43
  initialTheme?: Theme;
44
+ /** URL prefix for all routes (from createRouter({ basename })). */
45
+ basename?: string;
41
46
  /** Whether connection warmup is enabled */
42
47
  warmupEnabled?: boolean;
43
48
  /** Server-side redirect with optional state (for partial requests) */
@@ -63,7 +68,10 @@ export interface RSCDependencies {
63
68
  */
64
69
  renderToReadableStream: <T>(
65
70
  payload: T,
66
- options?: { temporaryReferences?: unknown },
71
+ options?: {
72
+ temporaryReferences?: unknown;
73
+ onError?: (error: unknown) => string | void;
74
+ },
67
75
  ) => ReadableStream<Uint8Array>;
68
76
 
69
77
  /**
@@ -191,8 +191,12 @@ export type EntryData =
191
191
  /** Original PrerenderHandlerDefinition (for build-time getParams access) */
192
192
  prerenderDef?: {
193
193
  getParams?: (ctx: any) => Promise<any[]> | any[];
194
- options?: { passthrough?: boolean };
194
+ options?: { concurrency?: number };
195
195
  };
196
+ /** Set when route is wrapped with Passthrough() — has a separate live handler */
197
+ isPassthrough?: true;
198
+ /** Live handler for runtime fallback (only set on Passthrough routes) */
199
+ liveHandler?: Handler<any, any, any>;
196
200
  /** Set when handler is a Static definition (build-time only) */
197
201
  isStaticPrerender?: true;
198
202
  /** Static handler $$id for build-time store lookup */
@@ -273,6 +277,9 @@ interface HelperContext {
273
277
  string,
274
278
  import("../cache/profile-registry.js").CacheProfile
275
279
  >;
280
+ /** True when resolving handlers inside a cache() DSL boundary.
281
+ * Read by ctx.get() to guard non-cacheable variable reads. */
282
+ insideCacheScope?: boolean;
276
283
  }
277
284
  // Use a global symbol key so the AsyncLocalStorage instance survives HMR
278
285
  // module re-evaluation. Without this, Vite's RSC module runner may create
@@ -666,3 +673,45 @@ export function track(label: string, depth?: number): () => void {
666
673
  });
667
674
  };
668
675
  }
676
+
677
+ /**
678
+ * Separate ALS for tracking loader execution scope.
679
+ * Uses a dedicated ALS (not RSCRouterContext) to avoid issues with
680
+ * nested RSCRouterContext.run() calls in Vite's module runner.
681
+ */
682
+ const LOADER_SCOPE_KEY = Symbol.for("rangojs-router:loader-scope");
683
+ const loaderScopeALS: AsyncLocalStorage<{ active: true }> = ((
684
+ globalThis as any
685
+ )[LOADER_SCOPE_KEY] ??= new AsyncLocalStorage<{ active: true }>());
686
+
687
+ /**
688
+ * Check if the current execution is inside a cache() DSL boundary.
689
+ * Returns false inside loader execution — loaders are always fresh
690
+ * (never cached), so non-cacheable reads are safe.
691
+ */
692
+ export function isInsideCacheScope(): boolean {
693
+ if (RSCRouterContext.getStore()?.insideCacheScope !== true) return false;
694
+ // Loaders are always fresh — even inside a cache() boundary, the loader
695
+ // function re-executes on every request. Skip the guard when running
696
+ // inside a loader.
697
+ if (loaderScopeALS.getStore()?.active) return false;
698
+ return true;
699
+ }
700
+
701
+ /**
702
+ * Check if the current execution is inside a DSL loader scope
703
+ * (wrapped by runInsideLoaderScope). Used by rendered() barrier
704
+ * to distinguish DSL loaders from handler-invoked loaders.
705
+ */
706
+ export function isInsideLoaderScope(): boolean {
707
+ return loaderScopeALS.getStore()?.active === true;
708
+ }
709
+
710
+ /**
711
+ * Run `fn` inside a loader scope. While active, cache-scope guards
712
+ * are bypassed because loaders are always fresh (never cached) and
713
+ * their side effects (setCookie, header, etc.) are safe.
714
+ */
715
+ export function runInsideLoaderScope<T>(fn: () => T): T {
716
+ return loaderScopeALS.run({ active: true }, fn);
717
+ }
@@ -13,6 +13,25 @@
13
13
  */
14
14
  export type HandleData = Record<string, Record<string, unknown[]>>;
15
15
 
16
+ /**
17
+ * Build a HandleData snapshot from a HandleStore using segment ordering.
18
+ * Reads data directly from the store for each segment in order.
19
+ */
20
+ export function buildHandleSnapshot(
21
+ handleStore: HandleStore,
22
+ segmentOrder: string[],
23
+ ): HandleData {
24
+ const data: HandleData = {};
25
+ for (const segmentId of segmentOrder) {
26
+ const segData = handleStore.getDataForSegment(segmentId);
27
+ for (const handleName in segData) {
28
+ if (!data[handleName]) data[handleName] = {};
29
+ data[handleName][segmentId] = segData[handleName];
30
+ }
31
+ }
32
+ return data;
33
+ }
34
+
16
35
  function createLateHandlePushError(
17
36
  handleName: string,
18
37
  segmentId: string,
@@ -44,20 +44,21 @@ export function setLoaderImports(
44
44
  export async function getLoaderLazy(
45
45
  id: string,
46
46
  ): Promise<LoaderRegistryEntry | undefined> {
47
- // Check if already cached in main registry
48
- const existing = loaderRegistry.get(id);
49
- if (existing) {
50
- return existing;
51
- }
52
-
53
- // Check the fetchable loader registry (populated by createLoader)
47
+ // Always check fetchableLoaderRegistry first it's the source of truth.
48
+ // createLoader() updates it during module re-evaluation (HMR), so checking
49
+ // here ensures we pick up the fresh function after a loader file change.
54
50
  const fetchable = getFetchableLoader(id);
55
51
  if (fetchable) {
56
- // Cache in main registry for future requests
57
52
  loaderRegistry.set(id, fetchable);
58
53
  return fetchable;
59
54
  }
60
55
 
56
+ // Fall back to local cache (populated by previous lazy imports in production)
57
+ const existing = loaderRegistry.get(id);
58
+ if (existing) {
59
+ return existing;
60
+ }
61
+
61
62
  // Try to lazy load from the import map (production mode)
62
63
  if (lazyLoaderImports && lazyLoaderImports.size > 0) {
63
64
  const lazyImport = lazyLoaderImports.get(id);
@@ -20,8 +20,18 @@ 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";
24
- import { createHandleStore, type HandleStore } from "./handle-store.js";
23
+ import {
24
+ type ContextVar,
25
+ contextGet,
26
+ contextSet,
27
+ isNonCacheable,
28
+ } from "../context-var.js";
29
+ import {
30
+ createHandleStore,
31
+ buildHandleSnapshot,
32
+ type HandleStore,
33
+ type HandleData,
34
+ } from "./handle-store.js";
25
35
  import { isHandle } from "../handle.js";
26
36
  import { track, type MetricsStore } from "./context.js";
27
37
  import { getFetchableLoader } from "./fetchable-loader-store.js";
@@ -30,6 +40,7 @@ import type { Theme, ResolvedThemeConfig } from "../theme/types.js";
30
40
  import { THEME_COOKIE } from "../theme/constants.js";
31
41
  import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
32
42
  import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
43
+ import { isInsideCacheScope } from "./context.js";
33
44
  import {
34
45
  createReverseFunction,
35
46
  stripInternalParams,
@@ -63,8 +74,8 @@ export interface RequestContext<
63
74
  pathname: string;
64
75
  /** URL search params (with internal `_rsc*` params stripped, same as `url.searchParams`) */
65
76
  searchParams: URLSearchParams;
66
- /** Variables set by middleware (same as ctx.var) */
67
- var: Record<string, any>;
77
+ /** @internal Shared variable backing store for ctx.get()/ctx.set(). */
78
+ _variables: Record<string, any>;
68
79
  /** Get a variable set by middleware */
69
80
  get: {
70
81
  <T>(contextVar: ContextVar<T>): T | undefined;
@@ -72,8 +83,12 @@ export interface RequestContext<
72
83
  };
73
84
  /** Set a variable (shared with middleware and handlers) */
74
85
  set: {
75
- <T>(contextVar: ContextVar<T>, value: T): void;
76
- <K extends string>(key: K, value: any): void;
86
+ <T>(
87
+ contextVar: ContextVar<T>,
88
+ value: T,
89
+ options?: { cache?: boolean },
90
+ ): void;
91
+ <K extends string>(key: K, value: any, options?: { cache?: boolean }): void;
77
92
  };
78
93
  /**
79
94
  * Route params (populated after route matching)
@@ -261,6 +276,54 @@ export interface RequestContext<
261
276
  /** @internal Previous route key (from the navigation source), used for revalidation */
262
277
  _prevRouteKey?: string;
263
278
 
279
+ /**
280
+ * @internal Render barrier for experimental `rendered()` API.
281
+ * Resolves when all non-loader segments have settled and handle data
282
+ * is available. Used by DSL loaders that call `ctx.rendered()`.
283
+ */
284
+ _renderBarrier: Promise<void>;
285
+
286
+ /**
287
+ * @internal Resolve the render barrier. Accepts resolved segments, filters
288
+ * out loaders, and captures non-loader segment IDs as the handle ordering.
289
+ * Called after segment resolution (fresh) or handle replay (cache/prerender).
290
+ */
291
+ _resolveRenderBarrier: (
292
+ segments: Array<{ type: string; id: string }>,
293
+ ) => void;
294
+
295
+ /**
296
+ * @internal Segment order at barrier resolution time, used by loader
297
+ * ctx.use(handle) to collect handle data in correct order.
298
+ */
299
+ _renderBarrierSegmentOrder?: string[];
300
+
301
+ /**
302
+ * @internal Set to true when the matched entry tree contains any `loading()`
303
+ * entries (streaming). Used by rendered() to fail fast.
304
+ */
305
+ _treeHasStreaming?: boolean;
306
+
307
+ /**
308
+ * @internal Loader IDs that have called rendered() and are waiting for the
309
+ * barrier. Used to detect deadlocks when a handler tries to await the same
310
+ * loader via ctx.use(Loader).
311
+ */
312
+ _renderBarrierWaiters?: Set<string>;
313
+
314
+ /**
315
+ * @internal Loader IDs that handlers have started awaiting via ctx.use().
316
+ * Used for bidirectional deadlock detection: if a loader later calls
317
+ * rendered() and a handler already awaits it, we can detect the deadlock.
318
+ */
319
+ _handlerLoaderDeps?: Set<string>;
320
+
321
+ /**
322
+ * @internal Cached HandleData snapshot built at barrier resolution time.
323
+ * Avoids rebuilding the snapshot on every loader ctx.use(handle) call.
324
+ */
325
+ _renderBarrierHandleSnapshot?: HandleData;
326
+
264
327
  /** @internal Per-request error dedup set for onError reporting */
265
328
  _reportedErrors: WeakSet<object>;
266
329
 
@@ -277,6 +340,15 @@ export interface RequestContext<
277
340
 
278
341
  /** @internal Request-scoped performance metrics store */
279
342
  _metricsStore?: MetricsStore;
343
+
344
+ /** @internal Router basename for this request (used by redirect()) */
345
+ _basename?: string;
346
+
347
+ /**
348
+ * @internal RouteSnapshot from classifyRequest, reused by match/matchPartial
349
+ * to avoid a second resolveRoute call. Cleared on HMR invalidation.
350
+ */
351
+ _classifiedRoute?: import("../router/route-snapshot.js").RouteSnapshot;
280
352
  }
281
353
 
282
354
  /**
@@ -303,10 +375,20 @@ export type PublicRequestContext<
303
375
  | "_routeName"
304
376
  | "_prevRouteKey"
305
377
  | "_reportedErrors"
378
+ | "_renderBarrier"
379
+ | "_resolveRenderBarrier"
380
+ | "_renderBarrierSegmentOrder"
381
+ | "_treeHasStreaming"
382
+ | "_renderBarrierWaiters"
383
+ | "_handlerLoaderDeps"
384
+ | "_renderBarrierHandleSnapshot"
306
385
  | "_reportBackgroundError"
307
386
  | "_debugPerformance"
308
387
  | "_metricsStore"
388
+ | "_basename"
309
389
  | "_setStatus"
390
+ | "_variables"
391
+ | "_classifiedRoute"
310
392
  | "res"
311
393
  >;
312
394
 
@@ -506,6 +588,18 @@ export function createRequestContext<TEnv>(
506
588
  responseCookieCache = null;
507
589
  };
508
590
 
591
+ // Guard: throw if a response-level side effect is called inside a cache() scope.
592
+ // Uses ALS to detect the scope (set during segment resolution).
593
+ function assertNotInsideCacheScopeALS(methodName: string): void {
594
+ if (isInsideCacheScope()) {
595
+ throw new Error(
596
+ `ctx.${methodName}() cannot be called inside a cache() boundary. ` +
597
+ `On cache hit the handler is skipped, so this side effect would be lost. ` +
598
+ `Move ctx.${methodName}() to a middleware or layout outside the cache() scope.`,
599
+ );
600
+ }
601
+ }
602
+
509
603
  // Effective cookie read: response stub Set-Cookie wins, then original header.
510
604
  // The stub IS the source of truth for same-request mutations.
511
605
  const effectiveCookie = (name: string): string | undefined => {
@@ -569,12 +663,20 @@ export function createRequestContext<TEnv>(
569
663
  originalUrl: new URL(request.url),
570
664
  pathname: url.pathname,
571
665
  searchParams: cleanUrl.searchParams,
572
- var: variables,
573
- get: ((keyOrVar: any) =>
574
- contextGet(variables, keyOrVar)) as RequestContext<TEnv>["get"],
575
- set: ((keyOrVar: any, value: any) => {
666
+ _variables: variables,
667
+ get: ((keyOrVar: any) => {
668
+ if (isNonCacheable(variables, keyOrVar) && isInsideCacheScope()) {
669
+ throw new Error(
670
+ `ctx.get() for a non-cacheable variable cannot be called inside a cache() boundary. ` +
671
+ `The variable was created with { cache: false } or set with { cache: false }, ` +
672
+ `and its value would be stale on cache hit. Move the read outside the cached scope.`,
673
+ );
674
+ }
675
+ return contextGet(variables, keyOrVar);
676
+ }) as RequestContext<TEnv>["get"],
677
+ set: ((keyOrVar: any, value: any, options?: any) => {
576
678
  assertNotInsideCacheExec(ctx, "set");
577
- contextSet(variables, keyOrVar, value);
679
+ contextSet(variables, keyOrVar, value, options);
578
680
  }) as RequestContext<TEnv>["set"],
579
681
  params: {} as Record<string, string>,
580
682
 
@@ -612,6 +714,7 @@ export function createRequestContext<TEnv>(
612
714
 
613
715
  setCookie(name: string, value: string, options?: CookieOptions): void {
614
716
  assertNotInsideCacheExec(ctx, "setCookie");
717
+ assertNotInsideCacheScopeALS("setCookie");
615
718
  stubResponse.headers.append(
616
719
  "Set-Cookie",
617
720
  serializeCookieValue(name, value, options),
@@ -624,6 +727,7 @@ export function createRequestContext<TEnv>(
624
727
  options?: Pick<CookieOptions, "domain" | "path">,
625
728
  ): void {
626
729
  assertNotInsideCacheExec(ctx, "deleteCookie");
730
+ assertNotInsideCacheScopeALS("deleteCookie");
627
731
  stubResponse.headers.append(
628
732
  "Set-Cookie",
629
733
  serializeCookieValue(name, "", { ...options, maxAge: 0 }),
@@ -633,11 +737,13 @@ export function createRequestContext<TEnv>(
633
737
 
634
738
  header(name: string, value: string): void {
635
739
  assertNotInsideCacheExec(ctx, "header");
740
+ assertNotInsideCacheScopeALS("header");
636
741
  stubResponse.headers.set(name, value);
637
742
  },
638
743
 
639
744
  setStatus(status: number): void {
640
745
  assertNotInsideCacheExec(ctx, "setStatus");
746
+ assertNotInsideCacheScopeALS("setStatus");
641
747
  stubResponse = new Response(null, {
642
748
  status,
643
749
  headers: stubResponse.headers,
@@ -676,6 +782,7 @@ export function createRequestContext<TEnv>(
676
782
 
677
783
  onResponse(callback: (response: Response) => Response): void {
678
784
  assertNotInsideCacheExec(ctx, "onResponse");
785
+ assertNotInsideCacheScopeALS("onResponse");
679
786
  this._onResponseCallbacks.push(callback);
680
787
  },
681
788
 
@@ -703,9 +810,58 @@ export function createRequestContext<TEnv>(
703
810
  _reportedErrors: new WeakSet<object>(),
704
811
  _metricsStore: undefined,
705
812
 
813
+ // Render barrier: deferred promise resolved after non-loader segments settle.
814
+ _renderBarrier: null as any, // set below
815
+ _resolveRenderBarrier: null as any, // set below
816
+ _renderBarrierSegmentOrder: undefined,
817
+
706
818
  reverse: createReverseFunction(getGlobalRouteMap(), undefined, {}),
707
819
  };
708
820
 
821
+ // Lazy render barrier: only allocate the Promise when a loader actually
822
+ // calls rendered(). Requests that don't use rendered() pay zero cost.
823
+ let barrierResolved = false;
824
+ let resolveBarrier: (() => void) | undefined;
825
+ ctx._renderBarrier = null as any; // lazy — created on first access
826
+ ctx._resolveRenderBarrier = (
827
+ segments: Array<{ type: string; id: string }>,
828
+ ) => {
829
+ if (barrierResolved) return;
830
+ barrierResolved = true;
831
+ const segOrder = segments
832
+ .filter((s) => s.type !== "loader")
833
+ .map((s) => s.id);
834
+ ctx._renderBarrierSegmentOrder = segOrder;
835
+ // Build and cache handle snapshot so loader ctx.use(handle) calls
836
+ // don't rebuild it on every invocation.
837
+ ctx._renderBarrierHandleSnapshot = buildHandleSnapshot(
838
+ handleStore,
839
+ segOrder,
840
+ );
841
+ ctx._renderBarrierWaiters = undefined;
842
+ ctx._handlerLoaderDeps = undefined;
843
+ if (resolveBarrier) resolveBarrier();
844
+ };
845
+ Object.defineProperty(ctx, "_renderBarrier", {
846
+ get() {
847
+ // Barrier already resolved (cache/prerender hit) or first lazy access.
848
+ // Either way, replace the getter with a concrete value to avoid
849
+ // repeated Promise.resolve() allocations on subsequent reads.
850
+ const p = barrierResolved
851
+ ? Promise.resolve()
852
+ : new Promise<void>((resolve) => {
853
+ resolveBarrier = resolve;
854
+ });
855
+ Object.defineProperty(ctx, "_renderBarrier", {
856
+ value: p,
857
+ writable: false,
858
+ configurable: false,
859
+ });
860
+ return p;
861
+ },
862
+ configurable: true,
863
+ });
864
+
709
865
  // Now create use() with access to ctx
710
866
  ctx.use = createUseFunction({
711
867
  handleStore,
@@ -888,14 +1044,13 @@ export function createUseFunction<TEnv>(
888
1044
  pathname: ctx.pathname,
889
1045
  url: ctx.url,
890
1046
  env: ctx.env as any,
891
- var: ctx.var as any,
892
1047
  get: ctx.get as any,
893
- use: <TDep, TDepParams = any>(
1048
+ use: (<TDep, TDepParams = any>(
894
1049
  dep: LoaderDefinition<TDep, TDepParams>,
895
1050
  ): Promise<TDep> => {
896
1051
  // Recursive call - will start dep loader if not already started
897
1052
  return ctx.use(dep);
898
- },
1053
+ }) as LoaderContext["use"],
899
1054
  method: "GET",
900
1055
  body: undefined,
901
1056
  reverse: createReverseFunction(
@@ -904,9 +1059,14 @@ export function createUseFunction<TEnv>(
904
1059
  ctx.params as Record<string, string>,
905
1060
  ctx._routeName ? isRouteRootScoped(ctx._routeName) : undefined,
906
1061
  ),
1062
+ rendered: () => {
1063
+ throw new Error(
1064
+ `ctx.rendered() is only available in DSL loaders (registered via loader() in urls()). ` +
1065
+ `It cannot be used from request-context loaders or server actions.`,
1066
+ );
1067
+ },
907
1068
  };
908
1069
 
909
- // Start loader execution with tracking
910
1070
  const doneLoader = track(`loader:${loader.$$id}`, 2);
911
1071
  const promise = Promise.resolve(loaderFn(loaderCtx)).finally(() => {
912
1072
  doneLoader();