@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
@@ -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
- // Stale (SWR) hit: serve the stale shell now, recapture in the
182
- // background (stampede-guarded + backoff inside scheduleShellCapture).
183
- if (cached.shouldRevalidate) {
184
- scheduleShellCapture(
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
- const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
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
  },
@@ -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(
@@ -75,13 +75,44 @@ export function buildShellKey(url: URL): string {
75
75
  }
76
76
 
77
77
  /**
78
- * React version captured at prerender time is the invalidation gate: a stored
79
- * shell whose reactVersion differs from the running React cannot be resumed (the
80
- * postponed blob is build-coupled), so it is treated as a miss — the recapture
81
- * overwrites the same key and the entry otherwise ages out via TTL.
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(entry: ShellCacheEntry): boolean {
84
- return entry.reactVersion === React.version;
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