@rangojs/router 0.0.0-experimental.142 → 0.0.0-experimental.144
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 +25 -6
- package/package.json +4 -2
- package/skills/cache-guide/SKILL.md +3 -1
- package/skills/caching/SKILL.md +41 -2
- package/skills/catalog.json +6 -0
- package/skills/composability/SKILL.md +32 -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/observability/SKILL.md +8 -0
- package/skills/parallel/SKILL.md +4 -0
- package/skills/ppr/SKILL.md +110 -20
- package/skills/rango/SKILL.md +10 -0
- package/skills/route/SKILL.md +8 -0
- package/skills/typesafety/SKILL.md +1 -0
- package/skills/typesafety/generated-files-and-cli.md +30 -0
- package/skills/use-cache/SKILL.md +12 -2
- package/src/browser/partial-update.ts +7 -0
- package/src/cache/cache-key-utils.ts +29 -0
- package/src/cache/cache-scope.ts +2 -17
- package/src/cache/cache-tag.ts +60 -14
- package/src/cache/cf/cf-cache-store.ts +54 -20
- package/src/cache/document-cache.ts +17 -11
- package/src/cache/vercel/vercel-cache-store.ts +9 -19
- package/src/cloudflare/tracing.ts +7 -8
- package/src/index.rsc.ts +1 -0
- package/src/index.ts +12 -8
- package/src/redirect-origin.ts +14 -0
- package/src/route-definition/helpers-types.ts +5 -4
- package/src/route-map-builder.ts +41 -4
- package/src/router/find-match.ts +15 -1
- package/src/router/instrument.ts +9 -4
- package/src/router/lazy-includes.ts +8 -2
- package/src/router/loader-resolution.ts +14 -2
- package/src/router/match-handlers.ts +175 -133
- package/src/router/middleware.ts +40 -30
- package/src/router/router-interfaces.ts +9 -0
- package/src/router/segment-resolution/loader-snapshot.ts +98 -17
- package/src/router/telemetry-otel.ts +6 -8
- package/src/router/telemetry.ts +9 -1
- package/src/router/tracing.ts +14 -5
- package/src/router.ts +22 -14
- package/src/rsc/handler.ts +55 -32
- package/src/rsc/redirect-guard.ts +2 -1
- package/src/rsc/rsc-rendering.ts +35 -2
- package/src/rsc/shell-capture.ts +98 -20
- 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 +145 -107
- package/src/testing/dispatch.ts +149 -37
- package/src/urls/path-helper-types.ts +9 -4
- package/src/vercel/tracing.ts +7 -7
- package/src/vite/inject-client-debug.ts +64 -12
- package/src/vite/router-discovery.ts +9 -1
package/src/rsc/shell-capture.ts
CHANGED
|
@@ -327,6 +327,39 @@ function warnCaptureRefusedOnce(key: string, reason: string): void {
|
|
|
327
327
|
);
|
|
328
328
|
}
|
|
329
329
|
|
|
330
|
+
/** Keys already warned about untagged bake-lane data baked into the shell. */
|
|
331
|
+
const warnedUntaggedShellBakes = new Set<string>();
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Warn once per shell key that a bake-lane loader baked material into the shell
|
|
335
|
+
* but the capture recorded ZERO tags (no render-collected _requestTags, no
|
|
336
|
+
* static ppr.tags). Such data is frozen in the shared shell until TTL and is
|
|
337
|
+
* un-evictable by tag: a server action refreshes the CLIENT only (rotates Rango
|
|
338
|
+
* state, busts the browser HTTP cache) and never touches the server shell store,
|
|
339
|
+
* and updateTag()/revalidateTag() cannot drop data that was baked WITHOUT a tag.
|
|
340
|
+
*
|
|
341
|
+
* Coarse per-shell-key signal, not per-loader attribution — the tag set is
|
|
342
|
+
* unioned globally at the write barrier, not tracked per loader, so we cannot
|
|
343
|
+
* name the offending loader without adding attribution plumbing (deliberately
|
|
344
|
+
* not done). Dev-only + once-per-key so it never spams production or fires on
|
|
345
|
+
* every capture.
|
|
346
|
+
*/
|
|
347
|
+
function warnUntaggedShellBakeOnce(key: string): void {
|
|
348
|
+
if (warnedUntaggedShellBakes.has(key)) return;
|
|
349
|
+
warnedUntaggedShellBakes.add(key);
|
|
350
|
+
console.warn(
|
|
351
|
+
`[rango] Shell capture for "${key}" baked bake-lane loader data into the ` +
|
|
352
|
+
"shell with NO cache tag. That data is frozen in the shared shell until " +
|
|
353
|
+
"TTL and cannot be tag-invalidated: a server action refresh touches the " +
|
|
354
|
+
"client only (not the server shell store), and updateTag() cannot evict " +
|
|
355
|
+
"data that was baked without a tag.\n" +
|
|
356
|
+
'Fix: tag the data (cacheTag() / "use cache" / cache({ tags })) so ' +
|
|
357
|
+
"updateTag() drops the shell, or move the volatile read under a loading() " +
|
|
358
|
+
"hole so it stays on the live lane and is never baked. See the /ppr skill " +
|
|
359
|
+
"(node_modules/@rangojs/router/skills/ppr/SKILL.md).",
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
330
363
|
export interface FlightCaptureGate {
|
|
331
364
|
/** Identity passthrough of the source stream; feed this to captureShellHTML. */
|
|
332
365
|
stream: ReadableStream<Uint8Array>;
|
|
@@ -487,9 +520,12 @@ export function gateFlightForCapture(
|
|
|
487
520
|
* store, and passed to scheduleShellCapture directly — it is NOT threaded through
|
|
488
521
|
* the request context. `tags` carries the route's OPERATIONAL `ppr.tags`; the
|
|
489
522
|
* 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
|
-
*
|
|
523
|
+
* tags from its derived render (the collected set stays authoritative). That
|
|
524
|
+
* union happens at the putShell WRITE BARRIER in captureAndStoreShell — after the
|
|
525
|
+
* capture quiesces — not at stream construction, so a tag recorded after an await
|
|
526
|
+
* in async shell content is still collected (issue #676). `store` is the same
|
|
527
|
+
* store the serve path resolved for its getShell read (requestCtx._cacheStore),
|
|
528
|
+
* so the capture writes where the serve reads.
|
|
493
529
|
*/
|
|
494
530
|
export interface ShellCaptureDescriptor {
|
|
495
531
|
key: string;
|
|
@@ -715,6 +751,11 @@ async function attemptCapture(
|
|
|
715
751
|
derivedCtx._transitionWhen = [];
|
|
716
752
|
derivedCtx._shellCaptureRun = true;
|
|
717
753
|
derivedCtx._metricsStore = undefined;
|
|
754
|
+
// Spans, like perf metrics above, are a FOREGROUND surface: the capture
|
|
755
|
+
// re-render must not emit a second rango.render/loader/ssr set after the
|
|
756
|
+
// foreground rango.request span ended (orphan spans in the trace).
|
|
757
|
+
// _tracing is otherwise inherited through Object.create(reqCtx).
|
|
758
|
+
derivedCtx._tracing = undefined;
|
|
718
759
|
// Bake-lane loader containers (loaders on entries with no renderable
|
|
719
760
|
// loading() execute during capture — docs/design/loader-container-bake.md).
|
|
720
761
|
// resolveLoaderData registers each container promise here; the drain in
|
|
@@ -771,24 +812,18 @@ async function attemptCapture(
|
|
|
771
812
|
},
|
|
772
813
|
});
|
|
773
814
|
|
|
774
|
-
//
|
|
775
|
-
//
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
|
|
780
|
-
const union = new Set<string>([...(descriptor.tags ?? []), ...collected]);
|
|
781
|
-
const tags = union.size > 0 ? [...union] : undefined;
|
|
782
|
-
|
|
815
|
+
// Pass the descriptor with its STATIC ppr.tags unchanged. The shell's own
|
|
816
|
+
// render-recorded tags are snapshotted at the putShell WRITE BARRIER inside
|
|
817
|
+
// captureAndStoreShell, not here: a tag recorded AFTER an await in async shell
|
|
818
|
+
// content (and tags propagated by async cache()/"use cache" reads) lands after
|
|
819
|
+
// this synchronous construction point, so snapshotting here dropped it — the
|
|
820
|
+
// shell-tag snapshot must sit behind the quiesce gate (issue #676).
|
|
783
821
|
return captureAndStoreShell(
|
|
784
822
|
ssrModule,
|
|
785
823
|
rscStream,
|
|
786
824
|
freshHandleStore,
|
|
787
825
|
derivedCtx,
|
|
788
|
-
|
|
789
|
-
...descriptor,
|
|
790
|
-
tags,
|
|
791
|
-
},
|
|
826
|
+
descriptor,
|
|
792
827
|
);
|
|
793
828
|
});
|
|
794
829
|
}
|
|
@@ -873,11 +908,25 @@ async function captureAndStoreShell(
|
|
|
873
908
|
const refuseOnGuardTrip = (): "refused" | undefined => {
|
|
874
909
|
const fnName = reqCtx._shellCaptureGuardTripped;
|
|
875
910
|
if (!fnName) return undefined;
|
|
911
|
+
// Name the recorded source instead of hardcoding a lane. Under the
|
|
912
|
+
// consumption-lane rule, handler-INVOKED loader bodies are exempt from
|
|
913
|
+
// the guard (their value is a baked shared copy, mirroring cache()), so a
|
|
914
|
+
// trip can only come from a bake-lane SEGMENT loader or from non-loader
|
|
915
|
+
// handler/render code — the old blanket "bake-lane loader" attribution
|
|
916
|
+
// sent a live-lane debugging session down the wrong lane (issue #672).
|
|
917
|
+
const loaderId = reqCtx._shellCaptureGuardTrippedLoaderId;
|
|
918
|
+
const origin = loaderId
|
|
919
|
+
? `segment loader "${loaderId}"`
|
|
920
|
+
: "handler/render code (no loader body was executing)";
|
|
876
921
|
warnCaptureRefusedOnce(
|
|
877
922
|
capture.key,
|
|
878
|
-
|
|
879
|
-
"
|
|
880
|
-
"(the live lane,
|
|
923
|
+
`${origin} called ${fnName}() during capture. Identity must not bake into a shared shell. ` +
|
|
924
|
+
"For a segment loader (bake lane, no loading()): give its entry a loading() boundary " +
|
|
925
|
+
"(the live lane, masked at capture) or move the identity-dependent part into a nested " +
|
|
926
|
+
"promise. For handler/render code: keep the value live by consuming a loader " +
|
|
927
|
+
'client-side (useLoader in a "use client" component). Note: `await ctx.use(loader)` ' +
|
|
928
|
+
"inside a HANDLER is exempt from this guard — its value bakes into the shared shell " +
|
|
929
|
+
"as a capture-time copy, mirroring cache() semantics (the consumption-lane rule).",
|
|
881
930
|
);
|
|
882
931
|
return "refused";
|
|
883
932
|
};
|
|
@@ -976,6 +1025,10 @@ async function captureAndStoreShell(
|
|
|
976
1025
|
// gate above already returned no-shell) or postponed under an ANCESTOR
|
|
977
1026
|
// boundary (it is a hole; omitting the record keeps it live).
|
|
978
1027
|
const loaderRecords = reqCtx._shellCaptureLoaderRecords;
|
|
1028
|
+
// Set once a bake-lane loader settles with real (non-hole) material: its data
|
|
1029
|
+
// is frozen into the shell prelude regardless of whether snapshot
|
|
1030
|
+
// serialization succeeds. Drives the untagged-bake dev warning below.
|
|
1031
|
+
let bakedLoaderMaterial = false;
|
|
979
1032
|
if (loaderRecords && loaderRecords.size > 0) {
|
|
980
1033
|
// The codec import is deferred past the elide probes: a rejected record
|
|
981
1034
|
// refuses and a never-settled record is omitted WITHOUT touching Flight
|
|
@@ -996,6 +1049,9 @@ async function captureAndStoreShell(
|
|
|
996
1049
|
// The container itself never settled: it is a hole (under an ancestor
|
|
997
1050
|
// boundary) or the trivial-prelude gate already fired. Omit — no pin.
|
|
998
1051
|
if (isLoaderHoleMarker(elided.value)) continue;
|
|
1052
|
+
// Past the hole check: this container settled with real material that
|
|
1053
|
+
// bakes into the shell prelude (independent of the snapshot pin below).
|
|
1054
|
+
bakedLoaderMaterial = true;
|
|
999
1055
|
try {
|
|
1000
1056
|
// serializeResult (not rscSerialize): null is a valid container and
|
|
1001
1057
|
// must round-trip; serializeResult preserves it through Flight.
|
|
@@ -1016,6 +1072,28 @@ async function captureAndStoreShell(
|
|
|
1016
1072
|
}
|
|
1017
1073
|
}
|
|
1018
1074
|
|
|
1075
|
+
// Shell tags snapshot at the WRITE BARRIER, not at stream construction: by
|
|
1076
|
+
// here the capture has quiesced and the deferred cache writes were awaited, so
|
|
1077
|
+
// tags recorded AFTER an await in async shell content (and by async
|
|
1078
|
+
// cache()/"use cache" reads propagating through recordRequestTags) are
|
|
1079
|
+
// included — issue #676. Loaders are masked, so loader cache tags — which
|
|
1080
|
+
// belong to the holes, not the shell — never execute during capture and cannot
|
|
1081
|
+
// contribute. Union with the route's static ppr.tags (capture.tags); the
|
|
1082
|
+
// collected set is authoritative, the option only adds what the render cannot
|
|
1083
|
+
// know.
|
|
1084
|
+
const collected = [...reqCtx._requestTags];
|
|
1085
|
+
const union = new Set<string>([...(capture.tags ?? []), ...collected]);
|
|
1086
|
+
const shellTags = union.size > 0 ? [...union] : undefined;
|
|
1087
|
+
|
|
1088
|
+
// Untagged-bake diagnostic: a bake-lane loader froze mutable data into the
|
|
1089
|
+
// shell but nothing tags the entry, so it is un-invalidatable except by TTL —
|
|
1090
|
+
// a read-your-own-writes gap on the document channel (an action refresh skips
|
|
1091
|
+
// the server shell; updateTag cannot drop untagged data). Coarse per-shell-key
|
|
1092
|
+
// signal; dev-only and once-per-key so it never spams production.
|
|
1093
|
+
if (isDevMode() && bakedLoaderMaterial && shellTags === undefined) {
|
|
1094
|
+
warnUntaggedShellBakeOnce(capture.key);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1019
1097
|
const store = capture.store ?? reqCtx._cacheStore;
|
|
1020
1098
|
if (store?.putShell) {
|
|
1021
1099
|
try {
|
|
@@ -1039,7 +1117,7 @@ async function captureAndStoreShell(
|
|
|
1039
1117
|
entry,
|
|
1040
1118
|
capture.ttl,
|
|
1041
1119
|
capture.swr,
|
|
1042
|
-
|
|
1120
|
+
shellTags,
|
|
1043
1121
|
);
|
|
1044
1122
|
} catch (error) {
|
|
1045
1123
|
// Best-effort: a failed put must never throw out of the background task.
|
package/src/server/context.ts
CHANGED
|
@@ -770,14 +770,23 @@ const loaderScopeALS: AsyncLocalStorage<{ active: true }> = ((
|
|
|
770
770
|
|
|
771
771
|
// Purity-only scope: marks that a loader FUNCTION BODY is executing, regardless
|
|
772
772
|
// of how the loader was invoked (DSL via runInsideLoaderScope, or handler-
|
|
773
|
-
// invoked via ctx.use). Consulted
|
|
774
|
-
// request-scoped reads
|
|
775
|
-
//
|
|
776
|
-
//
|
|
773
|
+
// invoked via ctx.use). Consulted by isInsideCacheScope() to exempt
|
|
774
|
+
// request-scoped reads, by getCurrentLoaderBodyId() for guard-warning
|
|
775
|
+
// attribution, and by isInsideHandlerInvokedLoaderBody() for the
|
|
776
|
+
// consumption-lane rule (the shell-capture guard exemption). It deliberately
|
|
777
|
+
// does NOT affect isInsideLoaderScope(), so rendered()/barrier/deadlock
|
|
778
|
+
// gating (which must distinguish DSL from handler-invoked loaders) is
|
|
779
|
+
// unchanged.
|
|
777
780
|
const LOADER_BODY_SCOPE_KEY = Symbol.for("rangojs-router:loader-body-scope");
|
|
778
|
-
const loaderBodyScopeALS: AsyncLocalStorage<{
|
|
779
|
-
|
|
780
|
-
|
|
781
|
+
const loaderBodyScopeALS: AsyncLocalStorage<{
|
|
782
|
+
active: true;
|
|
783
|
+
loaderId?: string;
|
|
784
|
+
handlerInvoked?: boolean;
|
|
785
|
+
}> = ((globalThis as any)[LOADER_BODY_SCOPE_KEY] ??= new AsyncLocalStorage<{
|
|
786
|
+
active: true;
|
|
787
|
+
loaderId?: string;
|
|
788
|
+
handlerInvoked?: boolean;
|
|
789
|
+
}>());
|
|
781
790
|
|
|
782
791
|
/**
|
|
783
792
|
* Check if the current execution is inside a cache() DSL boundary.
|
|
@@ -822,8 +831,37 @@ export function runInsideLoaderScope<T>(fn: () => T): T {
|
|
|
822
831
|
* and handler-invoked via ctx.use) so request-scoped reads inside a loader
|
|
823
832
|
* never trip the cache-scope guards — loaders always run fresh.
|
|
824
833
|
*/
|
|
825
|
-
export function runInsideLoaderBodyScope<T>(
|
|
826
|
-
|
|
834
|
+
export function runInsideLoaderBodyScope<T>(
|
|
835
|
+
fn: () => T,
|
|
836
|
+
loaderId?: string,
|
|
837
|
+
handlerInvoked?: boolean,
|
|
838
|
+
): T {
|
|
839
|
+
return loaderBodyScopeALS.run({ active: true, loaderId, handlerInvoked }, fn);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* The $$id of the loader whose body is currently executing, or undefined
|
|
844
|
+
* outside any loader body. Used by the shell-capture identity guard
|
|
845
|
+
* (cookie-store.ts) so its refusal warning can name the loader that read
|
|
846
|
+
* cookies()/headers() instead of blaming a lane it cannot see — the old
|
|
847
|
+
* hardcoded "bake-lane loader" text misled a live-lane debugging session
|
|
848
|
+
* (issue #672, secondary).
|
|
849
|
+
*/
|
|
850
|
+
export function getCurrentLoaderBodyId(): string | undefined {
|
|
851
|
+
return loaderBodyScopeALS.getStore()?.loaderId;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* True while a HANDLER-invoked loader body (`await ctx.use(Loader)` from a
|
|
856
|
+
* handler, not the DSL segment funnel) is executing. The consumption-lane
|
|
857
|
+
* rule keys off this: handler consumption yields a BAKED copy in every shared
|
|
858
|
+
* artifact — cache(), "use cache", and the PPR shell — so the shell-capture
|
|
859
|
+
* identity guard (cookie-store.ts) permits cookies()/headers() here, exactly
|
|
860
|
+
* like the cache-purity guards do. DSL segment loaders (live lane masked at
|
|
861
|
+
* capture, bake lane guarded) never set the flag.
|
|
862
|
+
*/
|
|
863
|
+
export function isInsideHandlerInvokedLoaderBody(): boolean {
|
|
864
|
+
return loaderBodyScopeALS.getStore()?.handlerInvoked === true;
|
|
827
865
|
}
|
|
828
866
|
|
|
829
867
|
// Scope for handle PUSH CALLBACKS (push(() => ...), including async ones).
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
|
|
10
10
|
import type { CookieOptions } from "../router/middleware-types.js";
|
|
11
11
|
import { getRequestContext, _getRequestContext } from "./request-context.js";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
isInsideCacheScope,
|
|
14
|
+
getCurrentLoaderBodyId,
|
|
15
|
+
isInsideHandlerInvokedLoaderBody,
|
|
16
|
+
} from "./context.js";
|
|
13
17
|
import { INSIDE_CACHE_EXEC } from "../cache/taint.js";
|
|
14
18
|
|
|
15
19
|
/**
|
|
@@ -139,9 +143,19 @@ function assertNotInsideCacheContext(ctx: unknown, fnName: string): void {
|
|
|
139
143
|
* shell-capture.ts). The captured shell prelude is shared across every user
|
|
140
144
|
* hitting the URL, so a request-scoped read here would bake one user's
|
|
141
145
|
* cookies/headers into markup served to others — same hazard as the cache
|
|
142
|
-
* scopes above, at the document tier.
|
|
143
|
-
* masked (never executed) during capture and
|
|
144
|
-
*
|
|
146
|
+
* scopes above, at the document tier. DSL segment loaders need no exemption:
|
|
147
|
+
* the live lane is masked (never executed) during capture, and the bake lane
|
|
148
|
+
* is exactly what this guard exists for.
|
|
149
|
+
*
|
|
150
|
+
* HANDLER-INVOKED loader bodies (`await ctx.use(Loader)` from a handler) are
|
|
151
|
+
* EXEMPT — the consumption-lane rule: handler consumption yields a BAKED
|
|
152
|
+
* shared copy in every artifact tier, and the cache-purity guards above
|
|
153
|
+
* already permit identity reads there (cache()/"use cache" bake the same
|
|
154
|
+
* reads today). Guarding only the PPR tier made the same code legal under
|
|
155
|
+
* cache() but capture-refusing under ppr (issue #672 / #674). The trade is
|
|
156
|
+
* documented: an identity read in a handler-consumed loader bakes the CAPTURE
|
|
157
|
+
* request's value into the shared shell; client-side consumption (useLoader)
|
|
158
|
+
* is the live lane.
|
|
145
159
|
*
|
|
146
160
|
* Keys off `_shellCaptureRun`, NOT the `_shellCapture` descriptor: the descriptor
|
|
147
161
|
* is also present during the FOREGROUND render (it means "a capture is wanted"),
|
|
@@ -164,12 +178,19 @@ function assertNotInsideShellCapture(ctx: unknown, fnName: string): void {
|
|
|
164
178
|
typeof ctx === "object" &&
|
|
165
179
|
(ctx as { _shellCaptureRun?: unknown })._shellCaptureRun === true
|
|
166
180
|
) {
|
|
181
|
+
if (isInsideHandlerInvokedLoaderBody()) return;
|
|
167
182
|
// Flag the capture context BEFORE throwing: inside an executing bake-lane
|
|
168
183
|
// loader this throw is swallowed by wrapLoaderPromise into per-loader error
|
|
169
184
|
// UI, which would bake silently into the shared shell. The capture checks
|
|
170
|
-
// the flag after the render and refuses (shell-capture.ts).
|
|
185
|
+
// the flag after the render and refuses (shell-capture.ts). Also record
|
|
186
|
+
// WHICH loader body (if any) made the read, so the refusal warning can
|
|
187
|
+
// name the real source instead of hardcoding a lane — the read may come
|
|
188
|
+
// from a bake-lane loader OR from handler/render code (issue #672).
|
|
171
189
|
(ctx as { _shellCaptureGuardTripped?: string })._shellCaptureGuardTripped =
|
|
172
190
|
fnName;
|
|
191
|
+
(
|
|
192
|
+
ctx as { _shellCaptureGuardTrippedLoaderId?: string }
|
|
193
|
+
)._shellCaptureGuardTrippedLoaderId = getCurrentLoaderBodyId();
|
|
173
194
|
throw new Error(
|
|
174
195
|
`${fnName}() cannot be called while capturing a shared shell ` +
|
|
175
196
|
`(shell-cache middleware). The captured shell is served to every user ` +
|
|
@@ -220,6 +220,16 @@ export interface RequestContext<
|
|
|
220
220
|
*/
|
|
221
221
|
_shellCaptureGuardTripped?: string;
|
|
222
222
|
|
|
223
|
+
/**
|
|
224
|
+
* @internal The loader $$id whose BODY was executing when the capture guard
|
|
225
|
+
* tripped (read off the loader-body ALS scope at trip time), or undefined
|
|
226
|
+
* when the read came from handler/render code. Only used to make the
|
|
227
|
+
* once-per-key refusal warning name the real source — the old text
|
|
228
|
+
* hardcoded "a bake-lane loader", which misattributed handler-land reads
|
|
229
|
+
* and sent users debugging the wrong lane (issue #672, secondary).
|
|
230
|
+
*/
|
|
231
|
+
_shellCaptureGuardTrippedLoaderId?: string;
|
|
232
|
+
|
|
223
233
|
/**
|
|
224
234
|
* @internal Handler-owned registry of explicit per-scope stores from
|
|
225
235
|
* cache({ store }). Created once per createRSCHandler() and threaded into
|
|
@@ -468,6 +478,15 @@ export interface RequestContext<
|
|
|
468
478
|
/** @internal Request-scoped performance metrics store */
|
|
469
479
|
_metricsStore?: MetricsStore;
|
|
470
480
|
|
|
481
|
+
/**
|
|
482
|
+
* @internal True request entry timestamp (performance.now() at handler entry).
|
|
483
|
+
* Set once at request-context creation (rsc/handler.ts) so a metrics store
|
|
484
|
+
* created MID-request — ctx.debugPerformance() or the getMetricsStore wrapper —
|
|
485
|
+
* anchors its timeline to the real request start instead of the opt-in moment,
|
|
486
|
+
* keeping phases that began before the opt-in at their true (non-negative) offset.
|
|
487
|
+
*/
|
|
488
|
+
_handlerStart?: number;
|
|
489
|
+
|
|
471
490
|
/** @internal Resolved platform phase-span tracing for this request (Cloudflare or OTel) */
|
|
472
491
|
_tracing?: ResolvedTracing;
|
|
473
492
|
|
|
@@ -509,6 +528,7 @@ export type PublicRequestContext<
|
|
|
509
528
|
| "_transitionWhen"
|
|
510
529
|
| "_cacheStore"
|
|
511
530
|
| "_shellCaptureRun"
|
|
531
|
+
| "_shellCaptureGuardTrippedLoaderId"
|
|
512
532
|
| "_explicitTaggedStores"
|
|
513
533
|
| "_requestTags"
|
|
514
534
|
| "_cacheProfiles"
|
|
@@ -536,6 +556,7 @@ export type PublicRequestContext<
|
|
|
536
556
|
| "_reportBackgroundError"
|
|
537
557
|
| "_debugPerformance"
|
|
538
558
|
| "_metricsStore"
|
|
559
|
+
| "_handlerStart"
|
|
539
560
|
| "_basename"
|
|
540
561
|
| "_setStatus"
|
|
541
562
|
| "_rotateStateCookie"
|
|
@@ -1028,6 +1049,7 @@ export function createRequestContext<TEnv>(
|
|
|
1028
1049
|
|
|
1029
1050
|
_reportedErrors: new WeakSet<object>(),
|
|
1030
1051
|
_metricsStore: undefined,
|
|
1052
|
+
_handlerStart: undefined,
|
|
1031
1053
|
|
|
1032
1054
|
_renderBarrier: null as any,
|
|
1033
1055
|
_resolveRenderBarrier: null as any,
|
package/src/ssr/index.tsx
CHANGED
|
@@ -247,11 +247,22 @@ async function readStreamToUint8Array(
|
|
|
247
247
|
const reader = stream.getReader();
|
|
248
248
|
const chunks: Uint8Array[] = [];
|
|
249
249
|
let total = 0;
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
250
|
+
try {
|
|
251
|
+
while (true) {
|
|
252
|
+
const { done, value } = await reader.read();
|
|
253
|
+
if (done) break;
|
|
254
|
+
chunks.push(value);
|
|
255
|
+
total += value.length;
|
|
256
|
+
}
|
|
257
|
+
} catch (error) {
|
|
258
|
+
// Mid-read abort path (documented): the prelude stream errors with our
|
|
259
|
+
// abort reason while we are still reading it. Cancel the source before
|
|
260
|
+
// rethrowing so it is not left uncancelled; releaseLock always runs in
|
|
261
|
+
// finally. Mirrors src/rsc/rsc-rendering.ts's serve-side reader cleanup.
|
|
262
|
+
reader.cancel(error).catch(() => {});
|
|
263
|
+
throw error;
|
|
264
|
+
} finally {
|
|
265
|
+
reader.releaseLock();
|
|
255
266
|
}
|
|
256
267
|
const out = new Uint8Array(total);
|
|
257
268
|
let offset = 0;
|
|
@@ -434,113 +445,140 @@ export function createShellCaptureHandler<TEnv = unknown>(
|
|
|
434
445
|
): Promise<ShellCaptureResult | null> {
|
|
435
446
|
const maxWaitMs = opts.maxWaitMs ?? DEFAULT_SHELL_CAPTURE_MAX_WAIT_MS;
|
|
436
447
|
|
|
437
|
-
//
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
// Start prerender first, then run the abort schedule concurrently. When
|
|
446
|
-
// holes are pending, prerender's promise settles only after abort(); when
|
|
447
|
-
// the shell completes with no holes it settles on its own and the later
|
|
448
|
-
// abort() is a harmless no-op (the DATA variant).
|
|
449
|
-
const controller = new AbortController();
|
|
450
|
-
const prerenderPromise = prerender(<SsrRoot />, {
|
|
451
|
-
signal: controller.signal,
|
|
452
|
-
bootstrapScriptContent,
|
|
453
|
-
// Abort is how capture WORKS: once the shell is quiet we abort() to freeze
|
|
454
|
-
// the prelude and let the still-pending holes postpone. React reports the
|
|
455
|
-
// abort reason for each pending boundary through onError. Without an onError
|
|
456
|
-
// here React falls back to console.error, so every capture that still has a
|
|
457
|
-
// live hole at abort time (the normal case, and every cold-module capture
|
|
458
|
-
// where the shell is not yet done) dumps a DOMException [AbortError] stack —
|
|
459
|
-
// once per pending boundary. That is EXPECTED degradation, not a failure, so
|
|
460
|
-
// swallow the abort here. Genuine shell render errors (a component throwing)
|
|
461
|
-
// are NOT the abort and still surface through the deps.onError channel, the
|
|
462
|
-
// same one renderHTML uses. See docs/design/ppr-shell-resume.md.
|
|
463
|
-
onError: (error: unknown) => {
|
|
464
|
-
if (
|
|
465
|
-
controller.signal.aborted &&
|
|
466
|
-
(error as { name?: string } | null)?.name === "AbortError"
|
|
467
|
-
) {
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
reportRenderError(onError, error);
|
|
471
|
-
},
|
|
472
|
-
});
|
|
473
|
-
// Pre-attach a no-op catch: the real await sits AFTER quiesce + the
|
|
474
|
-
// post-quiesce hops, so an early prerender rejection (e.g. a bake-lane
|
|
475
|
-
// loader tripping the identity guard within milliseconds) would otherwise
|
|
476
|
-
// spend several turns handler-less and crash the worker as an unhandled
|
|
477
|
-
// rejection. The actual rejection handling still happens at the await
|
|
478
|
-
// below; this parallel handler only keeps the gap crash-free.
|
|
479
|
-
prerenderPromise.catch(() => {});
|
|
480
|
-
|
|
481
|
-
// Wait for the caller's quiesce signal. By the time it resolves the Flight
|
|
482
|
-
// input is byte-quiet and FROZEN by the capture gate (shell-capture.ts
|
|
483
|
-
// gateFlightForCapture), so there is no wall-clock debounce here — maxWaitMs
|
|
484
|
-
// is only the pathological guard for a shell that never goes quiet (a root
|
|
485
|
-
// postpone / hung handle), and should never fire in tests.
|
|
486
|
-
const timer = createCancelableTimeout(maxWaitMs);
|
|
487
|
-
try {
|
|
488
|
-
await Promise.race([opts.quiesce, timer.promise]);
|
|
489
|
-
} finally {
|
|
490
|
-
timer.cancel();
|
|
491
|
-
}
|
|
492
|
-
// Fixed task hops before the abort: give React's fizz worker turns to flush
|
|
493
|
-
// the now-complete shell and mark the still-pending boundaries as POSTPONED
|
|
494
|
-
// rather than errored. Deterministic (the byte set is already frozen), so a
|
|
495
|
-
// fixed count of turns suffices — no wall-clock.
|
|
496
|
-
for (let i = 0; i < POST_QUIESCE_TASK_HOPS; i++) {
|
|
497
|
-
await macrotask();
|
|
498
|
-
}
|
|
499
|
-
controller.abort();
|
|
500
|
-
|
|
501
|
-
// A hard prerender rejection (fatal shell error) propagates. Expected
|
|
502
|
-
// degradation surfaces three ways and all return null: a trivial prelude
|
|
503
|
-
// (sanity gate below), the prerender REJECTING with an AbortError, or the
|
|
504
|
-
// prelude STREAM erroring with the abort reason mid-read — both abort
|
|
505
|
-
// shapes happen when our own abort lands before the shell completed (seen
|
|
506
|
-
// on dev cold paths, where module transform / first-render latency
|
|
507
|
-
// outlasts flight quiesce; a later request re-captures against warm
|
|
508
|
-
// modules and succeeds).
|
|
509
|
-
let prelude: Uint8Array;
|
|
510
|
-
let postponed: unknown;
|
|
448
|
+
// Arm the maxWaitMs deadline BEFORE the first await so it bounds the ENTIRE
|
|
449
|
+
// capture, the bootstrap-script load included. loadBootstrapScriptContent()
|
|
450
|
+
// used to run before the timer, so a hung/slow bootstrap load hung
|
|
451
|
+
// captureShellHTML with no upper bound and held the background capture task
|
|
452
|
+
// open. One deadline, shared by the bootstrap race below and the quiesce
|
|
453
|
+
// race, keeps the whole path "bounded by maxWaitMs like every quiesce input".
|
|
454
|
+
const deadline = createCancelableTimeout(maxWaitMs);
|
|
511
455
|
try {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
// the
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
456
|
+
// No nonce (nonce'd requests never reach capture); no formState.
|
|
457
|
+
const SsrRoot = createSsrRootComponent({
|
|
458
|
+
createFromReadableStream,
|
|
459
|
+
rscStream,
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// Bootstrap load raced against the deadline. A load that never resolves
|
|
463
|
+
// within maxWaitMs is the same bounded no-shell degrade as a shell that
|
|
464
|
+
// never goes quiet: return null, do not hang. A load that REJECTS is a
|
|
465
|
+
// genuine error and still propagates (it is not the deadline). `null` is
|
|
466
|
+
// the deadline sentinel — disjoint from the load's `Promise<string>`, so
|
|
467
|
+
// the race narrows to `string | null` with no wrapper. The no-op catch
|
|
468
|
+
// keeps a late rejection off the unhandledRejection path when the deadline
|
|
469
|
+
// already won; a rejection that lands first still propagates out.
|
|
470
|
+
const load = loadBootstrapScriptContent();
|
|
471
|
+
load.catch(() => {});
|
|
472
|
+
const bootstrapScriptContent = await Promise.race([
|
|
473
|
+
load,
|
|
474
|
+
deadline.promise.then(() => null),
|
|
475
|
+
]);
|
|
476
|
+
if (bootstrapScriptContent === null) {
|
|
523
477
|
return null;
|
|
524
478
|
}
|
|
525
|
-
throw error;
|
|
526
|
-
}
|
|
527
479
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
480
|
+
// Start prerender first, then run the abort schedule concurrently. When
|
|
481
|
+
// holes are pending, prerender's promise settles only after abort(); when
|
|
482
|
+
// the shell completes with no holes it settles on its own and the later
|
|
483
|
+
// abort() is a harmless no-op (the DATA variant).
|
|
484
|
+
const controller = new AbortController();
|
|
485
|
+
// Private reason object: the deliberate abort is identified by object
|
|
486
|
+
// IDENTITY in both the onError below and the post-await catch. React
|
|
487
|
+
// propagates this EXACT object to onError for every still-pending boundary
|
|
488
|
+
// (verified identity-preserving), and rejects/errors the prelude with it.
|
|
489
|
+
const abortReason = { rangoShellCaptureAbort: true };
|
|
490
|
+
const prerenderPromise = prerender(<SsrRoot />, {
|
|
491
|
+
signal: controller.signal,
|
|
492
|
+
bootstrapScriptContent,
|
|
493
|
+
// Abort is how capture WORKS: once the shell is quiet we abort() to
|
|
494
|
+
// freeze the prelude and let the still-pending holes postpone. React
|
|
495
|
+
// reports the abort reason for each pending boundary through onError.
|
|
496
|
+
// Without an onError here React falls back to console.error, so every
|
|
497
|
+
// capture that still has a live hole at abort time (the normal case)
|
|
498
|
+
// dumps a stack once per pending boundary. That is EXPECTED degradation,
|
|
499
|
+
// so swallow OUR abort — matched by IDENTITY (error === abortReason).
|
|
500
|
+
// Discriminate by identity, NOT error.name: capture aborts before
|
|
501
|
+
// awaiting, so signal.aborted is unconditionally true and a name check
|
|
502
|
+
// swallowed genuine AbortError-named throws (a component's own
|
|
503
|
+
// fetch/AbortController cancellation) as if they were our abort. Genuine
|
|
504
|
+
// render errors are NOT our sentinel and still surface through
|
|
505
|
+
// deps.onError, the same channel renderHTML uses. See
|
|
506
|
+
// docs/design/ppr-shell-resume.md.
|
|
507
|
+
onError: (error: unknown) => {
|
|
508
|
+
if (error === abortReason) {
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
reportRenderError(onError, error);
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
// Pre-attach a no-op catch: the real await sits AFTER quiesce + the
|
|
515
|
+
// post-quiesce hops, so an early prerender rejection (e.g. a bake-lane
|
|
516
|
+
// loader tripping the identity guard within milliseconds) would otherwise
|
|
517
|
+
// spend several turns handler-less and crash the worker as an unhandled
|
|
518
|
+
// rejection. The actual rejection handling still happens at the await
|
|
519
|
+
// below; this parallel handler only keeps the gap crash-free.
|
|
520
|
+
prerenderPromise.catch(() => {});
|
|
521
|
+
|
|
522
|
+
// Wait for the caller's quiesce signal, bounded by the SAME deadline. By
|
|
523
|
+
// the time it resolves the Flight input is byte-quiet and FROZEN by the
|
|
524
|
+
// capture gate (shell-capture.ts gateFlightForCapture), so there is no
|
|
525
|
+
// wall-clock debounce here — maxWaitMs is only the pathological guard for a
|
|
526
|
+
// shell that never goes quiet (a root postpone / hung handle).
|
|
527
|
+
await Promise.race([opts.quiesce, deadline.promise]);
|
|
528
|
+
// Fixed task hops before the abort: give React's fizz worker turns to flush
|
|
529
|
+
// the now-complete shell and mark the still-pending boundaries as POSTPONED
|
|
530
|
+
// rather than errored. Deterministic (the byte set is already frozen), so a
|
|
531
|
+
// fixed count of turns suffices — no wall-clock.
|
|
532
|
+
for (let i = 0; i < POST_QUIESCE_TASK_HOPS; i++) {
|
|
533
|
+
await macrotask();
|
|
534
|
+
}
|
|
535
|
+
controller.abort(abortReason);
|
|
536
|
+
|
|
537
|
+
// A hard prerender rejection (fatal shell error) propagates. Expected
|
|
538
|
+
// degradation surfaces three ways and all return null: a trivial prelude
|
|
539
|
+
// (sanity gate below), the prerender REJECTING with our abort reason, or
|
|
540
|
+
// the prelude STREAM erroring with our abort reason mid-read — both abort
|
|
541
|
+
// shapes happen when our own abort lands before the shell completed (seen
|
|
542
|
+
// on dev cold paths, where module transform / first-render latency
|
|
543
|
+
// outlasts flight quiesce; a later request re-captures against warm
|
|
544
|
+
// modules and succeeds).
|
|
545
|
+
let prelude: Uint8Array;
|
|
546
|
+
let postponed: unknown;
|
|
547
|
+
try {
|
|
548
|
+
const result = await prerenderPromise;
|
|
549
|
+
prelude = await readStreamToUint8Array(result.prelude);
|
|
550
|
+
postponed = result.postponed;
|
|
551
|
+
} catch (error) {
|
|
552
|
+
// Identity match: swallow ONLY our own deliberate abort
|
|
553
|
+
// (error === abortReason). Not error.name — capture aborts before this
|
|
554
|
+
// await, so signal.aborted is always true, and a name check let a
|
|
555
|
+
// genuine AbortError-named throw masquerade as our abort and degrade
|
|
556
|
+
// into a retryable no-shell, hiding real failures from reportCacheError.
|
|
557
|
+
if (error === abortReason) {
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
throw error;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Sanity gate: a prelude with no `<body` is the no-shell failure mode.
|
|
564
|
+
// Return null and store nothing; the request falls back to axis 1 and a
|
|
565
|
+
// later request re-captures. The dominant real-world cause is a loader
|
|
566
|
+
// route WITHOUT a route-level loading() boundary: renderSegments' loading-
|
|
567
|
+
// less branch awaits loader data at TREE-BUILD, so the masked loader pins
|
|
568
|
+
// the whole tree above <body> (root postpone). Root-postponing layouts and
|
|
569
|
+
// hung handles degrade the same way. shell-capture.ts logs a once-per-key
|
|
570
|
+
// warning so the eternal-MISS shape is diagnosable.
|
|
571
|
+
if (!new TextDecoder().decode(prelude).includes("<body")) {
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
539
574
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
575
|
+
return {
|
|
576
|
+
prelude,
|
|
577
|
+
postponed: postponed == null ? null : JSON.stringify(postponed),
|
|
578
|
+
};
|
|
579
|
+
} finally {
|
|
580
|
+
deadline.cancel();
|
|
581
|
+
}
|
|
544
582
|
};
|
|
545
583
|
}
|
|
546
584
|
|