@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/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
resolvePprConfig,
|
|
39
39
|
buildShellKey,
|
|
40
40
|
isValidShellHit,
|
|
41
|
+
hasIntactShellPayload,
|
|
41
42
|
base64ToBytes,
|
|
42
43
|
hasShellFamily,
|
|
43
44
|
warnShellStoreMissingOnce,
|
|
@@ -164,6 +165,7 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
164
165
|
) {
|
|
165
166
|
const descriptor: ShellCaptureDescriptor = {
|
|
166
167
|
key,
|
|
168
|
+
buildVersion: ctx.version,
|
|
167
169
|
ttl: pprConfig.ttl,
|
|
168
170
|
swr: pprConfig.swr,
|
|
169
171
|
tags: pprConfig.tags,
|
|
@@ -177,30 +179,46 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
177
179
|
// A failing store read degrades to axis 1 (MISS), never a 500.
|
|
178
180
|
reportCacheError(error, "cache-read", "[ShellServe] getShell");
|
|
179
181
|
}
|
|
180
|
-
if (cached && isValidShellHit(cached.entry)) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
182
|
+
if (cached && isValidShellHit(cached.entry, ctx.version)) {
|
|
183
|
+
if (!hasIntactShellPayload(cached.entry)) {
|
|
184
|
+
// Corrupt stored payload (undecodable prelude / unparseable
|
|
185
|
+
// postponed): a store-layer fault worth a diagnostic, unlike the
|
|
186
|
+
// silent version-mismatch lifecycle misses above. Degrade to MISS
|
|
187
|
+
// — pprMiss below schedules the recapture that overwrites it.
|
|
188
|
+
reportCacheError(
|
|
189
|
+
new Error(
|
|
190
|
+
`corrupt shell entry for "${key}": prelude/postponed failed ` +
|
|
191
|
+
"the integrity check; serving axis 1 and recapturing",
|
|
192
|
+
),
|
|
193
|
+
"cache-read",
|
|
194
|
+
"[ShellServe] getShell",
|
|
195
|
+
);
|
|
196
|
+
} else {
|
|
197
|
+
// Stale (SWR) hit: serve the stale shell now, recapture in the
|
|
198
|
+
// background (stampede-guarded + backoff inside scheduleShellCapture).
|
|
199
|
+
if (cached.shouldRevalidate) {
|
|
200
|
+
scheduleShellCapture(
|
|
201
|
+
ctx,
|
|
202
|
+
request,
|
|
203
|
+
env,
|
|
204
|
+
url,
|
|
205
|
+
reqCtx,
|
|
206
|
+
ssrModule,
|
|
207
|
+
descriptor,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return serveShellHit(
|
|
185
211
|
ctx,
|
|
186
212
|
request,
|
|
187
213
|
env,
|
|
188
214
|
url,
|
|
189
215
|
reqCtx,
|
|
216
|
+
handleStore,
|
|
190
217
|
ssrModule,
|
|
218
|
+
cached.entry,
|
|
191
219
|
descriptor,
|
|
192
220
|
);
|
|
193
221
|
}
|
|
194
|
-
return serveShellHit(
|
|
195
|
-
ctx,
|
|
196
|
-
request,
|
|
197
|
-
env,
|
|
198
|
-
url,
|
|
199
|
-
reqCtx,
|
|
200
|
-
handleStore,
|
|
201
|
-
ssrModule,
|
|
202
|
-
cached.entry,
|
|
203
|
-
);
|
|
204
222
|
}
|
|
205
223
|
// MISS (no entry, invalid reactVersion, or store read failure): axis 1
|
|
206
224
|
// + a background capture scheduled once the response is known servable.
|
|
@@ -468,13 +486,20 @@ function serveShellHit(
|
|
|
468
486
|
handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
|
|
469
487
|
ssrModule: SSRModule,
|
|
470
488
|
entry: ShellCacheEntry,
|
|
489
|
+
descriptor: ShellCaptureDescriptor,
|
|
471
490
|
): Response {
|
|
472
491
|
const preludeBytes = base64ToBytes(entry.prelude);
|
|
473
492
|
|
|
474
493
|
const renderTail = async (
|
|
475
494
|
activeCtx: RequestContext<any>,
|
|
476
495
|
): Promise<ReadableStream<Uint8Array> | { redirect: string }> => {
|
|
496
|
+
const matchStart = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
|
|
477
497
|
const match = await ctx.router.match(request, { env });
|
|
498
|
+
if (INTERNAL_RANGO_DEBUG) {
|
|
499
|
+
console.log(
|
|
500
|
+
`[Server][ppr] shell HIT: tail match done +${Math.round(performance.now() - matchStart)}ms (abs ${Math.round(performance.now())}, started ${Math.round(matchStart)})`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
478
503
|
if (match.redirect) return { redirect: match.redirect };
|
|
479
504
|
setRequestContextParams(match.params, match.routeName);
|
|
480
505
|
const payload = buildFullPayload(match, ctx, url, activeCtx, handleStore);
|
|
@@ -493,11 +518,32 @@ function serveShellHit(
|
|
|
493
518
|
}
|
|
494
519
|
// Full Flight render per request: hydration needs the whole payload (there
|
|
495
520
|
// is no Flight-side resume — a React limitation, not ours).
|
|
496
|
-
|
|
521
|
+
let rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
|
|
497
522
|
onError: (error: unknown) => {
|
|
498
523
|
ctx.callOnError(error, "rendering", { request, url, env });
|
|
499
524
|
},
|
|
500
525
|
});
|
|
526
|
+
// Timing tap: when does the Flight render produce its FIRST byte? Compared
|
|
527
|
+
// with the eager-inject/first-tail logs this proves whether hydration-start
|
|
528
|
+
// latency is genuine server work (loaders) or stream plumbing holding
|
|
529
|
+
// ready bytes back.
|
|
530
|
+
if (INTERNAL_RANGO_DEBUG) {
|
|
531
|
+
const tapStart = performance.now();
|
|
532
|
+
let first = false;
|
|
533
|
+
rscStream = rscStream.pipeThrough(
|
|
534
|
+
new TransformStream({
|
|
535
|
+
transform(chunk, controller) {
|
|
536
|
+
if (!first) {
|
|
537
|
+
first = true;
|
|
538
|
+
console.log(
|
|
539
|
+
`[Server][ppr] flight render: first chunk +${Math.round(performance.now() - tapStart)}ms (abs ${Math.round(performance.now())})`,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
controller.enqueue(chunk);
|
|
543
|
+
},
|
|
544
|
+
}),
|
|
545
|
+
);
|
|
546
|
+
}
|
|
501
547
|
return observePhase(PHASES.ssr, () =>
|
|
502
548
|
ssrModule.resumeShellHTML!(rscStream, {
|
|
503
549
|
postponed: entry.postponed,
|
|
@@ -531,8 +577,38 @@ function serveShellHit(
|
|
|
531
577
|
// decode into a seed Map for the resolveLoaderData overlay, so the
|
|
532
578
|
// payload's baked container bytes match the frozen prelude while the
|
|
533
579
|
// hole-marker paths keep the fresh run's live nested promises.
|
|
580
|
+
const seedStart = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
|
|
534
581
|
const loaderSeed = await buildShellLoaderSeed(entry.snapshot);
|
|
582
|
+
if (INTERNAL_RANGO_DEBUG) {
|
|
583
|
+
console.log(
|
|
584
|
+
`[Server][ppr] shell HIT: loader seed built +${Math.round(performance.now() - seedStart)}ms (abs ${Math.round(performance.now())})`,
|
|
585
|
+
);
|
|
586
|
+
}
|
|
535
587
|
if (loaderSeed) seededCtx._shellLoaderSeed = loaderSeed;
|
|
588
|
+
// Shell fast path (serve side): when the capture recorded the implicit
|
|
589
|
+
// doc segment record and the handler layer declared no liveness, arm the
|
|
590
|
+
// implicit scope on the seeded context — the tail match's cache lookup
|
|
591
|
+
// then HITs the SeededShellStore's doc entry and the whole handler layer
|
|
592
|
+
// is REPLAYED, not re-executed (loaders still run fresh via
|
|
593
|
+
// resolveFreshLoadersAndYield; per-request payload metadata is rebuilt
|
|
594
|
+
// by buildFullPayload as always). A route with handler-live holes, a
|
|
595
|
+
// route-derived cache scope, or a missing/corrupt record degrades to
|
|
596
|
+
// the full tail (handler re-run — today's behavior) automatically.
|
|
597
|
+
if (!entry.handlerLiveHoles) {
|
|
598
|
+
seededCtx._shellImplicitCache = {
|
|
599
|
+
ttl: descriptor.ttl,
|
|
600
|
+
swr: descriptor.swr,
|
|
601
|
+
};
|
|
602
|
+
if (INTERNAL_RANGO_DEBUG) {
|
|
603
|
+
console.log(
|
|
604
|
+
`[Server][ppr] shell HIT: fast path armed (implicit doc cache) (abs ${Math.round(performance.now())})`,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
} else if (INTERNAL_RANGO_DEBUG) {
|
|
608
|
+
console.log(
|
|
609
|
+
`[Server][ppr] shell HIT: fast path declined — handler-live holes; tail re-runs handlers (abs ${Math.round(performance.now())})`,
|
|
610
|
+
);
|
|
611
|
+
}
|
|
536
612
|
return runWithRequestContext(seededCtx, () => renderTail(seededCtx));
|
|
537
613
|
}
|
|
538
614
|
return renderTail(reqCtx);
|
|
@@ -541,22 +617,51 @@ function serveShellHit(
|
|
|
541
617
|
// failure before the stream is pulled never surfaces as an unhandled rejection.
|
|
542
618
|
tailPromise.catch(() => {});
|
|
543
619
|
|
|
620
|
+
const serveStart = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
|
|
544
621
|
const body = new ReadableStream<Uint8Array>({
|
|
545
622
|
async start(controller) {
|
|
546
623
|
controller.enqueue(preludeBytes);
|
|
624
|
+
if (INTERNAL_RANGO_DEBUG) {
|
|
625
|
+
console.log(
|
|
626
|
+
`[Server][ppr] shell HIT: prelude enqueued (${preludeBytes.length}b) +${Math.round(performance.now() - serveStart)}ms`,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
547
629
|
try {
|
|
548
630
|
const tail = await tailPromise;
|
|
631
|
+
if (INTERNAL_RANGO_DEBUG) {
|
|
632
|
+
console.log(
|
|
633
|
+
`[Server][ppr] shell HIT: tail stream handed over +${Math.round(performance.now() - serveStart)}ms (abs ${Math.round(performance.now())})`,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
549
636
|
if (tail instanceof ReadableStream) {
|
|
550
637
|
const reader = tail.getReader();
|
|
638
|
+
let firstTailChunk = true;
|
|
639
|
+
let tailBytes = 0;
|
|
551
640
|
try {
|
|
552
641
|
for (;;) {
|
|
553
642
|
const { done, value } = await reader.read();
|
|
554
643
|
if (done) break;
|
|
644
|
+
if (INTERNAL_RANGO_DEBUG && firstTailChunk) {
|
|
645
|
+
firstTailChunk = false;
|
|
646
|
+
console.log(
|
|
647
|
+
`[Server][ppr] shell HIT: first tail chunk on the wire +${Math.round(performance.now() - serveStart)}ms (abs ${Math.round(performance.now())})`,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
if (INTERNAL_RANGO_DEBUG) tailBytes += value.length;
|
|
555
651
|
controller.enqueue(value);
|
|
556
652
|
}
|
|
557
653
|
} finally {
|
|
558
654
|
reader.releaseLock();
|
|
559
655
|
}
|
|
656
|
+
// Bounds the post-header work Server-Timing structurally cannot see:
|
|
657
|
+
// the HIT commits headers at the flush, so ALL live-tail time (match,
|
|
658
|
+
// loaders, Flight, resume) happens inside the response body. This
|
|
659
|
+
// line plus the [Server][segments] build logs narrate that window.
|
|
660
|
+
if (INTERNAL_RANGO_DEBUG) {
|
|
661
|
+
console.log(
|
|
662
|
+
`[Server][ppr] shell HIT: tail complete +${Math.round(performance.now() - serveStart)}ms (${tailBytes}b)`,
|
|
663
|
+
);
|
|
664
|
+
}
|
|
560
665
|
} else {
|
|
561
666
|
// Defensive, near-unreachable: a redirecting match cannot have captured
|
|
562
667
|
// a shell (capture bails on redirects), so a HIT on a redirecting URL
|
|
@@ -577,6 +682,24 @@ function serveShellHit(
|
|
|
577
682
|
}
|
|
578
683
|
controller.close();
|
|
579
684
|
} catch (error) {
|
|
685
|
+
// Self-heal on a failed tail: the pre-commit gates (isValidShellHit +
|
|
686
|
+
// hasIntactShellPayload) cannot catch a parseable-but-mismatched
|
|
687
|
+
// postponed blob or a hard render error above the holes — those throw
|
|
688
|
+
// here, AFTER the 200 + prelude flushed, and would otherwise re-fail on
|
|
689
|
+
// every request until the entry ages out (nothing else evicts it).
|
|
690
|
+
// Recapturing overwrites the entry with one the current server
|
|
691
|
+
// produced. A client disconnect mid-stream also lands here and
|
|
692
|
+
// schedules a spurious-but-idempotent recapture — bounded by the
|
|
693
|
+
// stampede guard + backoff inside scheduleShellCapture.
|
|
694
|
+
scheduleShellCapture(
|
|
695
|
+
ctx,
|
|
696
|
+
request,
|
|
697
|
+
env,
|
|
698
|
+
url,
|
|
699
|
+
reqCtx,
|
|
700
|
+
ssrModule,
|
|
701
|
+
descriptor,
|
|
702
|
+
);
|
|
580
703
|
controller.error(error);
|
|
581
704
|
}
|
|
582
705
|
},
|
package/src/rsc/shell-capture.ts
CHANGED
|
@@ -28,6 +28,12 @@ import {
|
|
|
28
28
|
type RequestContext,
|
|
29
29
|
} from "../server/request-context.js";
|
|
30
30
|
import { createHandleStore, type HandleStore } from "../server/handle-store.js";
|
|
31
|
+
import {
|
|
32
|
+
maskNestedContainerThenables,
|
|
33
|
+
type MaskReport,
|
|
34
|
+
} from "../router/segment-resolution/mask-nested.js";
|
|
35
|
+
import { isInsideLoaderScope } from "../server/context.js";
|
|
36
|
+
import { isThenable } from "../handles/is-thenable.js";
|
|
31
37
|
import type {
|
|
32
38
|
ShellCacheEntry,
|
|
33
39
|
SegmentCacheStore,
|
|
@@ -39,6 +45,7 @@ import {
|
|
|
39
45
|
} from "../router/segment-resolution/loader-snapshot.js";
|
|
40
46
|
import {
|
|
41
47
|
RecordingShellStore,
|
|
48
|
+
SnapshotOnlySegmentStore,
|
|
42
49
|
getRecordingStore,
|
|
43
50
|
} from "../cache/shell-snapshot.js";
|
|
44
51
|
import type { HandlerContext } from "./handler-context.js";
|
|
@@ -529,6 +536,13 @@ export function gateFlightForCapture(
|
|
|
529
536
|
*/
|
|
530
537
|
export interface ShellCaptureDescriptor {
|
|
531
538
|
key: string;
|
|
539
|
+
/**
|
|
540
|
+
* The RSC handler's build version (HandlerContext.version), stamped into the
|
|
541
|
+
* stored entry as ShellCacheEntry.buildVersion — the serve-side
|
|
542
|
+
* isValidShellHit gate compares it against the running build so a persistent
|
|
543
|
+
* store can never resume a stale build's postponed blob.
|
|
544
|
+
*/
|
|
545
|
+
buildVersion: string;
|
|
532
546
|
ttl?: number;
|
|
533
547
|
swr?: number;
|
|
534
548
|
tags?: string[];
|
|
@@ -692,6 +706,18 @@ async function runShellCapture(
|
|
|
692
706
|
return "no-shell";
|
|
693
707
|
}
|
|
694
708
|
|
|
709
|
+
/** Fold the capture's handle-liveness record into the entry flag (true | undefined). */
|
|
710
|
+
function handlerLayerIsLive(
|
|
711
|
+
liveness: RequestContext["_shellCaptureHandleLiveness"],
|
|
712
|
+
): true | undefined {
|
|
713
|
+
if (!liveness) return undefined;
|
|
714
|
+
return liveness.holes ||
|
|
715
|
+
liveness.pendingPushes > 0 ||
|
|
716
|
+
liveness.handlerInvokedLoader
|
|
717
|
+
? true
|
|
718
|
+
: undefined;
|
|
719
|
+
}
|
|
720
|
+
|
|
695
721
|
/**
|
|
696
722
|
* One capture attempt in a DERIVED request context.
|
|
697
723
|
*
|
|
@@ -744,10 +770,85 @@ async function attemptCapture(
|
|
|
744
770
|
|
|
745
771
|
const freshHandleStore = createHandleStore();
|
|
746
772
|
freshHandleStore.onError = reqCtx._handleStore.onError;
|
|
773
|
+
// Shape = liveness for handles, exactly as for bake-lane loader containers
|
|
774
|
+
// (mask-nested.ts): nested thenables in a pushed handle container are
|
|
775
|
+
// per-request by declaration, so the CAPTURE's copy masks them — the
|
|
776
|
+
// consuming boundary postpones as a hole regardless of settle timing,
|
|
777
|
+
// instead of a fast-settling nested value baking into the shared shell. A
|
|
778
|
+
// TOP-LEVEL promise push keeps its documented bake contract (awaited
|
|
779
|
+
// pre-SSR, gate held open for it), but the container it RESOLVES to gets
|
|
780
|
+
// the same nested masking. Wrapping THIS store's push is the single funnel:
|
|
781
|
+
// the store exists only for this capture attempt, so every push wrapper
|
|
782
|
+
// (setupLoaderAccess, createUseFunction, prerender) inherits the policy and
|
|
783
|
+
// the foreground store is untouched.
|
|
784
|
+
// Shell fast path bookkeeping on the same funnel:
|
|
785
|
+
// - handleLiveness: a nested thenable in a push made OUTSIDE a DSL loader
|
|
786
|
+
// scope (attribution read synchronously at push time — handler bodies,
|
|
787
|
+
// handler-invoked ctx.use(loader) callbacks, defers) declares
|
|
788
|
+
// handler-layer per-request data. Its mask is a hole only a handler
|
|
789
|
+
// re-run can fill, so the entry must not serve handler-free
|
|
790
|
+
// (ShellCacheEntry.handlerLiveHoles). Still-pending top-level handler
|
|
791
|
+
// pushes at the putShell barrier count too — their liveness is unknowable.
|
|
792
|
+
// - loaderScopedPushValues: DSL-loader pushes re-run fresh on every HIT, so
|
|
793
|
+
// their captured values must NOT enter a segment record's handle snapshot
|
|
794
|
+
// (replay would duplicate the fresh push, and their masked nested
|
|
795
|
+
// promises would stall the Flight handle encode to its timeout). The set
|
|
796
|
+
// rides the derived context (_shellCaptureLoaderHandleValues) and is
|
|
797
|
+
// applied ONLY at the captureHandles cache-write call site — every other
|
|
798
|
+
// getDataForSegment consumer (the render-barrier snapshot, prerender)
|
|
799
|
+
// sees every push.
|
|
800
|
+
const handleLiveness = {
|
|
801
|
+
holes: false,
|
|
802
|
+
pendingPushes: 0,
|
|
803
|
+
handlerInvokedLoader: false,
|
|
804
|
+
};
|
|
805
|
+
const loaderScopedPushValues = new WeakSet<object>();
|
|
806
|
+
const rawCapturePush = freshHandleStore.push.bind(freshHandleStore);
|
|
807
|
+
freshHandleStore.push = (
|
|
808
|
+
handleName: string,
|
|
809
|
+
segmentId: string,
|
|
810
|
+
value: unknown,
|
|
811
|
+
) => {
|
|
812
|
+
const pushedInLoaderScope = isInsideLoaderScope();
|
|
813
|
+
// Single walk: the mask reports whether it masked any nested thenable
|
|
814
|
+
// (the liveness declaration) while building the capture copy.
|
|
815
|
+
const maskWithLiveness = (v: unknown): unknown => {
|
|
816
|
+
const report: MaskReport = { thenable: false };
|
|
817
|
+
const masked = maskNestedContainerThenables(v, undefined, report);
|
|
818
|
+
if (!pushedInLoaderScope && report.thenable) {
|
|
819
|
+
handleLiveness.holes = true;
|
|
820
|
+
}
|
|
821
|
+
return masked;
|
|
822
|
+
};
|
|
823
|
+
let masked: unknown;
|
|
824
|
+
if (isThenable(value)) {
|
|
825
|
+
if (!pushedInLoaderScope) {
|
|
826
|
+
handleLiveness.pendingPushes++;
|
|
827
|
+
const settle = () => handleLiveness.pendingPushes--;
|
|
828
|
+
value.then(settle, settle);
|
|
829
|
+
}
|
|
830
|
+
masked = value.then(maskWithLiveness);
|
|
831
|
+
} else {
|
|
832
|
+
masked = maskWithLiveness(value);
|
|
833
|
+
}
|
|
834
|
+
if (pushedInLoaderScope && typeof masked === "object" && masked !== null) {
|
|
835
|
+
loaderScopedPushValues.add(masked);
|
|
836
|
+
}
|
|
837
|
+
rawCapturePush(handleName, segmentId, masked);
|
|
838
|
+
};
|
|
747
839
|
|
|
748
840
|
const derivedCtx: RequestContext = Object.create(reqCtx);
|
|
749
841
|
derivedCtx._handleStore = freshHandleStore;
|
|
842
|
+
derivedCtx._shellCaptureLoaderHandleValues = loaderScopedPushValues;
|
|
750
843
|
derivedCtx._requestTags = new Set<string>();
|
|
844
|
+
// Own explicit-store registry: cache-store resolutions during the capture
|
|
845
|
+
// (the implicit scope's SnapshotOnlySegmentStore, any per-capture explicit
|
|
846
|
+
// store instance) must NOT register into the handler-lifetime
|
|
847
|
+
// _explicitTaggedStores set — a capture-ephemeral store pinned there would
|
|
848
|
+
// trip the partial-tag-store warning on every later updateTag() and retain
|
|
849
|
+
// the whole capture snapshot in memory. Capture registrations die with this
|
|
850
|
+
// context; module-singleton stores stay registered by normal renders.
|
|
851
|
+
derivedCtx._explicitTaggedStores = new Set();
|
|
751
852
|
derivedCtx._transitionWhen = [];
|
|
752
853
|
derivedCtx._shellCaptureRun = true;
|
|
753
854
|
derivedCtx._metricsStore = undefined;
|
|
@@ -781,6 +882,7 @@ async function attemptCapture(
|
|
|
781
882
|
// forwarding to the parent so the write persists and the worker stays alive),
|
|
782
883
|
// then captureAndStoreShell awaits them before draining. Reads that HIT are
|
|
783
884
|
// recorded synchronously during the render and need none of this.
|
|
885
|
+
derivedCtx._shellCaptureHandleLiveness = handleLiveness;
|
|
784
886
|
if (reqCtx._cacheStore) {
|
|
785
887
|
const recordingStore = new RecordingShellStore(reqCtx._cacheStore);
|
|
786
888
|
derivedCtx._cacheStore = recordingStore;
|
|
@@ -789,6 +891,17 @@ async function attemptCapture(
|
|
|
789
891
|
recordingStore.trackWrite(p);
|
|
790
892
|
reqCtx.waitUntil(() => p);
|
|
791
893
|
};
|
|
894
|
+
// Shell fast path (capture side): the implicit doc-cache scope makes the
|
|
895
|
+
// capture's match write ALL matched non-loader segments as one doc-keyed
|
|
896
|
+
// segment record — into the snapshot only (SnapshotOnlySegmentStore), so
|
|
897
|
+
// the record dies with the shell entry and the next capture's lookup
|
|
898
|
+
// still misses (handlers re-run on recapture). Routes deriving their own
|
|
899
|
+
// cache scope are untouched (resolveShellImplicitCacheScope).
|
|
900
|
+
derivedCtx._shellImplicitCache = {
|
|
901
|
+
ttl: descriptor.ttl,
|
|
902
|
+
swr: descriptor.swr,
|
|
903
|
+
store: new SnapshotOnlySegmentStore(recordingStore),
|
|
904
|
+
};
|
|
792
905
|
}
|
|
793
906
|
|
|
794
907
|
return runWithRequestContext(derivedCtx, async () => {
|
|
@@ -1104,12 +1217,21 @@ async function captureAndStoreShell(
|
|
|
1104
1217
|
prelude: bufferToBase64(result.prelude.slice().buffer as ArrayBuffer),
|
|
1105
1218
|
postponed: result.postponed,
|
|
1106
1219
|
reactVersion: React.version,
|
|
1220
|
+
buildVersion: capture.buildVersion,
|
|
1107
1221
|
// The theme this capture's payload was built with (buildFullPayload
|
|
1108
1222
|
// reads reqCtx.theme off the derived context). The serve tail replays
|
|
1109
1223
|
// it so the resume tree matches the frozen prelude — see
|
|
1110
1224
|
// ShellCacheEntry.initialTheme.
|
|
1111
1225
|
initialTheme: reqCtx.theme,
|
|
1112
1226
|
snapshot,
|
|
1227
|
+
// Handler-layer liveness folded at the barrier: nested thenables in
|
|
1228
|
+
// handler-scoped pushes, handler pushes still pending (liveness
|
|
1229
|
+
// unknowable), or a handler-invoked loader execution — any of them
|
|
1230
|
+
// refuses the FAST PATH, not the capture. See
|
|
1231
|
+
// _shellCaptureHandleLiveness.
|
|
1232
|
+
handlerLiveHoles: handlerLayerIsLive(
|
|
1233
|
+
reqCtx._shellCaptureHandleLiveness,
|
|
1234
|
+
),
|
|
1113
1235
|
createdAt: Date.now(),
|
|
1114
1236
|
};
|
|
1115
1237
|
await store.putShell(
|
package/src/rsc/shell-serve.ts
CHANGED
|
@@ -75,13 +75,44 @@ export function buildShellKey(url: URL): string {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
78
|
+
* Version gates for a stored shell: reactVersion AND buildVersion must both
|
|
79
|
+
* match the running server. The postponed blob encodes hole positions against
|
|
80
|
+
* one exact tree, so resuming it under a different React OR a different app
|
|
81
|
+
* build tree-mismatches inside resume() — after the 200 + prelude committed,
|
|
82
|
+
* with no recovery. Either mismatch is a miss: the recapture overwrites the
|
|
83
|
+
* same key (self-healing) and the entry otherwise ages out via TTL. An entry
|
|
84
|
+
* with no buildVersion (stored before the field existed) is a miss for the
|
|
85
|
+
* same reason — its build is unknown, so it cannot be proven resumable.
|
|
82
86
|
*/
|
|
83
|
-
export function isValidShellHit(
|
|
84
|
-
|
|
87
|
+
export function isValidShellHit(
|
|
88
|
+
entry: ShellCacheEntry,
|
|
89
|
+
buildVersion: string,
|
|
90
|
+
): boolean {
|
|
91
|
+
return (
|
|
92
|
+
entry.reactVersion === React.version && entry.buildVersion === buildVersion
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Payload integrity gate, run BEFORE the HIT response commits: a stored entry
|
|
98
|
+
* whose prelude is not decodable base64 or whose postponed blob is not
|
|
99
|
+
* parseable JSON would otherwise throw AFTER the 200 + full static prelude
|
|
100
|
+
* flushed (`serveShellHit` decodes at stream construction, `resumeShellHTML`
|
|
101
|
+
* parses in the tail) — the client gets a visually complete page that never
|
|
102
|
+
* hydrates, re-served on every request until the entry ages out (no eviction
|
|
103
|
+
* path exists; failure schedules no recapture by itself). Checking here turns
|
|
104
|
+
* a corrupt entry (store-layer fault) into a plain MISS the recapture
|
|
105
|
+
* overwrites. Cost: one duplicate decode/parse per HIT, sub-ms against a
|
|
106
|
+
* prelude flush that dominates the path.
|
|
107
|
+
*/
|
|
108
|
+
export function hasIntactShellPayload(entry: ShellCacheEntry): boolean {
|
|
109
|
+
try {
|
|
110
|
+
base64ToBytes(entry.prelude);
|
|
111
|
+
if (entry.postponed !== null) JSON.parse(entry.postponed);
|
|
112
|
+
return true;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
85
116
|
}
|
|
86
117
|
|
|
87
118
|
/** Decode a base64 prelude back into bytes for stream composition. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ResolvedSegment } from "./types.js";
|
|
2
|
+
import { INTERNAL_RANGO_DEBUG } from "./internal-debug.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Cache of aggregate Promise.all results keyed on the first loader's
|
|
@@ -83,6 +84,23 @@ export function buildLoaderPromise(loaders: ResolvedSegment[]): Promise<any[]> {
|
|
|
83
84
|
if (loaders.length === 0) {
|
|
84
85
|
return Promise.resolve([]);
|
|
85
86
|
}
|
|
87
|
+
// Debug tap (browser only): log when each PENDING loader promise settles —
|
|
88
|
+
// i.e. when its data actually lands from the flight stream — independent of
|
|
89
|
+
// when the tree build awaits it. `.then(cb, cb)` observes on a branch, so
|
|
90
|
+
// rejections still propagate to the real consumers untouched.
|
|
91
|
+
if (INTERNAL_RANGO_DEBUG && IS_BROWSER) {
|
|
92
|
+
const tapStart = performance.now();
|
|
93
|
+
for (const loader of loaders) {
|
|
94
|
+
if (loader.loaderData instanceof Promise) {
|
|
95
|
+
const settle = (outcome: string) => () =>
|
|
96
|
+
console.log(
|
|
97
|
+
`[Browser][segments] loader ${loader.loaderId} ${outcome} @ ${Math.round(performance.now())}ms`,
|
|
98
|
+
{ msSinceRequested: Math.round(performance.now() - tapStart) },
|
|
99
|
+
);
|
|
100
|
+
loader.loaderData.then(settle("settled"), settle("rejected"));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
86
104
|
return Promise.all(
|
|
87
105
|
loaders.map((loader) =>
|
|
88
106
|
loader.loaderData instanceof Promise
|