@rangojs/router 0.0.0-experimental.144 → 0.0.0-experimental.146

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 (35) hide show
  1. package/dist/bin/rango.js +1 -40
  2. package/dist/vite/index.js +35 -9
  3. package/package.json +1 -1
  4. package/skills/ppr/SKILL.md +29 -23
  5. package/src/browser/logging.ts +18 -0
  6. package/src/browser/rsc-router.tsx +43 -0
  7. package/src/cache/cache-runtime.ts +41 -51
  8. package/src/cache/cache-scope.ts +30 -1
  9. package/src/cache/cf/cf-cache-store.ts +4 -0
  10. package/src/cache/handle-snapshot.ts +22 -1
  11. package/src/cache/shell-snapshot.ts +47 -0
  12. package/src/cache/types.ts +31 -4
  13. package/src/cache/vercel/vercel-cache-store.ts +6 -1
  14. package/src/deps/ssr.ts +4 -1
  15. package/src/router/loader-resolution.ts +16 -0
  16. package/src/router/match-api.ts +9 -2
  17. package/src/router/match-handlers.ts +13 -0
  18. package/src/router/segment-resolution/loader-cache.ts +19 -3
  19. package/src/router/segment-resolution/loader-mask.ts +4 -11
  20. package/src/router/segment-resolution/loader-snapshot.ts +14 -6
  21. package/src/router/segment-resolution/mask-nested.ts +99 -0
  22. package/src/rsc/rsc-rendering.ts +139 -16
  23. package/src/rsc/shell-capture.ts +122 -0
  24. package/src/rsc/shell-serve.ts +37 -6
  25. package/src/segment-loader-promise.ts +18 -0
  26. package/src/segment-system.tsx +123 -9
  27. package/src/server/request-context.ts +47 -0
  28. package/src/ssr/index.tsx +118 -18
  29. package/src/ssr/inject-rsc-eager.ts +167 -0
  30. package/src/ssr/preinit-client-references.ts +106 -0
  31. package/src/vite/index.ts +8 -0
  32. package/src/vite/plugin-types.ts +33 -0
  33. package/src/vite/plugins/virtual-entries.ts +37 -4
  34. package/src/vite/rango.ts +10 -2
  35. package/src/vite/utils/shared-utils.ts +4 -2
package/dist/bin/rango.js CHANGED
@@ -1394,7 +1394,7 @@ var init_generate_route_types = __esm({
1394
1394
  function getVirtualVersionContent(version) {
1395
1395
  return `export const VERSION = ${JSON.stringify(version)};`;
1396
1396
  }
1397
- var VIRTUAL_ENTRY_BROWSER, VIRTUAL_ENTRY_SSR, VIRTUAL_IDS;
1397
+ var VIRTUAL_ENTRY_BROWSER, VIRTUAL_IDS;
1398
1398
  var init_virtual_entries = __esm({
1399
1399
  "src/vite/plugins/virtual-entries.ts"() {
1400
1400
  "use strict";
@@ -1434,45 +1434,6 @@ async function initializeApp() {
1434
1434
  }
1435
1435
 
1436
1436
  initializeApp().catch(console.error);
1437
- `.trim();
1438
- VIRTUAL_ENTRY_SSR = `
1439
- import { createFromReadableStream } from "@rangojs/router/internal/deps/ssr";
1440
- import { renderToReadableStream, resume } from "react-dom/server.edge";
1441
- import { prerender } from "react-dom/static.edge";
1442
- import { injectRSCPayload } from "@rangojs/router/internal/deps/html-stream-server";
1443
- import {
1444
- createSSRHandler,
1445
- createShellCaptureHandler,
1446
- createShellResumeHandler,
1447
- } from "@rangojs/router/ssr";
1448
-
1449
- export const renderHTML = createSSRHandler({
1450
- createFromReadableStream,
1451
- renderToReadableStream,
1452
- injectRSCPayload,
1453
- loadBootstrapScriptContent: () =>
1454
- import.meta.viteRsc.loadBootstrapScriptContent("index"),
1455
- });
1456
-
1457
- export const captureShellHTML = createShellCaptureHandler({
1458
- createFromReadableStream,
1459
- renderToReadableStream,
1460
- injectRSCPayload,
1461
- prerender,
1462
- resume,
1463
- loadBootstrapScriptContent: () =>
1464
- import.meta.viteRsc.loadBootstrapScriptContent("index"),
1465
- });
1466
-
1467
- export const resumeShellHTML = createShellResumeHandler({
1468
- createFromReadableStream,
1469
- renderToReadableStream,
1470
- injectRSCPayload,
1471
- prerender,
1472
- resume,
1473
- loadBootstrapScriptContent: () =>
1474
- import.meta.viteRsc.loadBootstrapScriptContent("index"),
1475
- });
1476
1437
  `.trim();
1477
1438
  VIRTUAL_IDS = {
1478
1439
  browser: "virtual:rsc-router/entry.browser.js",
@@ -2210,21 +2210,35 @@ async function initializeApp() {
2210
2210
 
2211
2211
  initializeApp().catch(console.error);
2212
2212
  `.trim();
2213
- var VIRTUAL_ENTRY_SSR = `
2214
- import { createFromReadableStream } from "@rangojs/router/internal/deps/ssr";
2213
+ function getVirtualEntrySSR(headScripts = "preinit") {
2214
+ const preinit = headScripts !== "preload";
2215
+ const depsImportNames = preinit ? "createFromReadableStream,\n setOnClientReference," : "createFromReadableStream,";
2216
+ const ssrImportNames = preinit ? "\n installClientReferencePreinit," : "";
2217
+ const install = preinit ? `
2218
+ // Upgrade client-reference modulepreload hints to executing module scripts in
2219
+ // the document head, for every render pass (live SSR, shell capture, resume).
2220
+ // See src/ssr/preinit-client-references.ts for the full rationale.
2221
+ installClientReferencePreinit(setOnClientReference);
2222
+ ` : "";
2223
+ const hs = JSON.stringify(headScripts);
2224
+ return `
2225
+ import {
2226
+ ${depsImportNames}
2227
+ } from "@rangojs/router/internal/deps/ssr";
2215
2228
  import { renderToReadableStream, resume } from "react-dom/server.edge";
2216
2229
  import { prerender } from "react-dom/static.edge";
2217
2230
  import { injectRSCPayload } from "@rangojs/router/internal/deps/html-stream-server";
2218
2231
  import {
2219
2232
  createSSRHandler,
2220
2233
  createShellCaptureHandler,
2221
- createShellResumeHandler,
2234
+ createShellResumeHandler,${ssrImportNames}
2222
2235
  } from "@rangojs/router/ssr";
2223
-
2236
+ ${install}
2224
2237
  export const renderHTML = createSSRHandler({
2225
2238
  createFromReadableStream,
2226
2239
  renderToReadableStream,
2227
2240
  injectRSCPayload,
2241
+ headScripts: ${hs},
2228
2242
  loadBootstrapScriptContent: () =>
2229
2243
  import.meta.viteRsc.loadBootstrapScriptContent("index"),
2230
2244
  });
@@ -2235,6 +2249,7 @@ export const captureShellHTML = createShellCaptureHandler({
2235
2249
  injectRSCPayload,
2236
2250
  prerender,
2237
2251
  resume,
2252
+ headScripts: ${hs},
2238
2253
  loadBootstrapScriptContent: () =>
2239
2254
  import.meta.viteRsc.loadBootstrapScriptContent("index"),
2240
2255
  });
@@ -2245,10 +2260,12 @@ export const resumeShellHTML = createShellResumeHandler({
2245
2260
  injectRSCPayload,
2246
2261
  prerender,
2247
2262
  resume,
2263
+ headScripts: ${hs},
2248
2264
  loadBootstrapScriptContent: () =>
2249
2265
  import.meta.viteRsc.loadBootstrapScriptContent("index"),
2250
2266
  });
2251
2267
  `.trim();
2268
+ }
2252
2269
  var RSC_ENTRY_BOOTSTRAP_IMPORTS = [
2253
2270
  "virtual:rsc-router/routes-manifest",
2254
2271
  "virtual:rsc-router/loader-manifest"
@@ -2393,7 +2410,7 @@ import { resolve } from "node:path";
2393
2410
  // package.json
2394
2411
  var package_default = {
2395
2412
  name: "@rangojs/router",
2396
- version: "0.0.0-experimental.144",
2413
+ version: "0.0.0-experimental.146",
2397
2414
  description: "Django-inspired RSC router with composable URL patterns",
2398
2415
  keywords: [
2399
2416
  "react",
@@ -4017,13 +4034,13 @@ function normalizeHostRouterEntry(rawInput, root, exists) {
4017
4034
  }
4018
4035
  return exists(resolve4(root, raw)) ? "./" + raw : raw;
4019
4036
  }
4020
- function createVirtualEntriesPlugin(entries, routerPathRef) {
4037
+ function createVirtualEntriesPlugin(entries, routerPathRef, options) {
4021
4038
  const virtualModules = {};
4022
4039
  if (entries.client === VIRTUAL_IDS.browser) {
4023
4040
  virtualModules[VIRTUAL_IDS.browser] = VIRTUAL_ENTRY_BROWSER;
4024
4041
  }
4025
4042
  if (entries.ssr === VIRTUAL_IDS.ssr) {
4026
- virtualModules[VIRTUAL_IDS.ssr] = VIRTUAL_ENTRY_SSR;
4043
+ virtualModules[VIRTUAL_IDS.ssr] = getVirtualEntrySSR(options?.headScripts);
4027
4044
  }
4028
4045
  const knownIds = new Set(Object.keys(virtualModules));
4029
4046
  if (entries.rsc === VIRTUAL_IDS.rsc) {
@@ -7893,7 +7910,11 @@ async function rango(options) {
7893
7910
  }
7894
7911
  }
7895
7912
  });
7896
- plugins.push(createVirtualEntriesPlugin(finalEntries));
7913
+ plugins.push(
7914
+ createVirtualEntriesPlugin(finalEntries, void 0, {
7915
+ headScripts: resolvedOptions.headScripts
7916
+ })
7917
+ );
7897
7918
  plugins.push(performanceTracksPlugin());
7898
7919
  plugins.push(
7899
7920
  rsc({
@@ -8073,7 +8094,11 @@ If this is a multi-app host router, export a createHostRouter() instance and set
8073
8094
  }
8074
8095
  }
8075
8096
  });
8076
- plugins.push(createVirtualEntriesPlugin(finalEntries, routerRef));
8097
+ plugins.push(
8098
+ createVirtualEntriesPlugin(finalEntries, routerRef, {
8099
+ headScripts: resolvedOptions.headScripts
8100
+ })
8101
+ );
8077
8102
  plugins.push(performanceTracksPlugin());
8078
8103
  plugins.push(
8079
8104
  rsc({
@@ -8217,6 +8242,7 @@ function poke() {
8217
8242
  };
8218
8243
  }
8219
8244
  export {
8245
+ directoryClientChunks,
8220
8246
  poke,
8221
8247
  rango
8222
8248
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.144",
3
+ "version": "0.0.0-experimental.146",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -251,18 +251,18 @@ async function Handler(ctx: HandlerContext) {
251
251
  | already-resolved / instant / synchronous values | `loader(() => Promise.resolve(x))` + `loading()` | a raw promise that settles inside the quiet window BAKES; only the live lane guarantees live |
252
252
  | none of the above | nothing | it bakes — that is what the shell is for |
253
253
 
254
- The physics caveat in one line: promise holes are holes because the I/O is
255
- genuinely pending at capture. If the value can resolve near-instantly (memory
256
- read, warmed cache), it may bake into the shell when liveness must be
257
- guaranteed rather than probable, use the live lane (`loading()`). The same
258
- physics governs bake-lane nested promises, with one shape guarantee: a nested
259
- promise that settles inside the window pins its VALUE, but the container key
260
- KEEPS its promise shape on HITs (the snapshot rehydrates a
261
- `Promise.resolve(pinned)`), so an unconditional `use(data.x)` consumer never
262
- breaks it just reads the pinned value. Note the timing consequence: whether
263
- such a value is pinned or live can vary per capture (concurrent loader traffic
264
- extends the quiet window), so treat "fast-resolving promise on the bake lane"
265
- as PINNED for correctness purposes.
254
+ The physics caveat in one line: HANDLER-created promise props are holes only
255
+ because the I/O is genuinely pending at capture if the value can resolve
256
+ near-instantly (memory read, warmed cache), it may bake into the shell; when
257
+ liveness must be guaranteed rather than probable, use a loader. BAKE-LANE
258
+ NESTED promises are exempt from that race: the capture MASKS every thenable
259
+ nested in a bake-lane container regardless of settle timing
260
+ (`maskNestedContainerThenables`, loader-cache.ts), so the consuming boundary
261
+ always postpones as a hole and every HIT streams the FRESH value — the
262
+ promise SHAPE is the liveness declaration, not a bet on latency. (Before the
263
+ mask, a nested promise that settled inside the window pinned its capture-time
264
+ value into the shared shell; found live as a storefront basket with the
265
+ capturing session's identifiers served to anonymous visitors.)
266
266
 
267
267
  ### Handles: "nesting = liveness"
268
268
 
@@ -272,7 +272,11 @@ as PINNED for correctness purposes.
272
272
  by the capture's 5s guard).
273
273
  - `ctx.use(H)({ x: promise })` — the container passes through verbatim
274
274
  (resolution is shallow); the nested promise streams to the consumer, who must
275
- `<Suspense>` it. Under capture that boundary postpones — a hole.
275
+ `<Suspense>` it. Under capture that boundary postpones — a hole — REGARDLESS
276
+ of settle timing: the capture masks nested thenables in pushed handle
277
+ containers (the capture store's push wrap, shell-capture.ts), so even an
278
+ already-resolved nested promise holes instead of baking its value into the
279
+ shared shell. Same shape-is-the-declaration rule as bake-lane loaders.
276
280
 
277
281
  ### Want a hole for already-resolved data?
278
282
 
@@ -284,8 +288,9 @@ how fast the value settles.
284
288
 
285
289
  A loader on an entry with no renderable `loading()` EXECUTES during capture
286
290
  (the capture gate holds open for its real latency, bounded by the 5s guard).
287
- Its settled container bakes into the prelude; every promise still nested in it
288
- postpones at the consumer's own `<Suspense>` a hole. On every HIT the
291
+ Its settled container bakes into the prelude; every promise nested in it is
292
+ masked at capture (regardless of how fast it settles) and postpones at the
293
+ consumer's own `<Suspense>` — a hole. On every HIT the
289
294
  capture snapshot's loader family overlays the recorded container onto the
290
295
  fresh run, so the payload matches the frozen prelude byte-for-byte while the
291
296
  nested promises run fresh. The return shape is the declaration:
@@ -552,21 +557,22 @@ evicted by tag at all — move always-fresh data under a `loading()` hole.
552
557
  - **The session-object bake trap (the guard cannot save you here)**: the
553
558
  capture guard sees `cookies()`/`headers()` calls ONLY. A bake-lane loader
554
559
  reading a middleware-provided session object (`ctx.get("session")`) refuses
555
- nothing and its FAST-RESOLVE branch is the killer:
560
+ nothing. Per-user data survives ONLY behind a nested promise — the shape is
561
+ the declaration, and it holds for BOTH branches regardless of settle timing
562
+ (nested thenables are masked at capture):
556
563
 
557
564
  ```typescript
558
565
  const CartLoader = createLoader(async (ctx) => {
559
566
  const basketId = ctx.get("session")!.get("basketId");
560
- if (!basketId) return { cart: Promise.resolve(null) }; // SETTLEDBAKES
561
- return { cart: fetchBasket(basketId) }; // pending → hole
567
+ if (!basketId) return { cart: Promise.resolve(null) }; // nested thenable masked → hole, fresh per HIT
568
+ return { cart: fetchBasket(basketId) }; // nested thenable masked → hole, fresh per HIT
562
569
  });
563
570
  ```
564
571
 
565
- If the capturing request is anonymous (it usually is), `cart: null` bakes
566
- and is snapshot-pinned: every logged-in user gets the anonymous badge on
567
- every HIT. The branch asymmetry makes it nondeterministic per capture. Any
568
- loader whose data is per-user belongs on the live lane — for a header
569
- widget, a parallel slot with its own `loading()` (playbook lever 3).
572
+ The remaining trap is returning per-user data as PLAIN container material:
573
+ `return { user: session.user }` bakes it into the shared shell like any
574
+ other settled value deterministically, not by race. Wrap it in a promise
575
+ (even an already-resolved one) or put the loader on the live lane.
570
576
 
571
577
  - **Theme on a HIT is capture-then-corrected**: the resume tree replays the
572
578
  CAPTURE's `initialTheme` (resume requires it to match the frozen prelude);
@@ -63,3 +63,21 @@ export function debugLog(msg: string, ...args: unknown[]): void {
63
63
  console.log(msg, ...args);
64
64
  }
65
65
  }
66
+
67
+ /**
68
+ * Boot-sequence debug log: one line per initial-document step (flight decode,
69
+ * handle stream, bridge wiring, initial tree build, hydration commit), each
70
+ * stamped with performance.now() so the gap BEFORE hydrateRoot is visible.
71
+ * The initial document path was otherwise silent — FE debug only started
72
+ * talking at the first soft navigation, so a boot stall (e.g. an await that
73
+ * holds initBrowserApp, and with it hydrateRoot) was invisible.
74
+ */
75
+ export function bootLog(step: string, details?: Record<string, unknown>): void {
76
+ if (!INTERNAL_RANGO_DEBUG) return;
77
+ const prefix = `[Browser][boot] ${step} @ ${Math.round(performance.now())}ms`;
78
+ if (details) {
79
+ console.log(prefix, details);
80
+ return;
81
+ }
82
+ console.log(prefix);
83
+ }
@@ -33,6 +33,7 @@ import {
33
33
  splitInterceptSegments,
34
34
  } from "./intercept-utils.js";
35
35
  import { createAppShellRef } from "./app-shell.js";
36
+ import { bootLog, IS_BROWSER_DEBUG } from "./logging.js";
36
37
 
37
38
  // Vite HMR types are provided by vite/client
38
39
 
@@ -156,6 +157,8 @@ export async function initBrowserApp(
156
157
  initialTheme,
157
158
  } = options;
158
159
 
160
+ bootLog("initBrowserApp start");
161
+ bootLog("flight decode: awaiting initial payload from document stream");
159
162
  const initialPayload =
160
163
  await deps.createFromReadableStream<RscPayload>(rscStream);
161
164
 
@@ -169,6 +172,14 @@ export async function initBrowserApp(
169
172
  // Get initial segments and compute history key from current URL
170
173
  const initialSegments = (initialPayload.metadata?.segments ??
171
174
  []) as ResolvedSegment[];
175
+ if (IS_BROWSER_DEBUG) {
176
+ bootLog("initial payload decoded", {
177
+ version: initialPayload.metadata?.version,
178
+ routerId: initialPayload.metadata?.routerId,
179
+ segments: initialSegments.map((s) => s.id),
180
+ matched: initialPayload.metadata?.matched,
181
+ });
182
+ }
172
183
  const initialHistoryKey = generateHistoryKey(window.location.href);
173
184
 
174
185
  // Create navigation store with history-based caching
@@ -207,11 +218,24 @@ export async function initBrowserApp(
207
218
  // This ensures useHandle returns correct data during hydration to avoid mismatch
208
219
  // The handles property is an async generator that yields on each push
209
220
  if (initialPayload.metadata?.handles) {
221
+ // This for-await consumes the handle generator to completion BEFORE
222
+ // hydrateRoot is called — on a streaming/PPR document the generator only
223
+ // ends when its stream side does, so the per-push logs below are the
224
+ // primary probe for "the document render is holding hydration".
225
+ bootLog("handles: consuming payload handle stream (pre-hydration await)");
210
226
  const handlesGenerator = initialPayload.metadata.handles;
211
227
  let lastHandleData: Record<string, Record<string, unknown[]>> = {};
228
+ let handlePushes = 0;
212
229
  for await (const handleData of handlesGenerator) {
213
230
  lastHandleData = handleData;
231
+ if (IS_BROWSER_DEBUG) {
232
+ handlePushes += 1;
233
+ bootLog(`handles: push #${handlePushes}`, {
234
+ segments: Object.keys(handleData),
235
+ });
236
+ }
214
237
  }
238
+ bootLog("handles: stream complete", { pushes: handlePushes });
215
239
  // Initialize event controller with initial handle state before hydration.
216
240
  eventController.setHandleData(
217
241
  lastHandleData,
@@ -221,6 +245,8 @@ export async function initBrowserApp(
221
245
  // Update the initial cache entry with the processed handleData
222
246
  // The cache entry was created by createNavigationStore but without handleData
223
247
  store.updateCacheHandleData(initialHistoryKey, lastHandleData);
248
+ } else {
249
+ bootLog("handles: none in payload");
224
250
  }
225
251
 
226
252
  // Create composable utilities
@@ -321,9 +347,17 @@ export async function initBrowserApp(
321
347
  if (linkInterception) {
322
348
  navigationBridge.registerLinkInterception();
323
349
  }
350
+ bootLog("bridges registered (action + navigation)");
324
351
 
325
352
  // Build initial tree with rootLayout
353
+ bootLog("building initial segment tree (renderSegments)");
326
354
  const initialTree = renderSegments(initialPayload.metadata!.segments);
355
+ if (IS_BROWSER_DEBUG && initialTree instanceof Promise) {
356
+ initialTree.then(
357
+ () => bootLog("initial segment tree settled"),
358
+ (err: unknown) => bootLog("initial segment tree rejected", { err }),
359
+ );
360
+ }
327
361
 
328
362
  // Setup HMR with debounce — burst saves (format-on-save, rapid edits)
329
363
  // fire many rsc:update events in quick succession. Without debouncing,
@@ -491,9 +525,14 @@ export async function initBrowserApp(
491
525
  };
492
526
  browserAppContext = context;
493
527
 
528
+ bootLog("initBrowserApp complete -- handing off to hydrateRoot");
494
529
  return context;
495
530
  }
496
531
 
532
+ // Once-flag so the hydration-commit boot log fires a single time (StrictMode
533
+ // re-runs the root effect; the second flush is not a second hydration).
534
+ let hydrationCommitLogged = false;
535
+
497
536
  /**
498
537
  * Get the browser app context. Throws if initBrowserApp hasn't been called.
499
538
  */
@@ -561,6 +600,10 @@ export function Rango(_props: RangoProps): React.ReactElement {
561
600
  // that does not depend on React internals like __reactFiber.
562
601
  React.useEffect(() => {
563
602
  document.documentElement.dataset.hydrated = "";
603
+ if (IS_BROWSER_DEBUG && !hydrationCommitLogged) {
604
+ hydrationCommitLogged = true;
605
+ bootLog("hydration commit (root effect flushed)");
606
+ }
564
607
  }, []);
565
608
 
566
609
  return (
@@ -412,26 +412,26 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
412
412
  try {
413
413
  const result = await serveCached(cached);
414
414
  // Background revalidation — must capture handles if tainted args present.
415
- // Use an isolated handle store so background pushes don't pollute the
416
- // live response or throw LateHandlePushError on the completed store.
417
- // Same isolation pattern as route-level background-revalidation.ts.
418
415
  runBackground(requestCtx, async () => {
419
- // The closure-captured requestCtx is reused for the framework's own
420
- // reads (handle store swap, error reporting) AND, below, to
421
- // re-establish the request-context ALS around the user fn. ALS context
422
- // may be gone inside waitUntil: on workerd a waitUntil task runs
423
- // detached from the request's I/O context, so getRequestContext()
424
- // inside the cached body would otherwise throw.
425
- let originalHandleStore:
426
- | ReturnType<typeof createHandleStore>
427
- | undefined;
428
- if (hasTaintedArgs && requestCtx) {
429
- originalHandleStore = requestCtx._handleStore;
430
- requestCtx._handleStore = createHandleStore();
431
- }
432
- const bgHandleStore = hasTaintedArgs
433
- ? requestCtx?._handleStore
434
- : undefined;
416
+ // The background body runs under a DERIVED context with an OWN
417
+ // _handleStore (the shell-capture isolation pattern
418
+ // shell-capture.ts attemptCapture): its handle pushes land in the
419
+ // isolated store (captured below, persisted with the entry) while
420
+ // the foreground keeps pushing into the ORIGINAL store, untouched.
421
+ // Derivation matters because the foreground is STILL RENDERING here
422
+ // — runBackground/waitUntil starts the task on the next microtask,
423
+ // not after the response. The previous shape swapped
424
+ // requestCtx._handleStore in place (restore in finally), which
425
+ // routed the whole overlap window's foreground pushes into the
426
+ // background store: lost from the live document AND persisted into
427
+ // the revalidated entry (issue #684, plan 010).
428
+ const bgHandleStore =
429
+ hasTaintedArgs && requestCtx ? createHandleStore() : undefined;
430
+ const bgCtx: typeof requestCtx = bgHandleStore
431
+ ? Object.assign(Object.create(requestCtx), {
432
+ _handleStore: bgHandleStore,
433
+ })
434
+ : requestCtx;
435
435
  let bgCapture: HandleCapture | undefined;
436
436
  let bgStopCapture: (() => void) | undefined;
437
437
  if (bgHandleStore) {
@@ -440,28 +440,15 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
440
440
  bgStopCapture = c.stop;
441
441
  }
442
442
 
443
- // Stamp tainted ARGS only not requestCtx. The args stamp guards
444
- // direct ctx method calls (ctx.set, ctx.header, ctx.onResponse, etc.)
445
- // which is sufficient for correctness.
446
- //
447
- // We intentionally skip stamping requestCtx here because:
448
- // 1. runBackground starts the async task synchronously (before the
449
- // first await), so stampCacheExec would pollute the shared
450
- // requestCtx while the foreground pipeline is still running.
451
- // This causes assertNotInsideCacheExec to fire when cache-store
452
- // later calls requestCtx.onResponse().
453
- // 2. requestCtx methods are closure-bound to the original ctx, so
454
- // neither Object.create() nor a proxy can isolate the stamp.
455
- // 3. The foreground miss path already stamps requestCtx and catches
456
- // cookies()/headers() misuse on first execution. The background
457
- // re-runs the same function with the same request.
458
- const bgTaintedArgs: unknown[] = [];
459
- for (const arg of args) {
460
- if (isTainted(arg)) {
461
- stampCacheExec(arg as object);
462
- bgTaintedArgs.push(arg);
463
- }
464
- }
443
+ // Tainted args are NOT stamped here, in contrast to the foreground
444
+ // miss path below. The args include the live HandlerContext the
445
+ // still-rendering foreground holds, and INSIDE_CACHE_EXEC is a
446
+ // property stamped onto that SHARED object — so for the whole
447
+ // revalidation window a concurrent foreground ctx.set() /
448
+ // ctx.headers.*() would throw (issue #684, plan 010). requestCtx is
449
+ // not stamped for the same reason. In-fn misuse is already caught
450
+ // by the miss path's stamps on the function's FIRST execution — the
451
+ // background re-runs the same function with the same request.
465
452
 
466
453
  try {
467
454
  // Re-establish the request-context ALS so a "use cache" body that
@@ -469,8 +456,10 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
469
456
  // getRequestContext().env.ApiKey) resolves during the background
470
457
  // revalidation instead of throwing "called outside of a request
471
458
  // context". runWithRequestContext sets the store for fn's
472
- // synchronous kickoff; its async continuations inherit it.
473
- const scoped = runWithRequestContext(requestCtx, () =>
459
+ // synchronous kickoff; its async continuations inherit it. The
460
+ // DERIVED context goes in, so ambient _handleStore reads inside
461
+ // the body resolve to the isolated store.
462
+ const scoped = runWithRequestContext(bgCtx, () =>
474
463
  runWithCacheTagScope(() => fn.apply(this, args)),
475
464
  );
476
465
  const freshResult = await scoped.result;
@@ -507,15 +496,9 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
507
496
  "[use cache] background revalidation failed",
508
497
  requestCtx,
509
498
  );
510
- } finally {
511
- for (const arg of bgTaintedArgs) {
512
- unstampCacheExec(arg as object);
513
- }
514
- // Restore original handle store
515
- if (originalHandleStore && requestCtx) {
516
- requestCtx._handleStore = originalHandleStore;
517
- }
518
499
  }
500
+ // No finally: nothing shared was mutated — the derived context and
501
+ // its handle store are garbage after the task settles.
519
502
  });
520
503
  return result;
521
504
  } catch (error) {
@@ -601,6 +584,13 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
601
584
  // inside the cached function body (those side effects are lost on hit).
602
585
  // Uses ref-counted stamp/unstamp so overlapping executions
603
586
  // sharing the same ctx don't clear each other's guards.
587
+ //
588
+ // LOAD-BEARING for the stale-revalidation path above: the background
589
+ // re-execution deliberately does NOT re-stamp (the objects are live
590
+ // foreground state mid-render), relying on THIS stamp having caught in-fn
591
+ // misuse on the function's first execution — an entry only becomes
592
+ // stale-revalidatable because a stamped miss ran clean and stored it. Do
593
+ // not create a "use cache" entry via any path that skips this stamp.
604
594
  const taintedArgs: unknown[] = [];
605
595
  for (const arg of args) {
606
596
  if (isTainted(arg)) {
@@ -452,7 +452,11 @@ export class CacheScope {
452
452
  }
453
453
 
454
454
  // Collect handle data for non-loader segments only
455
- const handles = captureHandles(nonLoaderSegments, handleStore);
455
+ const handles = captureHandles(
456
+ nonLoaderSegments,
457
+ handleStore,
458
+ requestCtx._shellCaptureLoaderHandleValues,
459
+ );
456
460
 
457
461
  try {
458
462
  if (INTERNAL_RANGO_DEBUG) {
@@ -512,3 +516,28 @@ export function createCacheScope(
512
516
  if (!config) return parent; // No config, inherit parent
513
517
  return new CacheScope(config.options, parent);
514
518
  }
519
+
520
+ /**
521
+ * Shell fast path: when the route tree derived NO cache scope and the current
522
+ * request context carries the `_shellImplicitCache` marker (a shell capture,
523
+ * or a HIT tail serving an eligible entry), substitute an implicit doc-level
524
+ * scope so withCacheLookup/withCacheStore treat the WHOLE matched route as a
525
+ * cache() boundary — the shell entry IS a cache() of the handler layer, with
526
+ * loaders as the live carve-outs (resolveFreshLoadersAndYield).
527
+ *
528
+ * An existing scope — including an explicit cache(false) opt-out — always
529
+ * wins: the consumer's cache() semantics (their ttl/swr/store/condition) are
530
+ * never overridden, and cache(false) keeps the tail on the full handler
531
+ * re-run path.
532
+ */
533
+ export function resolveShellImplicitCacheScope(
534
+ scope: CacheScope | null,
535
+ ): CacheScope | null {
536
+ if (scope) return scope;
537
+ const marker = getRequestContext()?._shellImplicitCache;
538
+ if (!marker) return null;
539
+ return new CacheScope(
540
+ { ttl: marker.ttl, swr: marker.swr, store: marker.store },
541
+ null,
542
+ );
543
+ }
@@ -211,6 +211,8 @@ interface KVShellEnvelope {
211
211
  po: string | null;
212
212
  /** React.version captured at prerender time */
213
213
  rv: string;
214
+ /** Build version captured at prerender time (ShellCacheEntry.buildVersion) */
215
+ bv?: string;
214
216
  /** createdAt (ms epoch) */
215
217
  c: number;
216
218
  /** When entry becomes stale (ms epoch) */
@@ -1673,6 +1675,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1673
1675
  prelude: envelope.p,
1674
1676
  postponed: envelope.po,
1675
1677
  reactVersion: envelope.rv,
1678
+ buildVersion: envelope.bv,
1676
1679
  initialTheme: envelope.i,
1677
1680
  snapshot: envelope.sn,
1678
1681
  createdAt: envelope.c,
@@ -1734,6 +1737,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1734
1737
  p: entry.prelude,
1735
1738
  po: entry.postponed,
1736
1739
  rv: entry.reactVersion,
1740
+ bv: entry.buildVersion,
1737
1741
  c: entry.createdAt,
1738
1742
  s: staleAt,
1739
1743
  e: staleAt + swrWindow * 1000,
@@ -83,14 +83,35 @@ export async function decodeHandleValue<T>(encoded: string): Promise<T | null> {
83
83
  /**
84
84
  * Capture handle data for a set of segments from the handle store.
85
85
  * Used when caching segments to preserve their handle data.
86
+ *
87
+ * `exclude` (shell captures: RequestContext._shellCaptureLoaderHandleValues)
88
+ * drops DSL-loader-scoped push values from the CACHE WRITE only: loaders
89
+ * re-run fresh on every HIT, so replaying their captured values would
90
+ * duplicate the fresh push — and their masked nested promises would stall the
91
+ * Flight handle encode to its timeout. Threaded as an explicit argument so
92
+ * every other getDataForSegment consumer (the render-barrier snapshot,
93
+ * prerender) provably sees every push.
86
94
  */
87
95
  export function captureHandles(
88
96
  segments: ResolvedSegment[],
89
97
  handleStore: HandleStore,
98
+ exclude?: WeakSet<object>,
90
99
  ): Record<string, SegmentHandleData> {
91
100
  const handles: Record<string, SegmentHandleData> = {};
92
101
  for (const seg of segments) {
93
- handles[seg.id] = handleStore.getDataForSegment(seg.id);
102
+ const data = handleStore.getDataForSegment(seg.id);
103
+ if (!exclude) {
104
+ handles[seg.id] = data;
105
+ continue;
106
+ }
107
+ const filtered: SegmentHandleData = {};
108
+ for (const [handleName, values] of Object.entries(data)) {
109
+ const kept = values.filter(
110
+ (v) => typeof v !== "object" || v === null || !exclude.has(v),
111
+ );
112
+ if (kept.length > 0) filtered[handleName] = kept;
113
+ }
114
+ handles[seg.id] = filtered;
94
115
  }
95
116
  return handles;
96
117
  }