@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
|
@@ -110,6 +110,19 @@ export class RecordingShellStore<
|
|
|
110
110
|
this.writes.push(p);
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Record a segment-family write into the snapshot WITHOUT touching the inner
|
|
115
|
+
* store. The shell fast path's implicit doc-cache scope writes through this
|
|
116
|
+
* (via {@link SnapshotOnlySegmentStore}): the recorded doc entry must ride
|
|
117
|
+
* ONLY inside the shell entry — a passthrough write would leave a doc-keyed
|
|
118
|
+
* entry in the real store that the NEXT capture's lookup would hit, replaying
|
|
119
|
+
* the previous generation's segments instead of re-running handlers (breaking
|
|
120
|
+
* SWR recapture freshness).
|
|
121
|
+
*/
|
|
122
|
+
recordSegmentWrite(key: string, data: CachedEntryData): void {
|
|
123
|
+
this.record("segment", key, data);
|
|
124
|
+
}
|
|
125
|
+
|
|
113
126
|
/**
|
|
114
127
|
* Await the tracked deferred writes so their records are present before drain.
|
|
115
128
|
* Drains ITERATIVELY: a write task can schedule a NESTED write (the ring-3
|
|
@@ -245,6 +258,40 @@ export function getRecordingStore<TEnv>(
|
|
|
245
258
|
return store instanceof RecordingShellStore ? store : undefined;
|
|
246
259
|
}
|
|
247
260
|
|
|
261
|
+
/**
|
|
262
|
+
* The store the shell fast path's IMPLICIT doc-cache scope resolves during a
|
|
263
|
+
* capture: reads pass through the recording store (a real-store hit is
|
|
264
|
+
* recorded, exactly like any capture read), but segment WRITES are recorded
|
|
265
|
+
* into the snapshot only — see {@link RecordingShellStore.recordSegmentWrite}
|
|
266
|
+
* for why passthrough would break SWR recapture. Routes with their OWN
|
|
267
|
+
* cache() config never see this store (their scope resolves the app-level
|
|
268
|
+
* recording store and keeps today's record-and-write behavior).
|
|
269
|
+
*/
|
|
270
|
+
export class SnapshotOnlySegmentStore<
|
|
271
|
+
TEnv = unknown,
|
|
272
|
+
> implements SegmentCacheStore<TEnv> {
|
|
273
|
+
constructor(private readonly recording: RecordingShellStore<TEnv>) {}
|
|
274
|
+
|
|
275
|
+
get defaults(): SegmentCacheStore<TEnv>["defaults"] {
|
|
276
|
+
return this.recording.defaults;
|
|
277
|
+
}
|
|
278
|
+
get keyGenerator(): SegmentCacheStore<TEnv>["keyGenerator"] {
|
|
279
|
+
return this.recording.keyGenerator;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async get(key: string): Promise<CacheGetResult | null> {
|
|
283
|
+
return this.recording.get(key);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async set(key: string, data: CachedEntryData): Promise<void> {
|
|
287
|
+
this.recording.recordSegmentWrite(key, data);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async delete(key: string): Promise<boolean> {
|
|
291
|
+
return this.recording.delete(key);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
248
295
|
/**
|
|
249
296
|
* Materialize the loader-family seed from a shell snapshot for a HIT's tail
|
|
250
297
|
* render: Flight-deserialize each recorded (promise-elided) bake-lane
|
package/src/cache/types.ts
CHANGED
|
@@ -225,10 +225,13 @@ export interface CacheItemResult {
|
|
|
225
225
|
* One entry carries BOTH artifacts a resume needs — the rendered HTML prelude
|
|
226
226
|
* and React's postponed state — because the pair is version- and
|
|
227
227
|
* generation-coupled and must never be mixed across a React upgrade or a build
|
|
228
|
-
* change. The reactVersion
|
|
229
|
-
* shell-
|
|
230
|
-
*
|
|
231
|
-
*
|
|
228
|
+
* change. The reactVersion and buildVersion fields are the read-time gates that
|
|
229
|
+
* enforce both halves: isValidShellHit (rsc/shell-serve.ts) treats an entry
|
|
230
|
+
* whose reactVersion differs from the running React, or whose buildVersion
|
|
231
|
+
* differs from the running build, as a miss (the postponed blob encodes hole
|
|
232
|
+
* positions against one exact tree; resuming it against a different React or a
|
|
233
|
+
* different app build tree-mismatches inside resume(), AFTER the 200 + prelude
|
|
234
|
+
* are committed — an unrecoverable broken serve).
|
|
232
235
|
*/
|
|
233
236
|
export interface ShellCacheEntry {
|
|
234
237
|
/** Rendered HTML prelude bytes, base64-encoded (stores are JSON-serializing). */
|
|
@@ -240,6 +243,17 @@ export interface ShellCacheEntry {
|
|
|
240
243
|
postponed: string | null;
|
|
241
244
|
/** React.version captured at prerender time; the read-time invalidation gate. */
|
|
242
245
|
reactVersion: string;
|
|
246
|
+
/**
|
|
247
|
+
* Build version captured at prerender time (the RSC handler's `version` —
|
|
248
|
+
* the `@rangojs/router:version` build stamp by default, bumped per build and
|
|
249
|
+
* on dev RSC-module edits). The second read-time gate: a persistent shared
|
|
250
|
+
* store (KV/runtime-cache) survives deploys, and an app-code change that
|
|
251
|
+
* keeps the same React version would otherwise leave a stale-build
|
|
252
|
+
* prelude+postponed live under the same key. Optional only for entries
|
|
253
|
+
* stored before the field existed — those are treated as a miss and the
|
|
254
|
+
* recapture re-stamps them (pre-release, no compat shim).
|
|
255
|
+
*/
|
|
256
|
+
buildVersion?: string;
|
|
243
257
|
/**
|
|
244
258
|
* The initialTheme the CAPTURE render was built with (the derived context's
|
|
245
259
|
* reqCtx.theme). The resume tail must render ThemeProvider with the SAME
|
|
@@ -266,6 +280,19 @@ export interface ShellCacheEntry {
|
|
|
266
280
|
* heals it. See docs/design/ppr-shell-resume.md ("the capture data snapshot").
|
|
267
281
|
*/
|
|
268
282
|
snapshot?: ShellSnapshotRecord[];
|
|
283
|
+
/**
|
|
284
|
+
* True when the capture's HANDLER layer declared per-request liveness: a
|
|
285
|
+
* handle pushed OUTSIDE a DSL loader scope carried a nested thenable (the
|
|
286
|
+
* capture mask turns it into a never-filling hole), such a push was still
|
|
287
|
+
* pending when the entry was written, or a handler-invoked loader
|
|
288
|
+
* (ctx.use(loader) from a handler body — the consumption lane, #672)
|
|
289
|
+
* executed during the capture. The serve tail then must NOT take the
|
|
290
|
+
* handler-free fast path (the implicit doc-cache hit): only a handler
|
|
291
|
+
* re-run can mint that hole's live promise or refresh that consumed value.
|
|
292
|
+
* DSL-loader pushes never set this — loaders re-run fresh on every HIT, so
|
|
293
|
+
* their holes always fill.
|
|
294
|
+
*/
|
|
295
|
+
handlerLiveHoles?: boolean;
|
|
269
296
|
/** Epoch ms when the shell was captured. */
|
|
270
297
|
createdAt: number;
|
|
271
298
|
}
|
|
@@ -177,6 +177,8 @@ interface VercelShellEnvelope {
|
|
|
177
177
|
po: string | null;
|
|
178
178
|
/** React.version at capture. */
|
|
179
179
|
rv: string;
|
|
180
|
+
/** Build version at capture (ShellCacheEntry.buildVersion). */
|
|
181
|
+
bv?: string;
|
|
180
182
|
/** createdAt (ms since epoch). */
|
|
181
183
|
c: number;
|
|
182
184
|
/** staleAt (ms since epoch). */
|
|
@@ -776,6 +778,7 @@ export class VercelCacheStore<
|
|
|
776
778
|
prelude: env.p,
|
|
777
779
|
postponed: env.po,
|
|
778
780
|
reactVersion: env.rv,
|
|
781
|
+
buildVersion: env.bv,
|
|
779
782
|
initialTheme: env.i,
|
|
780
783
|
snapshot: env.sn,
|
|
781
784
|
createdAt: env.c,
|
|
@@ -805,6 +808,7 @@ export class VercelCacheStore<
|
|
|
805
808
|
p: entry.prelude,
|
|
806
809
|
po: entry.postponed,
|
|
807
810
|
rv: entry.reactVersion,
|
|
811
|
+
bv: entry.buildVersion,
|
|
808
812
|
c: entry.createdAt,
|
|
809
813
|
s: staleAt,
|
|
810
814
|
e: expiresAt,
|
|
@@ -1091,7 +1095,7 @@ export class VercelCacheStore<
|
|
|
1091
1095
|
|
|
1092
1096
|
private asShellEnvelope(raw: unknown): VercelShellEnvelope | null {
|
|
1093
1097
|
if (!isRecord(raw)) return null;
|
|
1094
|
-
const { p, po, rv, c, s, e, t, i, sn } = raw;
|
|
1098
|
+
const { p, po, rv, bv, c, s, e, t, i, sn } = raw;
|
|
1095
1099
|
if (typeof p !== "string" || typeof rv !== "string") return null;
|
|
1096
1100
|
if (po !== null && typeof po !== "string") return null;
|
|
1097
1101
|
if (typeof c !== "number") return null;
|
|
@@ -1100,6 +1104,7 @@ export class VercelCacheStore<
|
|
|
1100
1104
|
p,
|
|
1101
1105
|
po: po as string | null,
|
|
1102
1106
|
rv,
|
|
1107
|
+
bv: typeof bv === "string" ? bv : undefined,
|
|
1103
1108
|
c,
|
|
1104
1109
|
s,
|
|
1105
1110
|
e,
|
package/src/deps/ssr.ts
CHANGED
|
@@ -430,6 +430,22 @@ function createLoaderExecutor<TEnv>(
|
|
|
430
430
|
// nested deps inherit isDslLoader=false only when the CHAIN started in a
|
|
431
431
|
// handler; a chain started by the segment funnel stays DSL (the loader
|
|
432
432
|
// scope ALS survives the body's awaits).
|
|
433
|
+
// Shell fast path eligibility: a HANDLER-invoked loader executing during a
|
|
434
|
+
// capture is handler-layer dynamism — on a handler-free (replayed) HIT it
|
|
435
|
+
// would never re-run, freezing its consumption-lane value (#672's "fresh
|
|
436
|
+
// per serve" slot shape). Mark the capture; the entry declines the fast
|
|
437
|
+
// path and keeps the full tail. DSL loaders re-run on every HIT and never
|
|
438
|
+
// set this.
|
|
439
|
+
if (!isDslLoader) {
|
|
440
|
+
const captureCtx = _getRequestContext();
|
|
441
|
+
if (
|
|
442
|
+
captureCtx?._shellCaptureRun &&
|
|
443
|
+
captureCtx._shellCaptureHandleLiveness
|
|
444
|
+
) {
|
|
445
|
+
captureCtx._shellCaptureHandleLiveness.handlerInvokedLoader = true;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
433
449
|
const promise = observePhase(PHASES.loader(loader.$$id), () =>
|
|
434
450
|
Promise.resolve(
|
|
435
451
|
runInsideLoaderBodyScope(
|
package/src/router/match-api.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
CacheScope,
|
|
3
|
+
createCacheScope,
|
|
4
|
+
resolveShellImplicitCacheScope,
|
|
5
|
+
} from "../cache/cache-scope.js";
|
|
2
6
|
import { RouteNotFoundError } from "../errors";
|
|
3
7
|
import {
|
|
4
8
|
createErrorInfo,
|
|
@@ -180,7 +184,10 @@ export async function createMatchContextForFull<TEnv>(
|
|
|
180
184
|
},
|
|
181
185
|
isSameRouteNavigation: false,
|
|
182
186
|
interceptResult: null,
|
|
183
|
-
|
|
187
|
+
// Shell fast path: a capture or an eligible HIT tail may substitute an
|
|
188
|
+
// implicit doc-level scope (marker-gated; a route-derived scope wins).
|
|
189
|
+
// Full matches only — partial navigations never serve a shell.
|
|
190
|
+
cacheScope: resolveShellImplicitCacheScope(snapshot.cacheScope),
|
|
184
191
|
isIntercept: false,
|
|
185
192
|
actionContext: undefined,
|
|
186
193
|
isAction: false,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
+
import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
|
|
2
3
|
import { sanitizeError } from "../errors";
|
|
3
4
|
import type { ErrorInfo, ErrorPhase, MatchResult } from "../types";
|
|
4
5
|
import type {
|
|
@@ -291,7 +292,13 @@ export function createMatchHandlers<TEnv = any>(
|
|
|
291
292
|
});
|
|
292
293
|
emitter.start();
|
|
293
294
|
|
|
295
|
+
const ctxBuildStart = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
|
|
294
296
|
const result = await createMatchContextForFull(request, env);
|
|
297
|
+
if (INTERNAL_RANGO_DEBUG) {
|
|
298
|
+
console.log(
|
|
299
|
+
`[Server][match] context built +${Math.round(performance.now() - ctxBuildStart)}ms (abs ${Math.round(performance.now())})`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
295
302
|
|
|
296
303
|
if ("type" in result && result.type === "redirect") {
|
|
297
304
|
emitter.end(0, false);
|
|
@@ -310,7 +317,13 @@ export function createMatchHandlers<TEnv = any>(
|
|
|
310
317
|
try {
|
|
311
318
|
const state = createPipelineState();
|
|
312
319
|
const pipeline = createMatchPartialPipeline(ctx, state);
|
|
320
|
+
const pipeStart = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
|
|
313
321
|
const matchResult = await collectMatchResult(pipeline, ctx, state);
|
|
322
|
+
if (INTERNAL_RANGO_DEBUG) {
|
|
323
|
+
console.log(
|
|
324
|
+
`[Server][match] pipeline collected +${Math.round(performance.now() - pipeStart)}ms (abs ${Math.round(performance.now())})`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
314
327
|
if (hasTelemetry || cacheSignalEnabled) {
|
|
315
328
|
const signalSegments = buildSignal(ctx.routeKey, state);
|
|
316
329
|
recordSignalIfEnabled(signalSegments);
|
|
@@ -36,7 +36,10 @@ import {
|
|
|
36
36
|
DEFAULT_ROUTE_TTL,
|
|
37
37
|
} from "../../cache/cache-policy.js";
|
|
38
38
|
import { readThroughItem } from "../../cache/read-through-swr.js";
|
|
39
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
maskNestedContainerThenables,
|
|
41
|
+
overlayLoaderContainer,
|
|
42
|
+
} from "./loader-snapshot.js";
|
|
40
43
|
import { recordRequestTags } from "../../cache/cache-tag.js";
|
|
41
44
|
import {
|
|
42
45
|
isShellCaptureActive,
|
|
@@ -162,8 +165,21 @@ export function resolveLoaderData<TEnv>(
|
|
|
162
165
|
// never as an unhandled rejection that can kill the worker before the
|
|
163
166
|
// drain probes this record.
|
|
164
167
|
containerPromise.catch(() => {});
|
|
165
|
-
|
|
166
|
-
|
|
168
|
+
// Nested-promise SHAPE is the liveness declaration: mask nested thenables
|
|
169
|
+
// in the capture's copy of the container so the consuming subtree
|
|
170
|
+
// postpones as a hole no matter when the promise settles, elide records a
|
|
171
|
+
// HOLE marker, and every HIT streams the fresh value. Without this, a
|
|
172
|
+
// nested promise that settled before the quiet window baked its value into
|
|
173
|
+
// the SHARED shell and the snapshot pinned it for every visitor
|
|
174
|
+
// (per-request basket data served cross-session, found live). The raw
|
|
175
|
+
// container is untouched: handler-side ctx.use consumption (the
|
|
176
|
+
// consumption-lane rule, semantic-matrix PPR3) keeps real values.
|
|
177
|
+
const maskedPromise = containerPromise.then((container: unknown) =>
|
|
178
|
+
maskNestedContainerThenables(container),
|
|
179
|
+
);
|
|
180
|
+
maskedPromise.catch(() => {});
|
|
181
|
+
reqCtx?._shellCaptureLoaderRecords?.set(bakeSegmentKey, maskedPromise);
|
|
182
|
+
return maskedPromise;
|
|
167
183
|
}
|
|
168
184
|
|
|
169
185
|
if (bakeSegmentKey) {
|
|
@@ -36,17 +36,10 @@ export function isShellCaptureActive(
|
|
|
36
36
|
return reqCtx?._shellCaptureRun === true;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
* shared shell. The capture abort (`maxWaitMs` in captureShellHTML) bounds how
|
|
44
|
-
* long the prerender waits before it freezes the prelude, so this never hangs the
|
|
45
|
-
* request.
|
|
46
|
-
*/
|
|
47
|
-
export function createMaskedLoaderPromise<T = unknown>(): Promise<T> {
|
|
48
|
-
return new Promise<T>(() => {});
|
|
49
|
-
}
|
|
39
|
+
// createMaskedLoaderPromise moved to the leaf module mask-nested.ts (shared
|
|
40
|
+
// with the handle-push funnel in request-context, which cannot import THIS
|
|
41
|
+
// module without a cycle). Re-exported to keep the mask API in one place.
|
|
42
|
+
export { createMaskedLoaderPromise } from "./mask-nested.js";
|
|
50
43
|
|
|
51
44
|
/**
|
|
52
45
|
* Lane decision for an entry's loaders under PPR (the loading() value decides;
|
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
* payload byte-identical to that frozen prelude, the capture pins the container
|
|
9
9
|
* in the shell snapshot:
|
|
10
10
|
*
|
|
11
|
-
* - elide: deep-walk the settled container; a
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* - elide: deep-walk the settled container; a PENDING nested promise is a
|
|
12
|
+
* hole, replaced by {@link LOADER_HOLE_KEY} — and since
|
|
13
|
+
* loader-cache masks every nested thenable at capture
|
|
14
|
+
* ({@link maskNestedContainerThenables}), nested promises are
|
|
15
|
+
* ALWAYS pending here for new captures. The SETTLED-marker branch
|
|
16
|
+
* below is legacy: it fires only if a settled thenable reaches
|
|
17
|
+
* elide anyway, and the overlay keeps decoding SETTLED markers
|
|
18
|
+
* from pre-mask snapshots. The result is promise-free and
|
|
17
19
|
* Flight-serializable.
|
|
18
20
|
* - overlay: on a HIT the loader runs fresh (only the loader body can mint
|
|
19
21
|
* the live nested promises), then the recorded container is laid
|
|
@@ -28,6 +30,12 @@
|
|
|
28
30
|
|
|
29
31
|
import { isThenable } from "../../handles/is-thenable.js";
|
|
30
32
|
|
|
33
|
+
// Capture-side nested-thenable masking lives in the LEAF module mask-nested.ts
|
|
34
|
+
// (request-context also needs it for handle pushes and cannot import through
|
|
35
|
+
// loader-mask without a cycle). Re-exported here so loader-cache and the unit
|
|
36
|
+
// tests keep one import site for the snapshot family.
|
|
37
|
+
export { maskNestedContainerThenables } from "./mask-nested.js";
|
|
38
|
+
|
|
31
39
|
/**
|
|
32
40
|
* Marker object standing in for a pending nested promise in a recorded loader
|
|
33
41
|
* container. Shape-checked (not identity-checked) because the record round-trips
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture-side nested-thenable masking — the mechanism behind "nested-promise
|
|
3
|
+
* shape is the liveness declaration" for BOTH bake-lane loader containers
|
|
4
|
+
* (loader-cache.ts) and pushed handle containers (request-context.ts
|
|
5
|
+
* createUseFunction).
|
|
6
|
+
*
|
|
7
|
+
* Deliberately a LEAF module: request-context needs the mask for handle
|
|
8
|
+
* pushes, and loader-mask (the other natural home) imports request-context —
|
|
9
|
+
* importing from there would cycle. This module imports only is-thenable.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { isThenable } from "../../handles/is-thenable.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A promise that never settles — the masked stand-in for a per-request value
|
|
16
|
+
* during shell capture. The consuming Suspense subtree suspends forever, so
|
|
17
|
+
* the static prerender postpones it as a hole instead of baking a per-request
|
|
18
|
+
* value into the shared shell. The capture abort (`maxWaitMs` in
|
|
19
|
+
* captureShellHTML) bounds how long the prerender waits before it freezes the
|
|
20
|
+
* prelude, so this never hangs the request.
|
|
21
|
+
*/
|
|
22
|
+
export function createMaskedLoaderPromise<T = unknown>(): Promise<T> {
|
|
23
|
+
return new Promise<T>(() => {});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
27
|
+
if (typeof value !== "object" || value === null) return false;
|
|
28
|
+
const proto = Object.getPrototypeOf(value);
|
|
29
|
+
return proto === Object.prototype || proto === null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deep-copy a container with every NESTED thenable replaced by a masked
|
|
34
|
+
* (never-resolving) promise. Applied during shell capture to (a) bake-lane
|
|
35
|
+
* loader containers (loader-cache.ts) and (b) pushed handle containers
|
|
36
|
+
* (createUseFunction) — the two rango-owned funnels where consumers declare
|
|
37
|
+
* per-request data by promise SHAPE.
|
|
38
|
+
*
|
|
39
|
+
* Why: a nested promise that happened to SETTLE before the capture's quiet
|
|
40
|
+
* window closed used to bake its value into the SHARED shell (and, for
|
|
41
|
+
* loaders, the snapshot pinned it for every HIT) — per-request data frozen
|
|
42
|
+
* and served cross-session (found live: a storefront basket, carrying the
|
|
43
|
+
* capturing session's basketId/customer identifiers, served to anonymous
|
|
44
|
+
* visitors). The window waits for the slowest shared material on the page, so
|
|
45
|
+
* any real data source (a 5ms SQL read, a 200ms basket API) lost the race.
|
|
46
|
+
* Masking makes the consuming subtree postpone as a hole no matter when the
|
|
47
|
+
* promise settles: liveness by declaration, not by racing the window.
|
|
48
|
+
*
|
|
49
|
+
* Only plain objects/arrays are traversed; other values are leaves. The INPUT
|
|
50
|
+
* IS NEVER MUTATED — handler-side loader consumption (the consumption-lane
|
|
51
|
+
* rule, semantic-matrix PPR3) shares the raw container and must keep real
|
|
52
|
+
* values. Cycles are preserved as cycles in the copy.
|
|
53
|
+
*/
|
|
54
|
+
/**
|
|
55
|
+
* Optional single-walk report for maskNestedContainerThenables: `thenable`
|
|
56
|
+
* flips true when the walk masked at least one thenable. The shell fast path
|
|
57
|
+
* reads it at handle-push time — a pushed container with a nested thenable
|
|
58
|
+
* declares per-request data, and a shell entry whose HANDLER layer made such
|
|
59
|
+
* a declaration cannot be served handler-free (the hole would never fill).
|
|
60
|
+
* Same single-pass shape as elideLoaderContainer's `hasHole`.
|
|
61
|
+
*/
|
|
62
|
+
export interface MaskReport {
|
|
63
|
+
thenable: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function maskNestedContainerThenables(
|
|
67
|
+
value: unknown,
|
|
68
|
+
seen: Map<object, unknown> = new Map(),
|
|
69
|
+
report?: MaskReport,
|
|
70
|
+
): unknown {
|
|
71
|
+
if (isThenable(value)) {
|
|
72
|
+
if (report) report.thenable = true;
|
|
73
|
+
return createMaskedLoaderPromise();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (Array.isArray(value)) {
|
|
77
|
+
const cached = seen.get(value);
|
|
78
|
+
if (cached !== undefined) return cached;
|
|
79
|
+
const out: unknown[] = new Array(value.length);
|
|
80
|
+
seen.set(value, out);
|
|
81
|
+
for (let i = 0; i < value.length; i++) {
|
|
82
|
+
out[i] = maskNestedContainerThenables(value[i], seen, report);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (isPlainObject(value)) {
|
|
88
|
+
const cached = seen.get(value);
|
|
89
|
+
if (cached !== undefined) return cached;
|
|
90
|
+
const out: Record<string, unknown> = {};
|
|
91
|
+
seen.set(value, out);
|
|
92
|
+
for (const key of Object.keys(value)) {
|
|
93
|
+
out[key] = maskNestedContainerThenables(value[key], seen, report);
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return value;
|
|
99
|
+
}
|