@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.
- package/dist/bin/rango.js +1 -40
- package/dist/vite/index.js +35 -9
- package/package.json +1 -1
- package/skills/ppr/SKILL.md +29 -23
- package/src/browser/logging.ts +18 -0
- package/src/browser/rsc-router.tsx +43 -0
- package/src/cache/cache-runtime.ts +41 -51
- package/src/cache/cache-scope.ts +30 -1
- package/src/cache/cf/cf-cache-store.ts +4 -0
- package/src/cache/handle-snapshot.ts +22 -1
- package/src/cache/shell-snapshot.ts +47 -0
- package/src/cache/types.ts +31 -4
- package/src/cache/vercel/vercel-cache-store.ts +6 -1
- package/src/deps/ssr.ts +4 -1
- package/src/router/loader-resolution.ts +16 -0
- package/src/router/match-api.ts +9 -2
- package/src/router/match-handlers.ts +13 -0
- package/src/router/segment-resolution/loader-cache.ts +19 -3
- package/src/router/segment-resolution/loader-mask.ts +4 -11
- package/src/router/segment-resolution/loader-snapshot.ts +14 -6
- package/src/router/segment-resolution/mask-nested.ts +99 -0
- package/src/rsc/rsc-rendering.ts +139 -16
- package/src/rsc/shell-capture.ts +122 -0
- package/src/rsc/shell-serve.ts +37 -6
- package/src/segment-loader-promise.ts +18 -0
- package/src/segment-system.tsx +123 -9
- package/src/server/request-context.ts +47 -0
- package/src/ssr/index.tsx +118 -18
- package/src/ssr/inject-rsc-eager.ts +167 -0
- package/src/ssr/preinit-client-references.ts +106 -0
- package/src/vite/index.ts +8 -0
- package/src/vite/plugin-types.ts +33 -0
- package/src/vite/plugins/virtual-entries.ts +37 -4
- package/src/vite/rango.ts +10 -2
- 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,
|
|
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",
|
package/dist/vite/index.js
CHANGED
|
@@ -2210,21 +2210,35 @@ async function initializeApp() {
|
|
|
2210
2210
|
|
|
2211
2211
|
initializeApp().catch(console.error);
|
|
2212
2212
|
`.trim();
|
|
2213
|
-
|
|
2214
|
-
|
|
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.
|
|
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] =
|
|
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(
|
|
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(
|
|
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
package/skills/ppr/SKILL.md
CHANGED
|
@@ -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
|
|
255
|
-
genuinely pending at capture
|
|
256
|
-
read, warmed cache), it may bake into the shell
|
|
257
|
-
guaranteed rather than probable, use
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
288
|
-
|
|
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
|
|
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) }; //
|
|
561
|
-
return { cart: fetchBasket(basketId) }; //
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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);
|
package/src/browser/logging.ts
CHANGED
|
@@ -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
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
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
|
-
|
|
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)) {
|
package/src/cache/cache-scope.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
}
|