@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
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Client-side debug channel for React Performance Tracks.
3
+ *
4
+ * Creates a bidirectional channel that communicates with the server-side
5
+ * debug channel via Vite's HMR WebSocket. Used with createFromFetch()
6
+ * so Chrome DevTools can display Server Components in the Performance tab.
7
+ *
8
+ * Dev-only — gated behind import.meta.hot.
9
+ */
10
+
11
+ export const DEBUG_ID_HEADER = "X-RSC-Debug-Id";
12
+ const DEBUG_S2C_EVENT = "rango:perf-s2c";
13
+ const DEBUG_C2S_EVENT = "rango:perf-c2s";
14
+
15
+ type DebugPayload =
16
+ | { i: string; b: string } // chunk (base64)
17
+ | { i: string; d: true }; // done
18
+
19
+ const bytesToBase64 = (bytes: Uint8Array) => {
20
+ let binary = "";
21
+ for (let i = 0; i < bytes.length; i++) {
22
+ binary += String.fromCharCode(bytes[i]!);
23
+ }
24
+ return btoa(binary);
25
+ };
26
+
27
+ const base64ToBytes = (base64: string) =>
28
+ Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
29
+
30
+ /**
31
+ * Create a client-side debug channel for the given debugId.
32
+ * The channel communicates with the server via Vite's HMR WebSocket.
33
+ */
34
+ export function createClientDebugChannel(debugId: string): {
35
+ readable: ReadableStream<Uint8Array>;
36
+ writable: WritableStream<Uint8Array>;
37
+ } | null {
38
+ const hot = (import.meta as any).hot;
39
+ if (!hot) return null;
40
+
41
+ let closed = false;
42
+ let onServerData: ((payload: DebugPayload) => void) | undefined;
43
+
44
+ const cleanup = (notify?: boolean) => {
45
+ if (closed) return;
46
+ closed = true;
47
+ if (onServerData) {
48
+ hot.off(DEBUG_S2C_EVENT, onServerData);
49
+ }
50
+ if (notify) {
51
+ hot.send(DEBUG_C2S_EVENT, { i: debugId, d: true } satisfies DebugPayload);
52
+ }
53
+ };
54
+
55
+ // Readable: receives server-to-client debug data via HMR WS
56
+ const readable = new ReadableStream<Uint8Array>({
57
+ start(controller) {
58
+ onServerData = (payload: DebugPayload) => {
59
+ if (closed || payload.i !== debugId) return;
60
+ if ("b" in payload) {
61
+ controller.enqueue(base64ToBytes(payload.b));
62
+ }
63
+ if ("d" in payload) {
64
+ cleanup();
65
+ controller.close();
66
+ }
67
+ };
68
+ hot.on(DEBUG_S2C_EVENT, onServerData);
69
+ },
70
+ cancel() {
71
+ cleanup(true);
72
+ },
73
+ });
74
+
75
+ // Writable: sends client-to-server commands via HMR WS
76
+ const writable = new WritableStream<Uint8Array>({
77
+ write(chunk) {
78
+ if (closed) throw new TypeError("Channel is closed");
79
+ hot.send(DEBUG_C2S_EVENT, {
80
+ i: debugId,
81
+ b: bytesToBase64(chunk),
82
+ } satisfies DebugPayload);
83
+ },
84
+ close() {
85
+ cleanup(true);
86
+ },
87
+ abort() {
88
+ cleanup(true);
89
+ },
90
+ });
91
+
92
+ return { readable, writable };
93
+ }
@@ -12,6 +12,8 @@ import {
12
12
  startBrowserTransaction,
13
13
  } from "./logging.js";
14
14
  import { getRangoState } from "./rango-state.js";
15
+ import { createClientDebugChannel, DEBUG_ID_HEADER } from "./debug-channel.js";
16
+ import { findSourceMapURL } from "../deps/browser.js";
15
17
  import {
16
18
  extractRscHeaderUrl,
17
19
  emptyResponse,
@@ -107,6 +109,14 @@ export function createNavigationClient(
107
109
  resolveStreamComplete = resolve;
108
110
  });
109
111
 
112
+ // Dev-only: create debug channel for React Performance Tracks
113
+ const debugId = (import.meta as any).hot
114
+ ? crypto.randomUUID()
115
+ : undefined;
116
+ const debugChannel = debugId
117
+ ? createClientDebugChannel(debugId)
118
+ : undefined;
119
+
110
120
  /** Start a fresh navigation fetch (no cache / inflight hit). */
111
121
  const doFreshFetch = (): Promise<Response> => {
112
122
  if (tx) {
@@ -124,6 +134,7 @@ export function createNavigationClient(
124
134
  "X-RSC-Router-Intercept-Source": interceptSourceUrl,
125
135
  }),
126
136
  ...(hmr && { "X-RSC-HMR": "1" }),
137
+ ...(debugId && { [DEBUG_ID_HEADER]: debugId }),
127
138
  },
128
139
  signal,
129
140
  }).then((response) => {
@@ -220,7 +231,12 @@ export function createNavigationClient(
220
231
 
221
232
  try {
222
233
  // Deserialize RSC payload
223
- const payload = await deps.createFromFetch<RscPayload>(responsePromise);
234
+ const payload = await deps.createFromFetch<RscPayload>(
235
+ responsePromise,
236
+ {
237
+ ...(debugChannel && { debugChannel, findSourceMapURL }),
238
+ },
239
+ );
224
240
  if (tx) {
225
241
  browserDebugLog(tx, "response received", {
226
242
  isPartial: payload.metadata?.isPartial,
@@ -259,6 +259,17 @@ export function createPartialUpdater(
259
259
  existingSegments,
260
260
  );
261
261
 
262
+ // Fix: tx.commit() cached the source page's handleData because
263
+ // eventController hasn't been updated yet. Overwrite with the
264
+ // correct cached handleData to prevent cache corruption on
265
+ // subsequent navigations to this same URL.
266
+ if (mode.targetCacheHandleData) {
267
+ store.updateCacheHandleData(
268
+ store.getHistoryKey(),
269
+ mode.targetCacheHandleData,
270
+ );
271
+ }
272
+
262
273
  // Include cachedHandleData in metadata so NavigationProvider can restore
263
274
  // breadcrumbs and other handle data from cache.
264
275
  // Remove `handles` from metadata to prevent NavigationProvider from
@@ -5,21 +5,19 @@
5
5
  * Hover prefetches bypass this queue — they fire directly for immediate response
6
6
  * to user intent.
7
7
  *
8
- * Draining is deferred to the next animation frame so prefetch network activity
9
- * never blocks paint. This applies to both the initial batch and subsequent
10
- * batches every drain cycle yields to the browser first.
8
+ * Draining waits for an idle main-thread moment and for viewport images to
9
+ * finish loading, so prefetch fetch() calls never compete with critical
10
+ * resources for the browser's connection pool.
11
11
  *
12
12
  * When a navigation starts, queued prefetches are cancelled but executing ones
13
13
  * are left running. Navigation can reuse their in-flight responses via the
14
14
  * prefetch cache's inflight promise map, avoiding duplicate requests.
15
15
  */
16
16
 
17
- const MAX_CONCURRENT = 2;
17
+ import { wait, waitForIdle, waitForViewportImages } from "./resource-ready.js";
18
18
 
19
- const deferToNextPaint: (fn: () => void) => void =
20
- typeof requestAnimationFrame === "function"
21
- ? requestAnimationFrame
22
- : (fn) => setTimeout(fn, 0);
19
+ const MAX_CONCURRENT = 2;
20
+ const IMAGE_WAIT_TIMEOUT = 2000;
23
21
 
24
22
  let active = 0;
25
23
  const queue: Array<{
@@ -28,8 +26,9 @@ const queue: Array<{
28
26
  }> = [];
29
27
  const queued = new Set<string>();
30
28
  const executing = new Set<string>();
31
- let abortController: AbortController | null = null;
29
+ const abortControllers = new Map<string, AbortController>();
32
30
  let drainScheduled = false;
31
+ let drainGeneration = 0;
33
32
 
34
33
  function startExecution(
35
34
  key: string,
@@ -37,8 +36,10 @@ function startExecution(
37
36
  ): void {
38
37
  active++;
39
38
  executing.add(key);
40
- abortController ??= new AbortController();
41
- execute(abortController.signal).finally(() => {
39
+ const ac = new AbortController();
40
+ abortControllers.set(key, ac);
41
+ execute(ac.signal).finally(() => {
42
+ abortControllers.delete(key);
42
43
  // Only decrement if this key wasn't already cleared by cancelAllPrefetches.
43
44
  // Without this guard, cancelled tasks' .finally() would underflow active
44
45
  // below zero, breaking the MAX_CONCURRENT guarantee.
@@ -50,18 +51,32 @@ function startExecution(
50
51
  }
51
52
 
52
53
  /**
53
- * Schedule a drain on the next animation frame.
54
- * Coalesces multiple drain requests into a single rAF callback so
55
- * batch completion doesn't schedule redundant frames.
54
+ * Schedule a drain after the browser is idle and viewport images are loaded.
55
+ * Coalesces multiple drain requests into a single deferred callback so
56
+ * batch completion doesn't schedule redundant waits.
57
+ *
58
+ * The two-step wait ensures prefetch fetch() calls don't compete with
59
+ * images for the browser's connection pool:
60
+ * 1. waitForIdle — yield until the main thread has a quiet moment
61
+ * 2. waitForViewportImages OR 2s timeout — yield until visible images
62
+ * finish loading, but don't let slow/broken images block indefinitely
56
63
  */
57
64
  function scheduleDrain(): void {
58
65
  if (drainScheduled) return;
59
66
  if (active >= MAX_CONCURRENT || queue.length === 0) return;
60
67
  drainScheduled = true;
61
- deferToNextPaint(() => {
62
- drainScheduled = false;
63
- drain();
64
- });
68
+ const gen = drainGeneration;
69
+ waitForIdle()
70
+ .then(() =>
71
+ Promise.race([waitForViewportImages(), wait(IMAGE_WAIT_TIMEOUT)]),
72
+ )
73
+ .then(() => {
74
+ drainScheduled = false;
75
+ // Stale drain: a cancel/abort happened while we were waiting.
76
+ // A fresh scheduleDrain will be called by whatever enqueues next.
77
+ if (gen !== drainGeneration) return;
78
+ if (queue.length > 0) drain();
79
+ });
65
80
  }
66
81
 
67
82
  function drain(): void {
@@ -74,9 +89,10 @@ function drain(): void {
74
89
 
75
90
  /**
76
91
  * Enqueue a prefetch for concurrency-limited execution.
77
- * Execution is always deferred to the next animation frame to avoid
78
- * blocking paint, even when below the concurrency limit.
79
- * Deduplicates by key — items already queued or executing are skipped.
92
+ * Execution is deferred until the browser is idle and viewport images
93
+ * have finished loading, so prefetches never compete with critical
94
+ * resources. Deduplicates by key — items already queued or executing
95
+ * are skipped.
80
96
  *
81
97
  * The executor receives an AbortSignal that is aborted when
82
98
  * cancelAllPrefetches() is called (e.g. on navigation start).
@@ -93,19 +109,32 @@ export function enqueuePrefetch(
93
109
  }
94
110
 
95
111
  /**
96
- * Cancel queued prefetches. Executing prefetches are left running so
97
- * navigation can reuse their in-flight responses (checked via
98
- * consumeInflightPrefetch in the prefetch cache). With MAX_CONCURRENT=2
99
- * and priority: "low", in-flight prefetches don't meaningfully compete
100
- * with navigation fetches under HTTP/2 multiplexing.
112
+ * Cancel queued prefetches and abort in-flight ones that don't match
113
+ * the current navigation target. If `keepUrl` is provided, the
114
+ * executing prefetch whose key contains that URL is kept alive so
115
+ * navigation can reuse its response via consumeInflightPrefetch.
101
116
  *
102
117
  * Called when a navigation starts via the NavigationProvider's
103
118
  * event controller subscription.
104
119
  */
105
- export function cancelAllPrefetches(): void {
120
+ export function cancelAllPrefetches(keepUrl?: string | null): void {
106
121
  queue.length = 0;
107
122
  queued.clear();
108
123
  drainScheduled = false;
124
+ drainGeneration++;
125
+
126
+ // Abort in-flight prefetches that aren't for the navigation target.
127
+ // Keys use format "sourceHref\0targetPathname+search" — match the
128
+ // target portion (after \0) against keepUrl.
129
+ for (const [key, ac] of abortControllers) {
130
+ const target = key.split("\0")[1];
131
+ if (keepUrl && target && keepUrl.startsWith(target)) continue;
132
+ ac.abort();
133
+ abortControllers.delete(key);
134
+ if (executing.delete(key)) {
135
+ active--;
136
+ }
137
+ }
109
138
  }
110
139
 
111
140
  /**
@@ -114,8 +143,10 @@ export function cancelAllPrefetches(): void {
114
143
  * in-flight responses would be stale.
115
144
  */
116
145
  export function abortAllPrefetches(): void {
117
- abortController?.abort();
118
- abortController = null;
146
+ for (const ac of abortControllers.values()) {
147
+ ac.abort();
148
+ }
149
+ abortControllers.clear();
119
150
 
120
151
  queue.length = 0;
121
152
  queued.clear();
@@ -125,4 +156,5 @@ export function abortAllPrefetches(): void {
125
156
  executing.clear();
126
157
  active = 0;
127
158
  drainScheduled = false;
159
+ drainGeneration++;
128
160
  }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Resource Readiness
3
+ *
4
+ * Utilities to defer speculative prefetches until critical resources
5
+ * (viewport images) have finished loading. Prevents prefetch fetch()
6
+ * calls from competing with images for the browser's connection pool.
7
+ */
8
+
9
+ /**
10
+ * Resolve when all in-viewport images have finished loading.
11
+ * Returns immediately if no images are pending.
12
+ *
13
+ * Only checks images that exist at call time — does not observe
14
+ * dynamically added images. For SPA navigations where new images
15
+ * appear after render, call this after the navigation settles.
16
+ */
17
+ export function waitForViewportImages(): Promise<void> {
18
+ if (typeof document === "undefined") return Promise.resolve();
19
+
20
+ const pending = Array.from(document.querySelectorAll("img")).filter((img) => {
21
+ if (img.complete) return false;
22
+ const rect = img.getBoundingClientRect();
23
+ return (
24
+ rect.bottom > 0 &&
25
+ rect.right > 0 &&
26
+ rect.top < window.innerHeight &&
27
+ rect.left < window.innerWidth
28
+ );
29
+ });
30
+
31
+ if (pending.length === 0) return Promise.resolve();
32
+
33
+ return new Promise((resolve) => {
34
+ const settled = new Set<HTMLImageElement>();
35
+
36
+ const settle = (img: HTMLImageElement) => {
37
+ if (settled.has(img)) return;
38
+ settled.add(img);
39
+ if (settled.size >= pending.length) resolve();
40
+ };
41
+
42
+ for (const img of pending) {
43
+ img.addEventListener("load", () => settle(img), { once: true });
44
+ img.addEventListener("error", () => settle(img), { once: true });
45
+ // Re-check: image may have completed between the initial filter
46
+ // and listener attachment. settle() is idempotent per image, so
47
+ // a queued load event firing afterward is harmless.
48
+ if (img.complete) settle(img);
49
+ }
50
+ });
51
+ }
52
+
53
+ /**
54
+ * Resolve after the given number of milliseconds.
55
+ */
56
+ export function wait(ms: number): Promise<void> {
57
+ return new Promise((resolve) => setTimeout(resolve, ms));
58
+ }
59
+
60
+ /**
61
+ * Resolve when the browser has an idle main-thread moment.
62
+ * Uses requestIdleCallback where available, falls back to setTimeout.
63
+ *
64
+ * This is a scheduling hint, not an asset-loaded detector — combine
65
+ * with waitForViewportImages() for full resource readiness.
66
+ */
67
+ export function waitForIdle(timeout = 200): Promise<void> {
68
+ if (typeof window !== "undefined" && "requestIdleCallback" in window) {
69
+ return new Promise((resolve) => {
70
+ window.requestIdleCallback(() => resolve(), { timeout });
71
+ });
72
+ }
73
+
74
+ return new Promise((resolve) => {
75
+ setTimeout(resolve, 0);
76
+ });
77
+ }
@@ -289,15 +289,17 @@ export function NavigationProvider({
289
289
  };
290
290
  }, [warmupEnabled]);
291
291
 
292
- // Cancel speculative prefetches when navigation starts.
293
- // Viewport/render prefetches should not compete with navigation fetches.
292
+ // Cancel non-matching prefetches when navigation starts.
293
+ // Frees connections so the navigation fetch isn't competing with
294
+ // speculative prefetches. The prefetch matching the navigation target
295
+ // is kept alive so it can be reused via consumeInflightPrefetch.
294
296
  useEffect(() => {
295
297
  let wasIdle = true;
296
298
  const unsub = eventController.subscribe(() => {
297
299
  const state = eventController.getState();
298
300
  const isIdle = state.state === "idle" && !state.isStreaming;
299
301
  if (wasIdle && !isIdle) {
300
- cancelAllPrefetches();
302
+ cancelAllPrefetches(state.pendingUrl);
301
303
  }
302
304
  wasIdle = isIdle;
303
305
  });
@@ -4,6 +4,8 @@ import type {
4
4
  RscPayload,
5
5
  } from "./types.js";
6
6
  import { createPartialUpdater } from "./partial-update.js";
7
+ import { createClientDebugChannel, DEBUG_ID_HEADER } from "./debug-channel.js";
8
+ import { findSourceMapURL } from "../deps/browser.js";
7
9
  import { createNavigationTransaction } from "./navigation-transaction.js";
8
10
  import {
9
11
  reconcileSegments,
@@ -199,6 +201,14 @@ export function createServerActionBridge(
199
201
  const onHandleAbort = () => fetchAbort.abort();
200
202
  handle.signal.addEventListener("abort", onHandleAbort, { once: true });
201
203
 
204
+ // Dev-only: create debug channel for React Performance Tracks
205
+ const debugId = (import.meta as any).hot
206
+ ? crypto.randomUUID()
207
+ : undefined;
208
+ const debugChannel = debugId
209
+ ? createClientDebugChannel(debugId)
210
+ : undefined;
211
+
202
212
  // Send action request with stream tracking
203
213
  const responsePromise = fetch(url, {
204
214
  method: "POST",
@@ -210,6 +220,7 @@ export function createServerActionBridge(
210
220
  ...(interceptSourceUrl && {
211
221
  "X-RSC-Router-Intercept-Source": interceptSourceUrl,
212
222
  }),
223
+ ...(debugId && { [DEBUG_ID_HEADER]: debugId }),
213
224
  },
214
225
  body: encodedBody,
215
226
  signal: fetchAbort.signal,
@@ -272,6 +283,7 @@ export function createServerActionBridge(
272
283
  try {
273
284
  payload = await deps.createFromFetch<RscPayload>(responsePromise, {
274
285
  temporaryReferences,
286
+ ...(debugChannel && { debugChannel, findSourceMapURL }),
275
287
  });
276
288
  } catch (error) {
277
289
  // Clean up streaming token on error (may be null if fetch failed before .then() ran)
@@ -341,7 +341,14 @@ export type ReadonlyURLSearchParams = Omit<
341
341
  export interface RscBrowserDependencies {
342
342
  createFromFetch: <T>(
343
343
  response: Promise<Response>,
344
- options?: { temporaryReferences?: any },
344
+ options?: {
345
+ temporaryReferences?: any;
346
+ debugChannel?: { readable?: ReadableStream; writable?: WritableStream };
347
+ findSourceMapURL?: (
348
+ filename: string,
349
+ environmentName: string,
350
+ ) => string | null;
351
+ },
345
352
  ) => Promise<T>;
346
353
  createFromReadableStream: <T>(stream: ReadableStream) => Promise<T>;
347
354
  encodeReply: (
@@ -214,11 +214,21 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
214
214
  bgStopCapture = c.stop;
215
215
  }
216
216
 
217
- // Stamp tainted args and RequestContext so request-scoped
218
- // reads (cookies, headers) and side effects (ctx.set, etc.)
219
- // throw inside background revalidation, same as the miss path.
220
- // Uses ref-counted stamp/unstamp so overlapping executions
221
- // sharing the same ctx don't clear each other's guards.
217
+ // Stamp tainted ARGS only not requestCtx. The args stamp guards
218
+ // direct ctx method calls (ctx.set, ctx.header, ctx.onResponse, etc.)
219
+ // which is sufficient for correctness.
220
+ //
221
+ // We intentionally skip stamping requestCtx here because:
222
+ // 1. runBackground starts the async task synchronously (before the
223
+ // first await), so stampCacheExec would pollute the shared
224
+ // requestCtx while the foreground pipeline is still running.
225
+ // This causes assertNotInsideCacheExec to fire when cache-store
226
+ // later calls requestCtx.onResponse().
227
+ // 2. requestCtx methods are closure-bound to the original ctx, so
228
+ // neither Object.create() nor a proxy can isolate the stamp.
229
+ // 3. The foreground miss path already stamps requestCtx and catches
230
+ // cookies()/headers() misuse on first execution. The background
231
+ // re-runs the same function with the same request.
222
232
  const bgTaintedArgs: unknown[] = [];
223
233
  for (const arg of args) {
224
234
  if (isTainted(arg)) {
@@ -226,9 +236,6 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
226
236
  bgTaintedArgs.push(arg);
227
237
  }
228
238
  }
229
- if (requestCtx) {
230
- stampCacheExec(requestCtx as object);
231
- }
232
239
 
233
240
  try {
234
241
  const freshResult = await fn.apply(this, args);
@@ -249,9 +256,6 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
249
256
  for (const arg of bgTaintedArgs) {
250
257
  unstampCacheExec(arg as object);
251
258
  }
252
- if (requestCtx) {
253
- unstampCacheExec(requestCtx as object);
254
- }
255
259
  // Restore original handle store
256
260
  if (originalHandleStore && requestCtx) {
257
261
  requestCtx._handleStore = originalHandleStore;
@@ -328,22 +328,59 @@ export class CacheScope {
328
328
  // Check if this is a partial request (navigation) vs document request
329
329
  const isPartial = requestCtx.originalUrl.searchParams.has("_rsc_partial");
330
330
 
331
+ if (INTERNAL_RANGO_DEBUG) {
332
+ debugCacheLog(
333
+ `[CacheScope] cacheRoute: scheduling waitUntil for ${key} (${nonLoaderSegments.length} segments, isPartial=${isPartial})`,
334
+ );
335
+ }
336
+
331
337
  requestCtx.waitUntil(async () => {
338
+ if (INTERNAL_RANGO_DEBUG) {
339
+ debugCacheLog(
340
+ `[CacheScope] waitUntil: awaiting handleStore.settled for ${key}`,
341
+ );
342
+ }
343
+
332
344
  await handleStore.settled;
333
345
 
334
- // For document requests: only cache if ALL segments have components (complete render)
335
- // For partial requests: null components are expected (client already has them)
346
+ if (INTERNAL_RANGO_DEBUG) {
347
+ debugCacheLog(`[CacheScope] waitUntil: handleStore settled for ${key}`);
348
+ }
349
+
350
+ // For document requests: only cache if layout segments have components
351
+ // (complete render). Parallel and route segments may legitimately have
352
+ // null components — UI-less @meta parallels return null, and void route
353
+ // handlers produce null when the UI lives in parallel slots/layouts.
354
+ // Partial requests always allow null components (client already has them).
336
355
  if (!isPartial) {
337
- const hasAllComponents = nonLoaderSegments.every(
338
- (s) => s.component !== null,
356
+ const hasIncompleteLayouts = nonLoaderSegments.some(
357
+ (s) => s.component === null && s.type === "layout",
339
358
  );
340
- if (!hasAllComponents) return;
359
+ if (hasIncompleteLayouts) {
360
+ const nullSegments = nonLoaderSegments
361
+ .filter((s) => s.component === null && s.type === "layout")
362
+ .map((s) => s.id);
363
+ const error = new Error(
364
+ `[CacheScope] Cache write skipped: layout segments have null components ` +
365
+ `(${nullSegments.join(", ")}). This indicates an incomplete render — ` +
366
+ `layout handlers must return JSX for document requests to be cacheable.`,
367
+ );
368
+ error.name = "CacheScopeInvariantError";
369
+ console.error(error.message);
370
+ return;
371
+ }
341
372
  }
342
373
 
343
374
  // Collect handle data for non-loader segments only
344
375
  const handles = captureHandles(nonLoaderSegments, handleStore);
345
376
 
346
377
  try {
378
+ if (INTERNAL_RANGO_DEBUG) {
379
+ debugCacheLog(
380
+ `[CacheScope] waitUntil: serializing ${nonLoaderSegments.length} segments for ${key}`,
381
+ );
382
+ }
383
+
347
384
  // Serialize non-loader segments only
348
385
  const serializedSegments = await serializeSegments(nonLoaderSegments);
349
386
 
@@ -353,6 +390,10 @@ export class CacheScope {
353
390
  expiresAt: Date.now() + ttl * 1000,
354
391
  };
355
392
 
393
+ if (INTERNAL_RANGO_DEBUG) {
394
+ debugCacheLog(`[CacheScope] waitUntil: calling store.set for ${key}`);
395
+ }
396
+
356
397
  await store.set(key, data, ttl, swr);
357
398
 
358
399
  if (INTERNAL_RANGO_DEBUG) {
@@ -81,6 +81,61 @@ export function assertNotInsideCacheExec(
81
81
  }
82
82
  }
83
83
 
84
+ /**
85
+ * Symbol stamped on ctx when resolving handlers inside a cache() DSL boundary.
86
+ * Separate from INSIDE_CACHE_EXEC ("use cache") because cache() allows
87
+ * ctx.set() (children are also cached) but blocks response-level side effects
88
+ * (headers, cookies, status) which are lost on cache hit.
89
+ */
90
+ export const INSIDE_CACHE_SCOPE: unique symbol = Symbol.for(
91
+ "rango:inside-cache-scope",
92
+ ) as any;
93
+
94
+ /**
95
+ * Mark ctx as inside a cache() scope. Must be paired with unstampCacheScope.
96
+ */
97
+ export function stampCacheScope(obj: object): void {
98
+ const current = (obj as any)[INSIDE_CACHE_SCOPE] ?? 0;
99
+ (obj as any)[INSIDE_CACHE_SCOPE] = current + 1;
100
+ }
101
+
102
+ /**
103
+ * Remove cache() scope mark.
104
+ */
105
+ export function unstampCacheScope(obj: object): void {
106
+ const current = (obj as any)[INSIDE_CACHE_SCOPE] ?? 0;
107
+ if (current <= 1) {
108
+ delete (obj as any)[INSIDE_CACHE_SCOPE];
109
+ } else {
110
+ (obj as any)[INSIDE_CACHE_SCOPE] = current - 1;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Throw if ctx is inside a cache() DSL boundary.
116
+ * Call from response-level side effects (header, setCookie, setStatus, etc.)
117
+ * which are lost on cache hit because the handler body is skipped.
118
+ * ctx.set() is allowed inside cache() — children are also cached and can
119
+ * read the value.
120
+ */
121
+ export function assertNotInsideCacheScope(
122
+ ctx: unknown,
123
+ methodName: string,
124
+ ): void {
125
+ if (
126
+ ctx !== null &&
127
+ ctx !== undefined &&
128
+ typeof ctx === "object" &&
129
+ (INSIDE_CACHE_SCOPE as symbol) in (ctx as Record<symbol, unknown>)
130
+ ) {
131
+ throw new Error(
132
+ `ctx.${methodName}() cannot be called inside a cache() boundary. ` +
133
+ `On cache hit the handler is skipped, so this side effect would be lost. ` +
134
+ `Move ctx.${methodName}() to a middleware or layout outside the cache() scope.`,
135
+ );
136
+ }
137
+ }
138
+
84
139
  /**
85
140
  * Brand symbol for functions wrapped by registerCachedFunction().
86
141
  * Used at runtime to detect when a "use cache" function is misused