@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.
- package/dist/vite/index.js +24 -6
- package/package.json +2 -2
- package/skills/cache-guide/SKILL.md +3 -1
- package/skills/caching/SKILL.md +23 -2
- package/skills/catalog.json +6 -0
- package/skills/defer-hydration/SKILL.md +235 -0
- package/skills/loader/SKILL.md +5 -0
- package/skills/migrate-nextjs/SKILL.md +4 -2
- package/skills/parallel/SKILL.md +2 -0
- package/skills/ppr/SKILL.md +63 -33
- package/skills/rango/SKILL.md +10 -0
- package/skills/use-cache/SKILL.md +12 -2
- package/src/browser/logging.ts +18 -0
- package/src/browser/partial-update.ts +7 -0
- package/src/browser/rsc-router.tsx +43 -0
- package/src/cache/cache-key-utils.ts +29 -0
- package/src/cache/cache-runtime.ts +41 -51
- package/src/cache/cache-scope.ts +2 -17
- package/src/cache/cache-tag.ts +60 -14
- package/src/cache/cf/cf-cache-store.ts +58 -20
- package/src/cache/document-cache.ts +17 -11
- package/src/cache/types.ts +18 -4
- package/src/cache/vercel/vercel-cache-store.ts +15 -20
- package/src/redirect-origin.ts +14 -0
- package/src/route-map-builder.ts +17 -3
- package/src/router/lazy-includes.ts +8 -2
- package/src/router/loader-resolution.ts +14 -2
- package/src/router/match-handlers.ts +11 -6
- package/src/router/middleware.ts +4 -1
- 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 +83 -0
- package/src/router/telemetry.ts +9 -1
- package/src/router.ts +7 -8
- package/src/rsc/handler.ts +9 -2
- package/src/rsc/redirect-guard.ts +2 -1
- package/src/rsc/rsc-rendering.ts +122 -18
- package/src/rsc/shell-capture.ts +125 -20
- package/src/rsc/shell-serve.ts +37 -6
- package/src/segment-loader-promise.ts +18 -0
- package/src/segment-system.tsx +90 -6
- package/src/server/context.ts +47 -9
- package/src/server/cookie-store.ts +26 -5
- package/src/server/request-context.ts +22 -0
- package/src/ssr/index.tsx +160 -113
- package/src/ssr/inject-rsc-eager.ts +167 -0
- package/src/testing/dispatch.ts +7 -0
- package/src/vite/index.ts +7 -0
- package/src/vite/inject-client-debug.ts +64 -12
- package/src/vite/router-discovery.ts +9 -1
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
},
|
package/src/rsc/shell-capture.ts
CHANGED
|
@@ -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).
|
|
491
|
-
*
|
|
492
|
-
*
|
|
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
|
-
//
|
|
780
|
-
//
|
|
781
|
-
//
|
|
782
|
-
//
|
|
783
|
-
//
|
|
784
|
-
|
|
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
|
-
|
|
884
|
-
"
|
|
885
|
-
"(the live lane,
|
|
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
|
-
|
|
1152
|
+
shellTags,
|
|
1048
1153
|
);
|
|
1049
1154
|
} catch (error) {
|
|
1050
1155
|
// Best-effort: a failed put must never throw out of the background task.
|
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
|