@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/segment-system.tsx
CHANGED
|
@@ -17,6 +17,22 @@ import {
|
|
|
17
17
|
getMemoizedLoaderPromise,
|
|
18
18
|
} from "./segment-loader-promise.js";
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Client-only debug log for the segment tree build. Gated on the baked flag
|
|
22
|
+
* AND `typeof window` (renderSegments also runs during SSR/RSC, which must
|
|
23
|
+
* stay silent). Timestamped so tree-build steps line up with the
|
|
24
|
+
* `[Browser][boot]` sequence around hydrateRoot.
|
|
25
|
+
*/
|
|
26
|
+
function segDebugLog(msg: string, details?: Record<string, unknown>): void {
|
|
27
|
+
if (!(INTERNAL_RANGO_DEBUG && typeof window === "object")) return;
|
|
28
|
+
const prefix = `[Browser][segments] ${msg} @ ${Math.round(performance.now())}ms`;
|
|
29
|
+
if (details) {
|
|
30
|
+
console.log(prefix, details);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
console.log(prefix);
|
|
34
|
+
}
|
|
35
|
+
|
|
20
36
|
// ViewTransition is only available in React experimental.
|
|
21
37
|
// Access via namespace import to avoid compile-time errors on stable React.
|
|
22
38
|
const ReactViewTransition: any =
|
|
@@ -212,6 +228,17 @@ export async function renderSegments(
|
|
|
212
228
|
rootLayout: RootLayout,
|
|
213
229
|
} = options || {};
|
|
214
230
|
|
|
231
|
+
const segDebug = INTERNAL_RANGO_DEBUG && typeof window === "object";
|
|
232
|
+
const segDebugStart = segDebug ? performance.now() : 0;
|
|
233
|
+
if (segDebug) {
|
|
234
|
+
segDebugLog("renderSegments start", {
|
|
235
|
+
segments: segments.map((s) => `${s.id}:${s.type}`),
|
|
236
|
+
isAction: !!isAction,
|
|
237
|
+
forceAwait: !!forceAwait,
|
|
238
|
+
intercepts: interceptSegments?.length ?? 0,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
215
242
|
const temporalLazyRefs: Promise<any>[] = [];
|
|
216
243
|
const normalizedSegments = restoreParallelLoaderMarkers(segments);
|
|
217
244
|
const normalizedInterceptSegments = interceptSegments
|
|
@@ -273,6 +300,15 @@ export async function renderSegments(
|
|
|
273
300
|
);
|
|
274
301
|
const { component, id, params, loading } = node.segment;
|
|
275
302
|
|
|
303
|
+
if (segDebug) {
|
|
304
|
+
segDebugLog(`segment ${id}`, {
|
|
305
|
+
type: node.segment.type,
|
|
306
|
+
loaders: node.loaders.map((l) => l.loaderId).filter(Boolean),
|
|
307
|
+
hasLoading: loading !== undefined && loading !== null,
|
|
308
|
+
parallel: node.parallel.map((p) => p.id),
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
276
312
|
// Param-agnostic keys are opt-in via the transition() DSL (see
|
|
277
313
|
// inTransitionScope above). A route (and its route-owned layouts) inside a
|
|
278
314
|
// transition scope drops the param from its key, so navigating between two
|
|
@@ -403,10 +439,25 @@ export async function renderSegments(
|
|
|
403
439
|
|
|
404
440
|
if (loading !== undefined && loading !== null) {
|
|
405
441
|
const loaderDataPromise = getMemoizedLoaderPromise(loaderEntries);
|
|
442
|
+
let boundaryLoaderData: Promise<any[]> | any[] = loaderDataPromise;
|
|
443
|
+
if (forceAwait || isAction) {
|
|
444
|
+
const awaitStart = segDebug ? performance.now() : 0;
|
|
445
|
+
boundaryLoaderData = await loaderDataPromise;
|
|
446
|
+
if (segDebug) {
|
|
447
|
+
segDebugLog(`segment ${id}: loaders awaited (forceAwait/action)`, {
|
|
448
|
+
loaderIds,
|
|
449
|
+
ms: Math.round(performance.now() - awaitStart),
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
} else if (segDebug) {
|
|
453
|
+
segDebugLog(
|
|
454
|
+
`segment ${id}: streaming loaders via LoaderBoundary (suspense)`,
|
|
455
|
+
{ loaderIds },
|
|
456
|
+
);
|
|
457
|
+
}
|
|
406
458
|
content = createElement(LoaderBoundary, {
|
|
407
459
|
key: `loader-boundary-${key}`,
|
|
408
|
-
loaderDataPromise:
|
|
409
|
-
forceAwait || isAction ? await loaderDataPromise : loaderDataPromise,
|
|
460
|
+
loaderDataPromise: boundaryLoaderData,
|
|
410
461
|
loaderIds,
|
|
411
462
|
fallback: loading,
|
|
412
463
|
outletKey: key,
|
|
@@ -430,7 +481,17 @@ export async function renderSegments(
|
|
|
430
481
|
);
|
|
431
482
|
|
|
432
483
|
const layoutLoaderIds = layoutLoaders.map((l) => l.loaderId!);
|
|
484
|
+
// No loading() on this segment, so its loader data cannot stream behind
|
|
485
|
+
// a Suspense fallback — the tree build BLOCKS here until the data
|
|
486
|
+
// arrives. On the initial document this await runs before hydrateRoot.
|
|
487
|
+
const layoutAwaitStart = segDebug ? performance.now() : 0;
|
|
433
488
|
const resolvedData = await buildLoaderPromise(layoutLoaders);
|
|
489
|
+
if (segDebug) {
|
|
490
|
+
segDebugLog(`segment ${id}: layout loaders awaited (blocking)`, {
|
|
491
|
+
loaderIds: layoutLoaderIds,
|
|
492
|
+
ms: Math.round(performance.now() - layoutAwaitStart),
|
|
493
|
+
});
|
|
494
|
+
}
|
|
434
495
|
const { loaderData, errorFallback } = decodeLoaderResults(
|
|
435
496
|
resolvedData,
|
|
436
497
|
layoutLoaderIds,
|
|
@@ -463,10 +524,27 @@ export async function renderSegments(
|
|
|
463
524
|
|
|
464
525
|
p.loaderIds = ownedLoaders.map((l) => l.loaderId!);
|
|
465
526
|
const aggregated = getMemoizedLoaderPromise(ownedLoaders);
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
527
|
+
if ((forceAwait || isAction) && aggregated instanceof Promise) {
|
|
528
|
+
const parallelAwaitStart = segDebug ? performance.now() : 0;
|
|
529
|
+
p.loaderDataPromise = await aggregated;
|
|
530
|
+
if (segDebug) {
|
|
531
|
+
segDebugLog(
|
|
532
|
+
`segment ${id}: parallel ${p.id} loaders awaited (forceAwait/action)`,
|
|
533
|
+
{
|
|
534
|
+
loaderIds: p.loaderIds,
|
|
535
|
+
ms: Math.round(performance.now() - parallelAwaitStart),
|
|
536
|
+
},
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
} else {
|
|
540
|
+
p.loaderDataPromise = aggregated;
|
|
541
|
+
if (segDebug) {
|
|
542
|
+
segDebugLog(
|
|
543
|
+
`segment ${id}: parallel ${p.id} loaders streaming (suspense)`,
|
|
544
|
+
{ loaderIds: p.loaderIds },
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
470
548
|
}
|
|
471
549
|
}
|
|
472
550
|
|
|
@@ -524,6 +602,12 @@ export async function renderSegments(
|
|
|
524
602
|
});
|
|
525
603
|
}
|
|
526
604
|
|
|
605
|
+
if (segDebug) {
|
|
606
|
+
segDebugLog("renderSegments complete", {
|
|
607
|
+
ms: Math.round(performance.now() - segDebugStart),
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
527
611
|
return result;
|
|
528
612
|
}
|
|
529
613
|
|
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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { createSsrRootComponent } from "./ssr-root.js";
|
|
3
|
+
import { injectRSCPayloadEager } from "./inject-rsc-eager.js";
|
|
3
4
|
import type { ErrorPhase } from "../types.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -247,11 +248,22 @@ async function readStreamToUint8Array(
|
|
|
247
248
|
const reader = stream.getReader();
|
|
248
249
|
const chunks: Uint8Array[] = [];
|
|
249
250
|
let total = 0;
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
251
|
+
try {
|
|
252
|
+
while (true) {
|
|
253
|
+
const { done, value } = await reader.read();
|
|
254
|
+
if (done) break;
|
|
255
|
+
chunks.push(value);
|
|
256
|
+
total += value.length;
|
|
257
|
+
}
|
|
258
|
+
} catch (error) {
|
|
259
|
+
// Mid-read abort path (documented): the prelude stream errors with our
|
|
260
|
+
// abort reason while we are still reading it. Cancel the source before
|
|
261
|
+
// rethrowing so it is not left uncancelled; releaseLock always runs in
|
|
262
|
+
// finally. Mirrors src/rsc/rsc-rendering.ts's serve-side reader cleanup.
|
|
263
|
+
reader.cancel(error).catch(() => {});
|
|
264
|
+
throw error;
|
|
265
|
+
} finally {
|
|
266
|
+
reader.releaseLock();
|
|
255
267
|
}
|
|
256
268
|
const out = new Uint8Array(total);
|
|
257
269
|
let offset = 0;
|
|
@@ -434,113 +446,140 @@ export function createShellCaptureHandler<TEnv = unknown>(
|
|
|
434
446
|
): Promise<ShellCaptureResult | null> {
|
|
435
447
|
const maxWaitMs = opts.maxWaitMs ?? DEFAULT_SHELL_CAPTURE_MAX_WAIT_MS;
|
|
436
448
|
|
|
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;
|
|
449
|
+
// Arm the maxWaitMs deadline BEFORE the first await so it bounds the ENTIRE
|
|
450
|
+
// capture, the bootstrap-script load included. loadBootstrapScriptContent()
|
|
451
|
+
// used to run before the timer, so a hung/slow bootstrap load hung
|
|
452
|
+
// captureShellHTML with no upper bound and held the background capture task
|
|
453
|
+
// open. One deadline, shared by the bootstrap race below and the quiesce
|
|
454
|
+
// race, keeps the whole path "bounded by maxWaitMs like every quiesce input".
|
|
455
|
+
const deadline = createCancelableTimeout(maxWaitMs);
|
|
511
456
|
try {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
// the
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
457
|
+
// No nonce (nonce'd requests never reach capture); no formState.
|
|
458
|
+
const SsrRoot = createSsrRootComponent({
|
|
459
|
+
createFromReadableStream,
|
|
460
|
+
rscStream,
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// Bootstrap load raced against the deadline. A load that never resolves
|
|
464
|
+
// within maxWaitMs is the same bounded no-shell degrade as a shell that
|
|
465
|
+
// never goes quiet: return null, do not hang. A load that REJECTS is a
|
|
466
|
+
// genuine error and still propagates (it is not the deadline). `null` is
|
|
467
|
+
// the deadline sentinel — disjoint from the load's `Promise<string>`, so
|
|
468
|
+
// the race narrows to `string | null` with no wrapper. The no-op catch
|
|
469
|
+
// keeps a late rejection off the unhandledRejection path when the deadline
|
|
470
|
+
// already won; a rejection that lands first still propagates out.
|
|
471
|
+
const load = loadBootstrapScriptContent();
|
|
472
|
+
load.catch(() => {});
|
|
473
|
+
const bootstrapScriptContent = await Promise.race([
|
|
474
|
+
load,
|
|
475
|
+
deadline.promise.then(() => null),
|
|
476
|
+
]);
|
|
477
|
+
if (bootstrapScriptContent === null) {
|
|
523
478
|
return null;
|
|
524
479
|
}
|
|
525
|
-
throw error;
|
|
526
|
-
}
|
|
527
480
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
481
|
+
// Start prerender first, then run the abort schedule concurrently. When
|
|
482
|
+
// holes are pending, prerender's promise settles only after abort(); when
|
|
483
|
+
// the shell completes with no holes it settles on its own and the later
|
|
484
|
+
// abort() is a harmless no-op (the DATA variant).
|
|
485
|
+
const controller = new AbortController();
|
|
486
|
+
// Private reason object: the deliberate abort is identified by object
|
|
487
|
+
// IDENTITY in both the onError below and the post-await catch. React
|
|
488
|
+
// propagates this EXACT object to onError for every still-pending boundary
|
|
489
|
+
// (verified identity-preserving), and rejects/errors the prelude with it.
|
|
490
|
+
const abortReason = { rangoShellCaptureAbort: true };
|
|
491
|
+
const prerenderPromise = prerender(<SsrRoot />, {
|
|
492
|
+
signal: controller.signal,
|
|
493
|
+
bootstrapScriptContent,
|
|
494
|
+
// Abort is how capture WORKS: once the shell is quiet we abort() to
|
|
495
|
+
// freeze the prelude and let the still-pending holes postpone. React
|
|
496
|
+
// reports the abort reason for each pending boundary through onError.
|
|
497
|
+
// Without an onError here React falls back to console.error, so every
|
|
498
|
+
// capture that still has a live hole at abort time (the normal case)
|
|
499
|
+
// dumps a stack once per pending boundary. That is EXPECTED degradation,
|
|
500
|
+
// so swallow OUR abort — matched by IDENTITY (error === abortReason).
|
|
501
|
+
// Discriminate by identity, NOT error.name: capture aborts before
|
|
502
|
+
// awaiting, so signal.aborted is unconditionally true and a name check
|
|
503
|
+
// swallowed genuine AbortError-named throws (a component's own
|
|
504
|
+
// fetch/AbortController cancellation) as if they were our abort. Genuine
|
|
505
|
+
// render errors are NOT our sentinel and still surface through
|
|
506
|
+
// deps.onError, the same channel renderHTML uses. See
|
|
507
|
+
// docs/design/ppr-shell-resume.md.
|
|
508
|
+
onError: (error: unknown) => {
|
|
509
|
+
if (error === abortReason) {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
reportRenderError(onError, error);
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
// Pre-attach a no-op catch: the real await sits AFTER quiesce + the
|
|
516
|
+
// post-quiesce hops, so an early prerender rejection (e.g. a bake-lane
|
|
517
|
+
// loader tripping the identity guard within milliseconds) would otherwise
|
|
518
|
+
// spend several turns handler-less and crash the worker as an unhandled
|
|
519
|
+
// rejection. The actual rejection handling still happens at the await
|
|
520
|
+
// below; this parallel handler only keeps the gap crash-free.
|
|
521
|
+
prerenderPromise.catch(() => {});
|
|
522
|
+
|
|
523
|
+
// Wait for the caller's quiesce signal, bounded by the SAME deadline. By
|
|
524
|
+
// the time it resolves the Flight input is byte-quiet and FROZEN by the
|
|
525
|
+
// capture gate (shell-capture.ts gateFlightForCapture), so there is no
|
|
526
|
+
// wall-clock debounce here — maxWaitMs is only the pathological guard for a
|
|
527
|
+
// shell that never goes quiet (a root postpone / hung handle).
|
|
528
|
+
await Promise.race([opts.quiesce, deadline.promise]);
|
|
529
|
+
// Fixed task hops before the abort: give React's fizz worker turns to flush
|
|
530
|
+
// the now-complete shell and mark the still-pending boundaries as POSTPONED
|
|
531
|
+
// rather than errored. Deterministic (the byte set is already frozen), so a
|
|
532
|
+
// fixed count of turns suffices — no wall-clock.
|
|
533
|
+
for (let i = 0; i < POST_QUIESCE_TASK_HOPS; i++) {
|
|
534
|
+
await macrotask();
|
|
535
|
+
}
|
|
536
|
+
controller.abort(abortReason);
|
|
537
|
+
|
|
538
|
+
// A hard prerender rejection (fatal shell error) propagates. Expected
|
|
539
|
+
// degradation surfaces three ways and all return null: a trivial prelude
|
|
540
|
+
// (sanity gate below), the prerender REJECTING with our abort reason, or
|
|
541
|
+
// the prelude STREAM erroring with our abort reason mid-read — both abort
|
|
542
|
+
// shapes happen when our own abort lands before the shell completed (seen
|
|
543
|
+
// on dev cold paths, where module transform / first-render latency
|
|
544
|
+
// outlasts flight quiesce; a later request re-captures against warm
|
|
545
|
+
// modules and succeeds).
|
|
546
|
+
let prelude: Uint8Array;
|
|
547
|
+
let postponed: unknown;
|
|
548
|
+
try {
|
|
549
|
+
const result = await prerenderPromise;
|
|
550
|
+
prelude = await readStreamToUint8Array(result.prelude);
|
|
551
|
+
postponed = result.postponed;
|
|
552
|
+
} catch (error) {
|
|
553
|
+
// Identity match: swallow ONLY our own deliberate abort
|
|
554
|
+
// (error === abortReason). Not error.name — capture aborts before this
|
|
555
|
+
// await, so signal.aborted is always true, and a name check let a
|
|
556
|
+
// genuine AbortError-named throw masquerade as our abort and degrade
|
|
557
|
+
// into a retryable no-shell, hiding real failures from reportCacheError.
|
|
558
|
+
if (error === abortReason) {
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
throw error;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Sanity gate: a prelude with no `<body` is the no-shell failure mode.
|
|
565
|
+
// Return null and store nothing; the request falls back to axis 1 and a
|
|
566
|
+
// later request re-captures. The dominant real-world cause is a loader
|
|
567
|
+
// route WITHOUT a route-level loading() boundary: renderSegments' loading-
|
|
568
|
+
// less branch awaits loader data at TREE-BUILD, so the masked loader pins
|
|
569
|
+
// the whole tree above <body> (root postpone). Root-postponing layouts and
|
|
570
|
+
// hung handles degrade the same way. shell-capture.ts logs a once-per-key
|
|
571
|
+
// warning so the eternal-MISS shape is diagnosable.
|
|
572
|
+
if (!new TextDecoder().decode(prelude).includes("<body")) {
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
539
575
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
576
|
+
return {
|
|
577
|
+
prelude,
|
|
578
|
+
postponed: postponed == null ? null : JSON.stringify(postponed),
|
|
579
|
+
};
|
|
580
|
+
} finally {
|
|
581
|
+
deadline.cancel();
|
|
582
|
+
}
|
|
544
583
|
};
|
|
545
584
|
}
|
|
546
585
|
|
|
@@ -556,7 +595,7 @@ export function createShellCaptureHandler<TEnv = unknown>(
|
|
|
556
595
|
export function createShellResumeHandler<TEnv = unknown>(
|
|
557
596
|
deps: SSRDependencies<TEnv>,
|
|
558
597
|
) {
|
|
559
|
-
const { createFromReadableStream,
|
|
598
|
+
const { createFromReadableStream, resume, onError } = deps;
|
|
560
599
|
|
|
561
600
|
/**
|
|
562
601
|
* @param rscStream - Fresh full Flight stream for this request.
|
|
@@ -571,11 +610,12 @@ export function createShellResumeHandler<TEnv = unknown>(
|
|
|
571
610
|
try {
|
|
572
611
|
if (postponed === null) {
|
|
573
612
|
// DATA variant: the stored prelude is the complete shell. No fizz runs;
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
613
|
+
// the eager injector pumps the fresh Flight payload scripts without
|
|
614
|
+
// needing an HTML chunk to trigger it (the stock injector deadlocked on
|
|
615
|
+
// a chunkless stream — see createDataVariantHtmlStream, kept for the
|
|
616
|
+
// batching invariant's sake).
|
|
577
617
|
return createDataVariantHtmlStream().pipeThrough(
|
|
578
|
-
|
|
618
|
+
injectRSCPayloadEager(rscStream, { nonce }),
|
|
579
619
|
);
|
|
580
620
|
}
|
|
581
621
|
|
|
@@ -602,7 +642,14 @@ export function createShellResumeHandler<TEnv = unknown>(
|
|
|
602
642
|
nonce,
|
|
603
643
|
});
|
|
604
644
|
|
|
605
|
-
|
|
645
|
+
// EAGER injection (resume-only): the stored prelude — a complete document
|
|
646
|
+
// through </body></html> — is already on the wire ahead of this stream,
|
|
647
|
+
// so a Flight <script> is valid as the first tail byte. The stock
|
|
648
|
+
// injector waits for the first fizz chunk, which only appears when the
|
|
649
|
+
// first hole's loaders resolve — parking the whole hydration payload
|
|
650
|
+
// (root row included) behind the slowest live loader. See
|
|
651
|
+
// inject-rsc-eager.ts for the measured failure mode.
|
|
652
|
+
return resumed.pipeThrough(injectRSCPayloadEager(rscStream2, { nonce }));
|
|
606
653
|
} catch (error) {
|
|
607
654
|
reportRenderError(onError, error);
|
|
608
655
|
throw error;
|