@rangojs/router 0.0.0-experimental.143 → 0.0.0-experimental.145

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 (51) hide show
  1. package/dist/vite/index.js +24 -6
  2. package/package.json +2 -2
  3. package/skills/cache-guide/SKILL.md +3 -1
  4. package/skills/caching/SKILL.md +23 -2
  5. package/skills/catalog.json +6 -0
  6. package/skills/defer-hydration/SKILL.md +235 -0
  7. package/skills/loader/SKILL.md +5 -0
  8. package/skills/migrate-nextjs/SKILL.md +4 -2
  9. package/skills/parallel/SKILL.md +2 -0
  10. package/skills/ppr/SKILL.md +63 -33
  11. package/skills/rango/SKILL.md +10 -0
  12. package/skills/use-cache/SKILL.md +12 -2
  13. package/src/browser/logging.ts +18 -0
  14. package/src/browser/partial-update.ts +7 -0
  15. package/src/browser/rsc-router.tsx +43 -0
  16. package/src/cache/cache-key-utils.ts +29 -0
  17. package/src/cache/cache-runtime.ts +41 -51
  18. package/src/cache/cache-scope.ts +2 -17
  19. package/src/cache/cache-tag.ts +60 -14
  20. package/src/cache/cf/cf-cache-store.ts +58 -20
  21. package/src/cache/document-cache.ts +17 -11
  22. package/src/cache/types.ts +18 -4
  23. package/src/cache/vercel/vercel-cache-store.ts +15 -20
  24. package/src/redirect-origin.ts +14 -0
  25. package/src/route-map-builder.ts +17 -3
  26. package/src/router/lazy-includes.ts +8 -2
  27. package/src/router/loader-resolution.ts +14 -2
  28. package/src/router/match-handlers.ts +11 -6
  29. package/src/router/middleware.ts +4 -1
  30. package/src/router/segment-resolution/loader-cache.ts +19 -3
  31. package/src/router/segment-resolution/loader-mask.ts +4 -11
  32. package/src/router/segment-resolution/loader-snapshot.ts +14 -6
  33. package/src/router/segment-resolution/mask-nested.ts +83 -0
  34. package/src/router/telemetry.ts +9 -1
  35. package/src/router.ts +7 -8
  36. package/src/rsc/handler.ts +9 -2
  37. package/src/rsc/redirect-guard.ts +2 -1
  38. package/src/rsc/rsc-rendering.ts +122 -18
  39. package/src/rsc/shell-capture.ts +125 -20
  40. package/src/rsc/shell-serve.ts +37 -6
  41. package/src/segment-loader-promise.ts +18 -0
  42. package/src/segment-system.tsx +90 -6
  43. package/src/server/context.ts +47 -9
  44. package/src/server/cookie-store.ts +26 -5
  45. package/src/server/request-context.ts +22 -0
  46. package/src/ssr/index.tsx +160 -113
  47. package/src/ssr/inject-rsc-eager.ts +167 -0
  48. package/src/testing/dispatch.ts +7 -0
  49. package/src/vite/index.ts +7 -0
  50. package/src/vite/inject-client-debug.ts +64 -12
  51. package/src/vite/router-discovery.ts +9 -1
@@ -38,12 +38,17 @@ import {
38
38
  resolvePprConfig,
39
39
  buildShellKey,
40
40
  isValidShellHit,
41
+ hasIntactShellPayload,
41
42
  base64ToBytes,
42
43
  hasShellFamily,
43
44
  warnShellStoreMissingOnce,
44
45
  warnPprNonceActiveOnce,
45
46
  } from "./shell-serve.js";
46
47
  import { contextGet } from "../context-var.js";
48
+ import {
49
+ resolveSameOriginRedirect,
50
+ safeSameOriginLanding,
51
+ } from "../redirect-origin.js";
47
52
  import { nonce as nonceToken } from "./nonce.js";
48
53
  import { reportCacheError } from "../cache/cache-error.js";
49
54
  import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
@@ -160,6 +165,7 @@ async function handleRscRenderingInner<TEnv>(
160
165
  ) {
161
166
  const descriptor: ShellCaptureDescriptor = {
162
167
  key,
168
+ buildVersion: ctx.version,
163
169
  ttl: pprConfig.ttl,
164
170
  swr: pprConfig.swr,
165
171
  tags: pprConfig.tags,
@@ -173,30 +179,46 @@ async function handleRscRenderingInner<TEnv>(
173
179
  // A failing store read degrades to axis 1 (MISS), never a 500.
174
180
  reportCacheError(error, "cache-read", "[ShellServe] getShell");
175
181
  }
176
- if (cached && isValidShellHit(cached.entry)) {
177
- // Stale (SWR) hit: serve the stale shell now, recapture in the
178
- // background (stampede-guarded + backoff inside scheduleShellCapture).
179
- if (cached.shouldRevalidate) {
180
- 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(
181
211
  ctx,
182
212
  request,
183
213
  env,
184
214
  url,
185
215
  reqCtx,
216
+ handleStore,
186
217
  ssrModule,
218
+ cached.entry,
187
219
  descriptor,
188
220
  );
189
221
  }
190
- return serveShellHit(
191
- ctx,
192
- request,
193
- env,
194
- url,
195
- reqCtx,
196
- handleStore,
197
- ssrModule,
198
- cached.entry,
199
- );
200
222
  }
201
223
  // MISS (no entry, invalid reactVersion, or store read failure): axis 1
202
224
  // + a background capture scheduled once the response is known servable.
@@ -413,6 +435,29 @@ async function handleRscRenderingInner<TEnv>(
413
435
  return response;
414
436
  }
415
437
 
438
+ /**
439
+ * Neutralize the shell-HIT degradation redirect target.
440
+ *
441
+ * The inline `location.replace` emitted by serveShellHit when a shell HIT lands
442
+ * on a URL whose route became redirecting mid-TTL is a document-native redirect
443
+ * exit that BYPASSES the 3xx chokepoint (guardOutgoingRedirect acts only on 3xx
444
+ * + Location responses, never a committed 200 body). So it reuses the ONE
445
+ * same-origin resolver directly: a cross-origin/unparseable/unsafe target
446
+ * neutralizes to the same safe same-origin landing as redirect-guard.ts
447
+ * (basename root, or "/" when unset) rather than navigating the user off-host.
448
+ * A safe same-origin/relative target passes through as its normalized href.
449
+ */
450
+ export function resolveShellHitRedirectTarget(
451
+ rawTarget: string,
452
+ requestOrigin: string,
453
+ basename: string | undefined,
454
+ ): string {
455
+ return (
456
+ resolveSameOriginRedirect(rawTarget, requestOrigin) ??
457
+ safeSameOriginLanding(basename)
458
+ );
459
+ }
460
+
416
461
  /**
417
462
  * Serve a validated shell HIT: commit the composed response NOW — the stored
418
463
  * prelude bytes are the first thing on the wire — and run the live tail
@@ -441,6 +486,7 @@ function serveShellHit(
441
486
  handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
442
487
  ssrModule: SSRModule,
443
488
  entry: ShellCacheEntry,
489
+ descriptor: ShellCaptureDescriptor,
444
490
  ): Response {
445
491
  const preludeBytes = base64ToBytes(entry.prelude);
446
492
 
@@ -466,11 +512,32 @@ function serveShellHit(
466
512
  }
467
513
  // Full Flight render per request: hydration needs the whole payload (there
468
514
  // is no Flight-side resume — a React limitation, not ours).
469
- const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
515
+ let rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
470
516
  onError: (error: unknown) => {
471
517
  ctx.callOnError(error, "rendering", { request, url, env });
472
518
  },
473
519
  });
520
+ // Timing tap: when does the Flight render produce its FIRST byte? Compared
521
+ // with the eager-inject/first-tail logs this proves whether hydration-start
522
+ // latency is genuine server work (loaders) or stream plumbing holding
523
+ // ready bytes back.
524
+ if (INTERNAL_RANGO_DEBUG) {
525
+ const tapStart = performance.now();
526
+ let first = false;
527
+ rscStream = rscStream.pipeThrough(
528
+ new TransformStream({
529
+ transform(chunk, controller) {
530
+ if (!first) {
531
+ first = true;
532
+ console.log(
533
+ `[Server][ppr] flight render: first chunk +${Math.round(performance.now() - tapStart)}ms`,
534
+ );
535
+ }
536
+ controller.enqueue(chunk);
537
+ },
538
+ }),
539
+ );
540
+ }
474
541
  return observePhase(PHASES.ssr, () =>
475
542
  ssrModule.resumeShellHTML!(rscStream, {
476
543
  postponed: entry.postponed,
@@ -514,17 +581,30 @@ function serveShellHit(
514
581
  // failure before the stream is pulled never surfaces as an unhandled rejection.
515
582
  tailPromise.catch(() => {});
516
583
 
584
+ const serveStart = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
517
585
  const body = new ReadableStream<Uint8Array>({
518
586
  async start(controller) {
519
587
  controller.enqueue(preludeBytes);
588
+ if (INTERNAL_RANGO_DEBUG) {
589
+ console.log(
590
+ `[Server][ppr] shell HIT: prelude enqueued (${preludeBytes.length}b) +${Math.round(performance.now() - serveStart)}ms`,
591
+ );
592
+ }
520
593
  try {
521
594
  const tail = await tailPromise;
522
595
  if (tail instanceof ReadableStream) {
523
596
  const reader = tail.getReader();
597
+ let firstTailChunk = true;
524
598
  try {
525
599
  for (;;) {
526
600
  const { done, value } = await reader.read();
527
601
  if (done) break;
602
+ if (INTERNAL_RANGO_DEBUG && firstTailChunk) {
603
+ firstTailChunk = false;
604
+ console.log(
605
+ `[Server][ppr] shell HIT: first tail chunk on the wire +${Math.round(performance.now() - serveStart)}ms`,
606
+ );
607
+ }
528
608
  controller.enqueue(value);
529
609
  }
530
610
  } finally {
@@ -535,15 +615,39 @@ function serveShellHit(
535
615
  // a shell (capture bails on redirects), so a HIT on a redirecting URL
536
616
  // requires the route to have BECOME redirecting within the shell TTL.
537
617
  // The 200 + prelude are already committed; degrade to a client-side
538
- // replace so the user still lands on the target.
618
+ // replace so the user still lands on the target. The target is
619
+ // neutralized first (see resolveShellHitRedirectTarget).
620
+ const safeTarget = resolveShellHitRedirectTarget(
621
+ tail.redirect,
622
+ url.origin,
623
+ ctx.router.basename,
624
+ );
539
625
  controller.enqueue(
540
626
  new TextEncoder().encode(
541
- `<script>location.replace(${JSON.stringify(tail.redirect)})</script>`,
627
+ `<script>location.replace(${JSON.stringify(safeTarget)})</script>`,
542
628
  ),
543
629
  );
544
630
  }
545
631
  controller.close();
546
632
  } catch (error) {
633
+ // Self-heal on a failed tail: the pre-commit gates (isValidShellHit +
634
+ // hasIntactShellPayload) cannot catch a parseable-but-mismatched
635
+ // postponed blob or a hard render error above the holes — those throw
636
+ // here, AFTER the 200 + prelude flushed, and would otherwise re-fail on
637
+ // every request until the entry ages out (nothing else evicts it).
638
+ // Recapturing overwrites the entry with one the current server
639
+ // produced. A client disconnect mid-stream also lands here and
640
+ // schedules a spurious-but-idempotent recapture — bounded by the
641
+ // stampede guard + backoff inside scheduleShellCapture.
642
+ scheduleShellCapture(
643
+ ctx,
644
+ request,
645
+ env,
646
+ url,
647
+ reqCtx,
648
+ ssrModule,
649
+ descriptor,
650
+ );
547
651
  controller.error(error);
548
652
  }
549
653
  },
@@ -28,6 +28,8 @@ 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 { maskNestedContainerThenables } from "../router/segment-resolution/mask-nested.js";
32
+ import { isThenable } from "../handles/is-thenable.js";
31
33
  import type {
32
34
  ShellCacheEntry,
33
35
  SegmentCacheStore,
@@ -327,6 +329,39 @@ function warnCaptureRefusedOnce(key: string, reason: string): void {
327
329
  );
328
330
  }
329
331
 
332
+ /** Keys already warned about untagged bake-lane data baked into the shell. */
333
+ const warnedUntaggedShellBakes = new Set<string>();
334
+
335
+ /**
336
+ * Warn once per shell key that a bake-lane loader baked material into the shell
337
+ * but the capture recorded ZERO tags (no render-collected _requestTags, no
338
+ * static ppr.tags). Such data is frozen in the shared shell until TTL and is
339
+ * un-evictable by tag: a server action refreshes the CLIENT only (rotates Rango
340
+ * state, busts the browser HTTP cache) and never touches the server shell store,
341
+ * and updateTag()/revalidateTag() cannot drop data that was baked WITHOUT a tag.
342
+ *
343
+ * Coarse per-shell-key signal, not per-loader attribution — the tag set is
344
+ * unioned globally at the write barrier, not tracked per loader, so we cannot
345
+ * name the offending loader without adding attribution plumbing (deliberately
346
+ * not done). Dev-only + once-per-key so it never spams production or fires on
347
+ * every capture.
348
+ */
349
+ function warnUntaggedShellBakeOnce(key: string): void {
350
+ if (warnedUntaggedShellBakes.has(key)) return;
351
+ warnedUntaggedShellBakes.add(key);
352
+ console.warn(
353
+ `[rango] Shell capture for "${key}" baked bake-lane loader data into the ` +
354
+ "shell with NO cache tag. That data is frozen in the shared shell until " +
355
+ "TTL and cannot be tag-invalidated: a server action refresh touches the " +
356
+ "client only (not the server shell store), and updateTag() cannot evict " +
357
+ "data that was baked without a tag.\n" +
358
+ 'Fix: tag the data (cacheTag() / "use cache" / cache({ tags })) so ' +
359
+ "updateTag() drops the shell, or move the volatile read under a loading() " +
360
+ "hole so it stays on the live lane and is never baked. See the /ppr skill " +
361
+ "(node_modules/@rangojs/router/skills/ppr/SKILL.md).",
362
+ );
363
+ }
364
+
330
365
  export interface FlightCaptureGate {
331
366
  /** Identity passthrough of the source stream; feed this to captureShellHTML. */
332
367
  stream: ReadableStream<Uint8Array>;
@@ -487,12 +522,22 @@ export function gateFlightForCapture(
487
522
  * store, and passed to scheduleShellCapture directly — it is NOT threaded through
488
523
  * the request context. `tags` carries the route's OPERATIONAL `ppr.tags`; the
489
524
  * capture UNIONS them with the shell's own auto-collected (non-loader) request
490
- * tags from its derived render (the collected set stays authoritative). `store`
491
- * is the same store the serve path resolved for its getShell read
492
- * (requestCtx._cacheStore), so the capture writes where the serve reads.
525
+ * tags from its derived render (the collected set stays authoritative). That
526
+ * union happens at the putShell WRITE BARRIER in captureAndStoreShell — after the
527
+ * capture quiesces — not at stream construction, so a tag recorded after an await
528
+ * in async shell content is still collected (issue #676). `store` is the same
529
+ * store the serve path resolved for its getShell read (requestCtx._cacheStore),
530
+ * so the capture writes where the serve reads.
493
531
  */
494
532
  export interface ShellCaptureDescriptor {
495
533
  key: string;
534
+ /**
535
+ * The RSC handler's build version (HandlerContext.version), stamped into the
536
+ * stored entry as ShellCacheEntry.buildVersion — the serve-side
537
+ * isValidShellHit gate compares it against the running build so a persistent
538
+ * store can never resume a stale build's postponed blob.
539
+ */
540
+ buildVersion: string;
496
541
  ttl?: number;
497
542
  swr?: number;
498
543
  tags?: string[];
@@ -708,6 +753,28 @@ async function attemptCapture(
708
753
 
709
754
  const freshHandleStore = createHandleStore();
710
755
  freshHandleStore.onError = reqCtx._handleStore.onError;
756
+ // Shape = liveness for handles, exactly as for bake-lane loader containers
757
+ // (mask-nested.ts): nested thenables in a pushed handle container are
758
+ // per-request by declaration, so the CAPTURE's copy masks them — the
759
+ // consuming boundary postpones as a hole regardless of settle timing,
760
+ // instead of a fast-settling nested value baking into the shared shell. A
761
+ // TOP-LEVEL promise push keeps its documented bake contract (awaited
762
+ // pre-SSR, gate held open for it), but the container it RESOLVES to gets
763
+ // the same nested masking. Wrapping THIS store's push is the single funnel:
764
+ // the store exists only for this capture attempt, so every push wrapper
765
+ // (setupLoaderAccess, createUseFunction, prerender) inherits the policy and
766
+ // the foreground store is untouched.
767
+ const rawCapturePush = freshHandleStore.push.bind(freshHandleStore);
768
+ freshHandleStore.push = (
769
+ handleName: string,
770
+ segmentId: string,
771
+ value: unknown,
772
+ ) => {
773
+ const masked = isThenable(value)
774
+ ? value.then((v: unknown) => maskNestedContainerThenables(v))
775
+ : maskNestedContainerThenables(value);
776
+ rawCapturePush(handleName, segmentId, masked);
777
+ };
711
778
 
712
779
  const derivedCtx: RequestContext = Object.create(reqCtx);
713
780
  derivedCtx._handleStore = freshHandleStore;
@@ -776,24 +843,18 @@ async function attemptCapture(
776
843
  },
777
844
  });
778
845
 
779
- // Shell tags = the non-loader request tags the capture render recorded on its
780
- // own fresh _requestTags (loaders are masked, so loader cache tags — which
781
- // belong to the holes, not the shell — are correctly excluded), UNIONED with
782
- // the middleware's operational `tags` option (descriptor.tags). The collected
783
- // set is authoritative; the option only adds tags the render cannot know.
784
- const collected = [...derivedCtx._requestTags];
785
- const union = new Set<string>([...(descriptor.tags ?? []), ...collected]);
786
- const tags = union.size > 0 ? [...union] : undefined;
787
-
846
+ // Pass the descriptor with its STATIC ppr.tags unchanged. The shell's own
847
+ // render-recorded tags are snapshotted at the putShell WRITE BARRIER inside
848
+ // captureAndStoreShell, not here: a tag recorded AFTER an await in async shell
849
+ // content (and tags propagated by async cache()/"use cache" reads) lands after
850
+ // this synchronous construction point, so snapshotting here dropped it — the
851
+ // shell-tag snapshot must sit behind the quiesce gate (issue #676).
788
852
  return captureAndStoreShell(
789
853
  ssrModule,
790
854
  rscStream,
791
855
  freshHandleStore,
792
856
  derivedCtx,
793
- {
794
- ...descriptor,
795
- tags,
796
- },
857
+ descriptor,
797
858
  );
798
859
  });
799
860
  }
@@ -878,11 +939,25 @@ async function captureAndStoreShell(
878
939
  const refuseOnGuardTrip = (): "refused" | undefined => {
879
940
  const fnName = reqCtx._shellCaptureGuardTripped;
880
941
  if (!fnName) return undefined;
942
+ // Name the recorded source instead of hardcoding a lane. Under the
943
+ // consumption-lane rule, handler-INVOKED loader bodies are exempt from
944
+ // the guard (their value is a baked shared copy, mirroring cache()), so a
945
+ // trip can only come from a bake-lane SEGMENT loader or from non-loader
946
+ // handler/render code — the old blanket "bake-lane loader" attribution
947
+ // sent a live-lane debugging session down the wrong lane (issue #672).
948
+ const loaderId = reqCtx._shellCaptureGuardTrippedLoaderId;
949
+ const origin = loaderId
950
+ ? `segment loader "${loaderId}"`
951
+ : "handler/render code (no loader body was executing)";
881
952
  warnCaptureRefusedOnce(
882
953
  capture.key,
883
- `a bake-lane loader (a loader on an entry with no loading()) called ${fnName}() during capture. ` +
884
- "Identity must not bake into a shared shell. Give that loader's entry a loading() boundary " +
885
- "(the live lane, exempt from the guard) or move the identity-dependent part into a nested promise.",
954
+ `${origin} called ${fnName}() during capture. Identity must not bake into a shared shell. ` +
955
+ "For a segment loader (bake lane, no loading()): give its entry a loading() boundary " +
956
+ "(the live lane, masked at capture) or move the identity-dependent part into a nested " +
957
+ "promise. For handler/render code: keep the value live by consuming a loader " +
958
+ 'client-side (useLoader in a "use client" component). Note: `await ctx.use(loader)` ' +
959
+ "inside a HANDLER is exempt from this guard — its value bakes into the shared shell " +
960
+ "as a capture-time copy, mirroring cache() semantics (the consumption-lane rule).",
886
961
  );
887
962
  return "refused";
888
963
  };
@@ -981,6 +1056,10 @@ async function captureAndStoreShell(
981
1056
  // gate above already returned no-shell) or postponed under an ANCESTOR
982
1057
  // boundary (it is a hole; omitting the record keeps it live).
983
1058
  const loaderRecords = reqCtx._shellCaptureLoaderRecords;
1059
+ // Set once a bake-lane loader settles with real (non-hole) material: its data
1060
+ // is frozen into the shell prelude regardless of whether snapshot
1061
+ // serialization succeeds. Drives the untagged-bake dev warning below.
1062
+ let bakedLoaderMaterial = false;
984
1063
  if (loaderRecords && loaderRecords.size > 0) {
985
1064
  // The codec import is deferred past the elide probes: a rejected record
986
1065
  // refuses and a never-settled record is omitted WITHOUT touching Flight
@@ -1001,6 +1080,9 @@ async function captureAndStoreShell(
1001
1080
  // The container itself never settled: it is a hole (under an ancestor
1002
1081
  // boundary) or the trivial-prelude gate already fired. Omit — no pin.
1003
1082
  if (isLoaderHoleMarker(elided.value)) continue;
1083
+ // Past the hole check: this container settled with real material that
1084
+ // bakes into the shell prelude (independent of the snapshot pin below).
1085
+ bakedLoaderMaterial = true;
1004
1086
  try {
1005
1087
  // serializeResult (not rscSerialize): null is a valid container and
1006
1088
  // must round-trip; serializeResult preserves it through Flight.
@@ -1021,6 +1103,28 @@ async function captureAndStoreShell(
1021
1103
  }
1022
1104
  }
1023
1105
 
1106
+ // Shell tags snapshot at the WRITE BARRIER, not at stream construction: by
1107
+ // here the capture has quiesced and the deferred cache writes were awaited, so
1108
+ // tags recorded AFTER an await in async shell content (and by async
1109
+ // cache()/"use cache" reads propagating through recordRequestTags) are
1110
+ // included — issue #676. Loaders are masked, so loader cache tags — which
1111
+ // belong to the holes, not the shell — never execute during capture and cannot
1112
+ // contribute. Union with the route's static ppr.tags (capture.tags); the
1113
+ // collected set is authoritative, the option only adds what the render cannot
1114
+ // know.
1115
+ const collected = [...reqCtx._requestTags];
1116
+ const union = new Set<string>([...(capture.tags ?? []), ...collected]);
1117
+ const shellTags = union.size > 0 ? [...union] : undefined;
1118
+
1119
+ // Untagged-bake diagnostic: a bake-lane loader froze mutable data into the
1120
+ // shell but nothing tags the entry, so it is un-invalidatable except by TTL —
1121
+ // a read-your-own-writes gap on the document channel (an action refresh skips
1122
+ // the server shell; updateTag cannot drop untagged data). Coarse per-shell-key
1123
+ // signal; dev-only and once-per-key so it never spams production.
1124
+ if (isDevMode() && bakedLoaderMaterial && shellTags === undefined) {
1125
+ warnUntaggedShellBakeOnce(capture.key);
1126
+ }
1127
+
1024
1128
  const store = capture.store ?? reqCtx._cacheStore;
1025
1129
  if (store?.putShell) {
1026
1130
  try {
@@ -1031,6 +1135,7 @@ async function captureAndStoreShell(
1031
1135
  prelude: bufferToBase64(result.prelude.slice().buffer as ArrayBuffer),
1032
1136
  postponed: result.postponed,
1033
1137
  reactVersion: React.version,
1138
+ buildVersion: capture.buildVersion,
1034
1139
  // The theme this capture's payload was built with (buildFullPayload
1035
1140
  // reads reqCtx.theme off the derived context). The serve tail replays
1036
1141
  // it so the resume tree matches the frozen prelude — see
@@ -1044,7 +1149,7 @@ async function captureAndStoreShell(
1044
1149
  entry,
1045
1150
  capture.ttl,
1046
1151
  capture.swr,
1047
- capture.tags,
1152
+ shellTags,
1048
1153
  );
1049
1154
  } catch (error) {
1050
1155
  // Best-effort: a failed put must never throw out of the background task.
@@ -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