@rangojs/router 0.0.0-experimental.144 → 0.0.0-experimental.146
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/rango.js +1 -40
- package/dist/vite/index.js +35 -9
- package/package.json +1 -1
- package/skills/ppr/SKILL.md +29 -23
- package/src/browser/logging.ts +18 -0
- package/src/browser/rsc-router.tsx +43 -0
- package/src/cache/cache-runtime.ts +41 -51
- package/src/cache/cache-scope.ts +30 -1
- package/src/cache/cf/cf-cache-store.ts +4 -0
- package/src/cache/handle-snapshot.ts +22 -1
- package/src/cache/shell-snapshot.ts +47 -0
- package/src/cache/types.ts +31 -4
- package/src/cache/vercel/vercel-cache-store.ts +6 -1
- package/src/deps/ssr.ts +4 -1
- package/src/router/loader-resolution.ts +16 -0
- package/src/router/match-api.ts +9 -2
- package/src/router/match-handlers.ts +13 -0
- 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 +99 -0
- package/src/rsc/rsc-rendering.ts +139 -16
- package/src/rsc/shell-capture.ts +122 -0
- package/src/rsc/shell-serve.ts +37 -6
- package/src/segment-loader-promise.ts +18 -0
- package/src/segment-system.tsx +123 -9
- package/src/server/request-context.ts +47 -0
- package/src/ssr/index.tsx +118 -18
- package/src/ssr/inject-rsc-eager.ts +167 -0
- package/src/ssr/preinit-client-references.ts +106 -0
- package/src/vite/index.ts +8 -0
- package/src/vite/plugin-types.ts +33 -0
- package/src/vite/plugins/virtual-entries.ts +37 -4
- package/src/vite/rango.ts +10 -2
- package/src/vite/utils/shared-utils.ts +4 -2
package/src/segment-system.tsx
CHANGED
|
@@ -17,6 +17,28 @@ import {
|
|
|
17
17
|
getMemoizedLoaderPromise,
|
|
18
18
|
} from "./segment-loader-promise.js";
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Debug log for the segment tree build, gated on the baked flag. Runs on BOTH
|
|
22
|
+
* sides now, environment-tagged: `[Browser][segments]` lines up with the
|
|
23
|
+
* `[Browser][boot]` sequence around hydrateRoot; `[Server][segments]` exposes
|
|
24
|
+
* the SSR/RSC tree-build stalls (blocking loader awaits during fizz are what
|
|
25
|
+
* dominate MISS TTFB) that used to be invisible because the logs were
|
|
26
|
+
* window-gated. Server lines have no request correlation — segment-system is
|
|
27
|
+
* shared client code and cannot import request-context (node:async_hooks
|
|
28
|
+
* would enter the browser bundle) — so on a busy server, correlate by
|
|
29
|
+
* timestamp + segment ids.
|
|
30
|
+
*/
|
|
31
|
+
function segDebugLog(msg: string, details?: Record<string, unknown>): void {
|
|
32
|
+
if (!INTERNAL_RANGO_DEBUG) return;
|
|
33
|
+
const env = typeof window === "object" ? "[Browser]" : "[Server]";
|
|
34
|
+
const prefix = `${env}[segments] ${msg} @ ${Math.round(performance.now())}ms`;
|
|
35
|
+
if (details) {
|
|
36
|
+
console.log(prefix, details);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
console.log(prefix);
|
|
40
|
+
}
|
|
41
|
+
|
|
20
42
|
// ViewTransition is only available in React experimental.
|
|
21
43
|
// Access via namespace import to avoid compile-time errors on stable React.
|
|
22
44
|
const ReactViewTransition: any =
|
|
@@ -212,6 +234,17 @@ export async function renderSegments(
|
|
|
212
234
|
rootLayout: RootLayout,
|
|
213
235
|
} = options || {};
|
|
214
236
|
|
|
237
|
+
const segDebug = INTERNAL_RANGO_DEBUG;
|
|
238
|
+
const segDebugStart = segDebug ? performance.now() : 0;
|
|
239
|
+
if (segDebug) {
|
|
240
|
+
segDebugLog("renderSegments start", {
|
|
241
|
+
segments: segments.map((s) => `${s.id}:${s.type}`),
|
|
242
|
+
isAction: !!isAction,
|
|
243
|
+
forceAwait: !!forceAwait,
|
|
244
|
+
intercepts: interceptSegments?.length ?? 0,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
215
248
|
const temporalLazyRefs: Promise<any>[] = [];
|
|
216
249
|
const normalizedSegments = restoreParallelLoaderMarkers(segments);
|
|
217
250
|
const normalizedInterceptSegments = interceptSegments
|
|
@@ -272,6 +305,7 @@ export async function renderSegments(
|
|
|
272
305
|
`Expected layout, route, error, or notFound segment, got ${node.segment.type}`,
|
|
273
306
|
);
|
|
274
307
|
const { component, id, params, loading } = node.segment;
|
|
308
|
+
const segNodeStart = segDebug ? performance.now() : 0;
|
|
275
309
|
|
|
276
310
|
// Param-agnostic keys are opt-in via the transition() DSL (see
|
|
277
311
|
// inTransitionScope above). A route (and its route-owned layouts) inside a
|
|
@@ -314,7 +348,13 @@ export async function renderSegments(
|
|
|
314
348
|
|
|
315
349
|
let resolvedComponent = component;
|
|
316
350
|
if (isAction && component instanceof Promise) {
|
|
351
|
+
const componentAwaitStart = segDebug ? performance.now() : 0;
|
|
317
352
|
resolvedComponent = await component;
|
|
353
|
+
if (segDebug) {
|
|
354
|
+
segDebugLog(`segment ${id}: component awaited (action)`, {
|
|
355
|
+
ms: Math.round(performance.now() - componentAwaitStart),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
318
358
|
}
|
|
319
359
|
|
|
320
360
|
let nodeContent: ReactNode = null;
|
|
@@ -331,9 +371,16 @@ export async function renderSegments(
|
|
|
331
371
|
// suspends on mount inside the content still reveals a fallback (it is not
|
|
332
372
|
// pre-resolved).
|
|
333
373
|
const contentPromise = getMemoizedContentPromise(resolvedComponent);
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
:
|
|
374
|
+
let loadingContent: Promise<ReactNode> | ReactNode = contentPromise;
|
|
375
|
+
if (forceAwait) {
|
|
376
|
+
const contentAwaitStart = segDebug ? performance.now() : 0;
|
|
377
|
+
loadingContent = await contentPromise;
|
|
378
|
+
if (segDebug) {
|
|
379
|
+
segDebugLog(`segment ${id}: content awaited (forceAwait)`, {
|
|
380
|
+
ms: Math.round(performance.now() - contentAwaitStart),
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
337
384
|
nodeContent = createElement(RouteContentWrapper, {
|
|
338
385
|
key: `suspense-loading-${id}`,
|
|
339
386
|
content: loadingContent,
|
|
@@ -403,10 +450,25 @@ export async function renderSegments(
|
|
|
403
450
|
|
|
404
451
|
if (loading !== undefined && loading !== null) {
|
|
405
452
|
const loaderDataPromise = getMemoizedLoaderPromise(loaderEntries);
|
|
453
|
+
let boundaryLoaderData: Promise<any[]> | any[] = loaderDataPromise;
|
|
454
|
+
if (forceAwait || isAction) {
|
|
455
|
+
const awaitStart = segDebug ? performance.now() : 0;
|
|
456
|
+
boundaryLoaderData = await loaderDataPromise;
|
|
457
|
+
if (segDebug) {
|
|
458
|
+
segDebugLog(`segment ${id}: loaders awaited (forceAwait/action)`, {
|
|
459
|
+
loaderIds,
|
|
460
|
+
ms: Math.round(performance.now() - awaitStart),
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
} else if (segDebug) {
|
|
464
|
+
segDebugLog(
|
|
465
|
+
`segment ${id}: streaming loaders via LoaderBoundary (suspense)`,
|
|
466
|
+
{ loaderIds },
|
|
467
|
+
);
|
|
468
|
+
}
|
|
406
469
|
content = createElement(LoaderBoundary, {
|
|
407
470
|
key: `loader-boundary-${key}`,
|
|
408
|
-
loaderDataPromise:
|
|
409
|
-
forceAwait || isAction ? await loaderDataPromise : loaderDataPromise,
|
|
471
|
+
loaderDataPromise: boundaryLoaderData,
|
|
410
472
|
loaderIds,
|
|
411
473
|
fallback: loading,
|
|
412
474
|
outletKey: key,
|
|
@@ -430,11 +492,30 @@ export async function renderSegments(
|
|
|
430
492
|
);
|
|
431
493
|
|
|
432
494
|
const layoutLoaderIds = layoutLoaders.map((l) => l.loaderId!);
|
|
495
|
+
// No loading() on this segment, so its loader data cannot stream behind
|
|
496
|
+
// a Suspense fallback — the tree build BLOCKS here until the data
|
|
497
|
+
// arrives. On the initial document this await runs before hydrateRoot.
|
|
498
|
+
const layoutAwaitStart = segDebug ? performance.now() : 0;
|
|
433
499
|
const resolvedData = await buildLoaderPromise(layoutLoaders);
|
|
500
|
+
if (segDebug) {
|
|
501
|
+
segDebugLog(`segment ${id}: layout loaders awaited (blocking)`, {
|
|
502
|
+
loaderIds: layoutLoaderIds,
|
|
503
|
+
ms: Math.round(performance.now() - layoutAwaitStart),
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
const decodeStart = segDebug ? performance.now() : 0;
|
|
434
507
|
const { loaderData, errorFallback } = decodeLoaderResults(
|
|
435
508
|
resolvedData,
|
|
436
509
|
layoutLoaderIds,
|
|
437
510
|
);
|
|
511
|
+
if (segDebug) {
|
|
512
|
+
const decodeMs = Math.round(performance.now() - decodeStart);
|
|
513
|
+
if (decodeMs > 0) {
|
|
514
|
+
segDebugLog(`segment ${id}: loader results decoded`, {
|
|
515
|
+
ms: decodeMs,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
438
519
|
|
|
439
520
|
if (parallelOwnedLoaders.length > 0) {
|
|
440
521
|
const loadersByParallelNamespace = new Map<string, ResolvedSegment[]>();
|
|
@@ -463,10 +544,27 @@ export async function renderSegments(
|
|
|
463
544
|
|
|
464
545
|
p.loaderIds = ownedLoaders.map((l) => l.loaderId!);
|
|
465
546
|
const aggregated = getMemoizedLoaderPromise(ownedLoaders);
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
547
|
+
if ((forceAwait || isAction) && aggregated instanceof Promise) {
|
|
548
|
+
const parallelAwaitStart = segDebug ? performance.now() : 0;
|
|
549
|
+
p.loaderDataPromise = await aggregated;
|
|
550
|
+
if (segDebug) {
|
|
551
|
+
segDebugLog(
|
|
552
|
+
`segment ${id}: parallel ${p.id} loaders awaited (forceAwait/action)`,
|
|
553
|
+
{
|
|
554
|
+
loaderIds: p.loaderIds,
|
|
555
|
+
ms: Math.round(performance.now() - parallelAwaitStart),
|
|
556
|
+
},
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
} else {
|
|
560
|
+
p.loaderDataPromise = aggregated;
|
|
561
|
+
if (segDebug) {
|
|
562
|
+
segDebugLog(
|
|
563
|
+
`segment ${id}: parallel ${p.id} loaders streaming (suspense)`,
|
|
564
|
+
{ loaderIds: p.loaderIds },
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
470
568
|
}
|
|
471
569
|
}
|
|
472
570
|
|
|
@@ -490,6 +588,16 @@ export async function renderSegments(
|
|
|
490
588
|
children: content,
|
|
491
589
|
});
|
|
492
590
|
}
|
|
591
|
+
|
|
592
|
+
if (segDebug) {
|
|
593
|
+
segDebugLog(`segment ${id} built`, {
|
|
594
|
+
type: node.segment.type,
|
|
595
|
+
ms: Math.round(performance.now() - segNodeStart),
|
|
596
|
+
loaders: node.loaders.map((l) => l.loaderId).filter(Boolean),
|
|
597
|
+
hasLoading: loading !== undefined && loading !== null,
|
|
598
|
+
parallel: node.parallel.map((p) => p.id),
|
|
599
|
+
});
|
|
600
|
+
}
|
|
493
601
|
}
|
|
494
602
|
|
|
495
603
|
const errorBoundaryWrapped = createElement(RootErrorBoundary, {
|
|
@@ -524,6 +632,12 @@ export async function renderSegments(
|
|
|
524
632
|
});
|
|
525
633
|
}
|
|
526
634
|
|
|
635
|
+
if (segDebug) {
|
|
636
|
+
segDebugLog("renderSegments complete", {
|
|
637
|
+
ms: Math.round(performance.now() - segDebugStart),
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
527
641
|
return result;
|
|
528
642
|
}
|
|
529
643
|
|
|
@@ -210,6 +210,53 @@ export interface RequestContext<
|
|
|
210
210
|
*/
|
|
211
211
|
_shellLoaderSeed?: Map<string, unknown>;
|
|
212
212
|
|
|
213
|
+
/**
|
|
214
|
+
* @internal Shell fast-path marker: makes the NEXT full match treat the whole
|
|
215
|
+
* matched route as an implicit doc-level cache() boundary (see
|
|
216
|
+
* resolveShellImplicitCacheScope in cache/cache-scope.ts). Set ONLY on
|
|
217
|
+
* (a) the capture's derived context — with a record-only store so the
|
|
218
|
+
* capture's cacheRoute write lands in the snapshot, never the real store —
|
|
219
|
+
* and (b) a HIT tail's seeded context when the entry is eligible
|
|
220
|
+
* (!handlerLiveHoles), where the SeededShellStore serves the recorded doc
|
|
221
|
+
* entry and the match skips handler execution entirely. Routes with their
|
|
222
|
+
* own cache() config (including cache(false)) are never overridden: the
|
|
223
|
+
* marker only applies when the route tree derived NO cache scope.
|
|
224
|
+
*/
|
|
225
|
+
_shellImplicitCache?: {
|
|
226
|
+
ttl?: number;
|
|
227
|
+
swr?: number;
|
|
228
|
+
store?: SegmentCacheStore;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* @internal Handler-layer liveness observed DURING a shell capture, from
|
|
233
|
+
* three sources: (a) the capture handle-store push wrapper (shell-capture.ts)
|
|
234
|
+
* when a push made OUTSIDE a DSL loader scope carries a nested thenable
|
|
235
|
+
* (masked to a never-filling hole); (b) still-pending top-level handler
|
|
236
|
+
* pushes (liveness unknowable at the barrier); (c) a handler-invoked loader
|
|
237
|
+
* executing during the capture (loader-resolution.ts — its consumption-lane
|
|
238
|
+
* value would freeze on a handler-free HIT). captureAndStoreShell folds it
|
|
239
|
+
* into ShellCacheEntry.handlerLiveHoles at the putShell barrier. Own
|
|
240
|
+
* property of the capture's derived context only.
|
|
241
|
+
*/
|
|
242
|
+
_shellCaptureHandleLiveness?: {
|
|
243
|
+
holes: boolean;
|
|
244
|
+
pendingPushes: number;
|
|
245
|
+
handlerInvokedLoader: boolean;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* @internal Handle values pushed from a DSL loader scope DURING a shell
|
|
250
|
+
* capture (identity set; populated by the capture push wrapper in
|
|
251
|
+
* shell-capture.ts). cacheRoute threads it into captureHandles so those
|
|
252
|
+
* values stay out of cache-write handle records — loaders re-run fresh on
|
|
253
|
+
* every HIT, so replaying their captured (masked) values would duplicate
|
|
254
|
+
* the fresh push and stall the Flight handle encode. Own property of the
|
|
255
|
+
* capture's derived context only; render-time handle consumers are
|
|
256
|
+
* unaffected (the exclusion applies only at the captureHandles call site).
|
|
257
|
+
*/
|
|
258
|
+
_shellCaptureLoaderHandleValues?: WeakSet<object>;
|
|
259
|
+
|
|
213
260
|
/**
|
|
214
261
|
* @internal Set (to the offending fn name) by the cookies()/headers()
|
|
215
262
|
* capture guard when it throws DURING a capture render. Load-bearing for the
|
package/src/ssr/index.tsx
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { createSsrRootComponent } from "./ssr-root.js";
|
|
3
|
+
import { injectRSCPayloadEager } from "./inject-rsc-eager.js";
|
|
4
|
+
import { runWithPreinitNonce } from "./preinit-client-references.js";
|
|
3
5
|
import type { ErrorPhase } from "../types.js";
|
|
6
|
+
import type { HeadScriptsOption } from "../vite/plugin-types.js";
|
|
7
|
+
|
|
8
|
+
export { installClientReferencePreinit } from "./preinit-client-references.js";
|
|
4
9
|
|
|
5
10
|
/**
|
|
6
11
|
* Options for injectRSCPayload
|
|
@@ -17,6 +22,7 @@ export interface InjectRSCPayloadOptions {
|
|
|
17
22
|
*/
|
|
18
23
|
interface RenderToReadableStreamOptions {
|
|
19
24
|
bootstrapScriptContent?: string;
|
|
25
|
+
bootstrapModules?: string[];
|
|
20
26
|
nonce?: string;
|
|
21
27
|
formState?: unknown;
|
|
22
28
|
}
|
|
@@ -34,6 +40,7 @@ interface ReactDOMReadableStream extends ReadableStream<Uint8Array> {
|
|
|
34
40
|
interface PrerenderOptions {
|
|
35
41
|
signal?: AbortSignal;
|
|
36
42
|
bootstrapScriptContent?: string;
|
|
43
|
+
bootstrapModules?: string[];
|
|
37
44
|
onError?: (error: unknown) => void;
|
|
38
45
|
}
|
|
39
46
|
|
|
@@ -132,6 +139,17 @@ export interface SSRDependencies<TEnv = unknown> {
|
|
|
132
139
|
*/
|
|
133
140
|
loadBootstrapScriptContent: () => Promise<string>;
|
|
134
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Document script strategy; the generated virtual SSR entry threads the
|
|
144
|
+
* `rango({ headScripts })` plugin option here (canonical docs on
|
|
145
|
+
* `RangoBaseOptions.headScripts` in vite/plugin-types.ts). The
|
|
146
|
+
* bootstrapModules conversion runs ONLY on an explicit `"preinit"`:
|
|
147
|
+
* undefined keeps the inline bootstrap verbatim, so a custom SSR entry that
|
|
148
|
+
* never installed the preinit hook cannot drift into the half-converted
|
|
149
|
+
* state on upgrade (the generated entry always passes an explicit value).
|
|
150
|
+
*/
|
|
151
|
+
headScripts?: HeadScriptsOption;
|
|
152
|
+
|
|
135
153
|
/**
|
|
136
154
|
* prerender from react-dom/static.edge. Optional; required only by
|
|
137
155
|
* {@link createShellCaptureHandler} for PPR shell capture.
|
|
@@ -325,6 +343,47 @@ interface ShellResumeOptions {
|
|
|
325
343
|
nonce?: string;
|
|
326
344
|
}
|
|
327
345
|
|
|
346
|
+
/**
|
|
347
|
+
* The exact shape plugin-rsc's loadBootstrapScriptContent returns in both dev
|
|
348
|
+
* and build: a single dynamic import of the browser entry, nothing else.
|
|
349
|
+
* Escapes/other statements never appear in that generated content; anything
|
|
350
|
+
* that doesn't match falls back to inline bootstrapScriptContent unchanged.
|
|
351
|
+
*/
|
|
352
|
+
const BOOTSTRAP_IMPORT_ONLY_RE =
|
|
353
|
+
/^\s*import\(\s*(["'])([^"'\\]+)\1\s*\)\s*;?\s*$/;
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Prefer bootstrapModules over the inline import() bootstrap. When the content
|
|
357
|
+
* is exactly `import("<entry-url>")`, hand Fizz the URL instead: React then
|
|
358
|
+
* emits a `<link rel="modulepreload" fetchpriority="low">` hint in the head
|
|
359
|
+
* plus the executing `<script type="module" src async>` at end of shell — the
|
|
360
|
+
* entry fetch starts with the first flushed bytes instead of when the parser
|
|
361
|
+
* reaches an opaque inline script that only reveals the URL once executed.
|
|
362
|
+
* Fizz stamps the request nonce on both tags (the inline form needed that
|
|
363
|
+
* too), and under PPR both land in the stored prelude; on resume React has
|
|
364
|
+
* already cleared the bootstrap fields from the postponed state, so nothing
|
|
365
|
+
* re-emits.
|
|
366
|
+
*/
|
|
367
|
+
function resolveBootstrapOptions(
|
|
368
|
+
content: string,
|
|
369
|
+
headScripts: SSRDependencies["headScripts"],
|
|
370
|
+
): Pick<
|
|
371
|
+
RenderToReadableStreamOptions,
|
|
372
|
+
"bootstrapScriptContent" | "bootstrapModules"
|
|
373
|
+
> {
|
|
374
|
+
// Explicit opt-in only: undefined (a custom SSR entry that predates the
|
|
375
|
+
// option, which also never installed the preinit hook) keeps the inline
|
|
376
|
+
// bootstrap byte-for-byte — converting by default would break CSPs that
|
|
377
|
+
// allowlist the known inline import() via a script hash.
|
|
378
|
+
if (headScripts !== "preinit") {
|
|
379
|
+
return { bootstrapScriptContent: content };
|
|
380
|
+
}
|
|
381
|
+
const match = BOOTSTRAP_IMPORT_ONLY_RE.exec(content);
|
|
382
|
+
return match
|
|
383
|
+
? { bootstrapModules: [match[2]!] }
|
|
384
|
+
: { bootstrapScriptContent: content };
|
|
385
|
+
}
|
|
386
|
+
|
|
328
387
|
/**
|
|
329
388
|
* Create an SSR handler that converts RSC streams to HTML.
|
|
330
389
|
*
|
|
@@ -382,12 +441,16 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
|
|
|
382
441
|
|
|
383
442
|
// Render React tree to HTML stream
|
|
384
443
|
// Pass formState for useActionState progressive enhancement if provided
|
|
385
|
-
// Pass nonce for CSP if provided
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
444
|
+
// Pass nonce for CSP if provided. runWithPreinitNonce makes the same
|
|
445
|
+
// nonce visible to the client-reference preinit hook (ALS — the hook is
|
|
446
|
+
// isolate-global, the nonce per request).
|
|
447
|
+
const htmlStream = await runWithPreinitNonce(nonce, () =>
|
|
448
|
+
renderToReadableStream(<SsrRoot />, {
|
|
449
|
+
...resolveBootstrapOptions(bootstrapScriptContent, deps.headScripts),
|
|
450
|
+
formState,
|
|
451
|
+
nonce,
|
|
452
|
+
}),
|
|
453
|
+
);
|
|
391
454
|
|
|
392
455
|
// Wait for all Suspense boundaries to resolve when streamMode is "allReady".
|
|
393
456
|
// This buffers the entire HTML before flushing — used for bots that
|
|
@@ -489,7 +552,7 @@ export function createShellCaptureHandler<TEnv = unknown>(
|
|
|
489
552
|
const abortReason = { rangoShellCaptureAbort: true };
|
|
490
553
|
const prerenderPromise = prerender(<SsrRoot />, {
|
|
491
554
|
signal: controller.signal,
|
|
492
|
-
bootstrapScriptContent,
|
|
555
|
+
...resolveBootstrapOptions(bootstrapScriptContent, deps.headScripts),
|
|
493
556
|
// Abort is how capture WORKS: once the shell is quiet we abort() to
|
|
494
557
|
// freeze the prelude and let the still-pending holes postpone. React
|
|
495
558
|
// reports the abort reason for each pending boundary through onError.
|
|
@@ -594,7 +657,7 @@ export function createShellCaptureHandler<TEnv = unknown>(
|
|
|
594
657
|
export function createShellResumeHandler<TEnv = unknown>(
|
|
595
658
|
deps: SSRDependencies<TEnv>,
|
|
596
659
|
) {
|
|
597
|
-
const { createFromReadableStream,
|
|
660
|
+
const { createFromReadableStream, resume, onError } = deps;
|
|
598
661
|
|
|
599
662
|
/**
|
|
600
663
|
* @param rscStream - Fresh full Flight stream for this request.
|
|
@@ -609,11 +672,12 @@ export function createShellResumeHandler<TEnv = unknown>(
|
|
|
609
672
|
try {
|
|
610
673
|
if (postponed === null) {
|
|
611
674
|
// DATA variant: the stored prelude is the complete shell. No fizz runs;
|
|
612
|
-
//
|
|
613
|
-
//
|
|
614
|
-
//
|
|
675
|
+
// the eager injector pumps the fresh Flight payload scripts without
|
|
676
|
+
// needing an HTML chunk to trigger it (the stock injector deadlocked on
|
|
677
|
+
// a chunkless stream — see createDataVariantHtmlStream, kept for the
|
|
678
|
+
// batching invariant's sake).
|
|
615
679
|
return createDataVariantHtmlStream().pipeThrough(
|
|
616
|
-
|
|
680
|
+
injectRSCPayloadEager(rscStream, { nonce }),
|
|
617
681
|
);
|
|
618
682
|
}
|
|
619
683
|
|
|
@@ -635,12 +699,48 @@ export function createShellResumeHandler<TEnv = unknown>(
|
|
|
635
699
|
nonce,
|
|
636
700
|
});
|
|
637
701
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
702
|
+
// EAGER injection (resume-only): the stored prelude — a complete document
|
|
703
|
+
// through </body></html> — is already on the wire ahead of this stream,
|
|
704
|
+
// so a Flight <script> is valid as the first tail byte. The stock
|
|
705
|
+
// injector waits for the first fizz chunk, which only appears when the
|
|
706
|
+
// first hole's loaders resolve — parking the whole hydration payload
|
|
707
|
+
// (root row included) behind the slowest live loader. See
|
|
708
|
+
// inject-rsc-eager.ts for the measured failure mode.
|
|
709
|
+
//
|
|
710
|
+
// EAGER HANDOVER: return the injector's readable BEFORE awaiting
|
|
711
|
+
// resume(). react-dom's resume() promise resolves only when the resumed
|
|
712
|
+
// shell (everything above the postponed holes) completes — which waits
|
|
713
|
+
// on the live loaders — so `await resume(...).pipeThrough(...)` parked
|
|
714
|
+
// the already-flowing Flight bytes a second time, behind the handshake
|
|
715
|
+
// instead of the stream (measured: injector output at +9ms, first tail
|
|
716
|
+
// byte on the wire at +735ms). Piping fizz in when it materializes lets
|
|
717
|
+
// serveShellHit start draining Flight immediately; pipeTo closes the
|
|
718
|
+
// writable on completion, which runs the injector's flush (trailer). A
|
|
719
|
+
// resume() rejection aborts the writable so the response errors instead
|
|
720
|
+
// of hanging.
|
|
721
|
+
const injector = injectRSCPayloadEager(rscStream2, { nonce });
|
|
722
|
+
void (async () => {
|
|
723
|
+
try {
|
|
724
|
+
// Nonce wrap mirrors renderHTML: client references first discovered
|
|
725
|
+
// during resume (holes the shell never rendered) preinit into the
|
|
726
|
+
// resumed stream and need the per-request nonce.
|
|
727
|
+
const resumed = await runWithPreinitNonce(nonce, () =>
|
|
728
|
+
resume(<SsrRoot />, JSON.parse(postponed), {
|
|
729
|
+
onError: (error) => reportRenderError(onError, error),
|
|
730
|
+
nonce,
|
|
731
|
+
}),
|
|
732
|
+
);
|
|
733
|
+
await resumed.pipeTo(injector.writable);
|
|
734
|
+
} catch (error) {
|
|
735
|
+
reportRenderError(onError, error);
|
|
736
|
+
try {
|
|
737
|
+
await injector.writable.abort(error);
|
|
738
|
+
} catch {
|
|
739
|
+
// Writable already errored/closed; the readable side has the error.
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
})();
|
|
743
|
+
return injector.readable;
|
|
644
744
|
} catch (error) {
|
|
645
745
|
reportRenderError(onError, error);
|
|
646
746
|
throw error;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Eager Flight-payload injector for the PPR resume path.
|
|
5
|
+
*
|
|
6
|
+
* rsc-html-stream's injectRSCPayload starts forwarding Flight chunks only from
|
|
7
|
+
* inside its first transform() callback — i.e. AFTER the first HTML chunk flows.
|
|
8
|
+
* That policy exists for the normal document path (a <script> must not precede
|
|
9
|
+
* the doctype). On a PPR shell HIT it parks the ENTIRE hydration payload: the
|
|
10
|
+
* resumed fizz render emits its first chunk only when the first hole's data
|
|
11
|
+
* resolves (live loaders — measured ~1.5s on SFCC-backed pages), while the
|
|
12
|
+
* Flight root row is ready within ~30ms of the tail render starting. The client
|
|
13
|
+
* cannot call hydrateRoot until that root row arrives, so the lazy start held
|
|
14
|
+
* hydration hostage to the slowest loader for no structural reason: the stored
|
|
15
|
+
* prelude (a complete document through </body></html>) is already on the wire
|
|
16
|
+
* before the tail, so every tail byte is foster-parented and a Flight <script>
|
|
17
|
+
* is valid as the FIRST tail byte.
|
|
18
|
+
*
|
|
19
|
+
* This injector starts pumping Flight chunks immediately in start(). Ordering
|
|
20
|
+
* safety is kept by serializing ALL writes through one promise chain: fizz
|
|
21
|
+
* chunks buffered within a tick flush as one atomic task (same batching idea as
|
|
22
|
+
* the stock injector — never inject between two partial HTML chunks), and each
|
|
23
|
+
* Flight script is its own task, so scripts land only between batches. The
|
|
24
|
+
* trailer is stripped from passing HTML and re-appended once, after both
|
|
25
|
+
* streams complete — identical to the stock contract.
|
|
26
|
+
*
|
|
27
|
+
* RESUME/DATA-VARIANT ONLY. The normal document path must keep the stock
|
|
28
|
+
* injector: there the first bytes are the document head, and an eager script
|
|
29
|
+
* would precede the doctype.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const encoder = new TextEncoder();
|
|
33
|
+
const TRAILER = "</body></html>";
|
|
34
|
+
|
|
35
|
+
// Escape closing script tags and HTML comments in JS content (ported from
|
|
36
|
+
// rsc-html-stream/server; escapes the "s" instead of the slash so a regexp
|
|
37
|
+
// literal like `0</script/` stays valid JS).
|
|
38
|
+
function escapeScript(script: string): string {
|
|
39
|
+
return script.replace(/<!--/g, "<\\!--").replace(/<\/(script)/gi, "</\\$1");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeScript(
|
|
43
|
+
controller: TransformStreamDefaultController<Uint8Array>,
|
|
44
|
+
jsExpr: string,
|
|
45
|
+
nonce: string | undefined,
|
|
46
|
+
): void {
|
|
47
|
+
controller.enqueue(
|
|
48
|
+
encoder.encode(
|
|
49
|
+
`<script${nonce ? ` nonce="${nonce}"` : ""}>${escapeScript(
|
|
50
|
+
`(self.__FLIGHT_DATA||=[]).push(${jsExpr})`,
|
|
51
|
+
)}</script>`,
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function injectRSCPayloadEager(
|
|
57
|
+
rscStream: ReadableStream<Uint8Array>,
|
|
58
|
+
options?: { nonce?: string },
|
|
59
|
+
): TransformStream<Uint8Array, Uint8Array> {
|
|
60
|
+
const nonce = options?.nonce;
|
|
61
|
+
const htmlDecoder = new TextDecoder();
|
|
62
|
+
const t0 = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
|
|
63
|
+
let loggedFirstFlight = false;
|
|
64
|
+
let loggedFirstHtml = false;
|
|
65
|
+
|
|
66
|
+
// All output goes through this chain: one task per Flight script, one task
|
|
67
|
+
// per buffered-HTML batch. A script can therefore never split a batch.
|
|
68
|
+
let queue: Promise<void> = Promise.resolve();
|
|
69
|
+
const enqueueTask = (fn: () => void): Promise<void> => {
|
|
70
|
+
queue = queue.then(fn);
|
|
71
|
+
return queue;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
let buffered: Uint8Array[] = [];
|
|
75
|
+
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
76
|
+
let rscDone: Promise<void> = Promise.resolve();
|
|
77
|
+
|
|
78
|
+
function flushBufferedHTML(
|
|
79
|
+
controller: TransformStreamDefaultController<Uint8Array>,
|
|
80
|
+
): void {
|
|
81
|
+
if (INTERNAL_RANGO_DEBUG && !loggedFirstHtml && buffered.length > 0) {
|
|
82
|
+
loggedFirstHtml = true;
|
|
83
|
+
console.log(
|
|
84
|
+
`[Server][ppr] eager-inject: first resumed HTML batch +${Math.round(performance.now() - t0)}ms (abs ${Math.round(performance.now())})`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
for (const chunk of buffered) {
|
|
88
|
+
let buf = htmlDecoder.decode(chunk, { stream: true });
|
|
89
|
+
if (buf.endsWith(TRAILER)) buf = buf.slice(0, -TRAILER.length);
|
|
90
|
+
controller.enqueue(encoder.encode(buf));
|
|
91
|
+
}
|
|
92
|
+
const remaining = htmlDecoder.decode();
|
|
93
|
+
if (remaining.length) {
|
|
94
|
+
const out = remaining.endsWith(TRAILER)
|
|
95
|
+
? remaining.slice(0, -TRAILER.length)
|
|
96
|
+
: remaining;
|
|
97
|
+
controller.enqueue(encoder.encode(out));
|
|
98
|
+
}
|
|
99
|
+
buffered.length = 0;
|
|
100
|
+
timeout = null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function pumpRSC(
|
|
104
|
+
controller: TransformStreamDefaultController<Uint8Array>,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
const rscDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
107
|
+
const reader = rscStream.getReader();
|
|
108
|
+
for (;;) {
|
|
109
|
+
const { done, value } = await reader.read();
|
|
110
|
+
if (done) break;
|
|
111
|
+
// String when the chunk is valid unicode, base64 round-trip otherwise —
|
|
112
|
+
// same fallback the stock injector uses.
|
|
113
|
+
let jsExpr: string;
|
|
114
|
+
try {
|
|
115
|
+
jsExpr = JSON.stringify(rscDecoder.decode(value, { stream: true }));
|
|
116
|
+
} catch {
|
|
117
|
+
const base64 = JSON.stringify(
|
|
118
|
+
btoa(String.fromCodePoint(...(value as Uint8Array))),
|
|
119
|
+
);
|
|
120
|
+
jsExpr = `Uint8Array.from(atob(${base64}), m => m.codePointAt(0))`;
|
|
121
|
+
}
|
|
122
|
+
await enqueueTask(() => {
|
|
123
|
+
if (INTERNAL_RANGO_DEBUG && !loggedFirstFlight) {
|
|
124
|
+
loggedFirstFlight = true;
|
|
125
|
+
console.log(
|
|
126
|
+
`[Server][ppr] eager-inject: first flight script +${Math.round(performance.now() - t0)}ms (abs ${Math.round(performance.now())})`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
writeScript(controller, jsExpr, nonce);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const remaining = rscDecoder.decode();
|
|
133
|
+
if (remaining.length) {
|
|
134
|
+
await enqueueTask(() =>
|
|
135
|
+
writeScript(controller, JSON.stringify(remaining), nonce),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return new TransformStream<Uint8Array, Uint8Array>({
|
|
141
|
+
start(controller) {
|
|
142
|
+
// The eager part: pump Flight immediately, before any HTML arrives.
|
|
143
|
+
rscDone = pumpRSC(controller).catch((err) => {
|
|
144
|
+
try {
|
|
145
|
+
controller.error(err);
|
|
146
|
+
} catch {
|
|
147
|
+
// Stream already errored/closed; nothing to signal.
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
transform(chunk, controller) {
|
|
152
|
+
buffered.push(chunk);
|
|
153
|
+
if (timeout) return;
|
|
154
|
+
// Batch same-tick fizz chunks so a Flight script cannot land between two
|
|
155
|
+
// partial HTML chunks of one logical write (stock injector's invariant).
|
|
156
|
+
timeout = setTimeout(() => {
|
|
157
|
+
void enqueueTask(() => flushBufferedHTML(controller));
|
|
158
|
+
}, 0);
|
|
159
|
+
},
|
|
160
|
+
async flush(controller) {
|
|
161
|
+
await rscDone;
|
|
162
|
+
if (timeout) clearTimeout(timeout);
|
|
163
|
+
await enqueueTask(() => flushBufferedHTML(controller));
|
|
164
|
+
controller.enqueue(encoder.encode(TRAILER));
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|