@rangojs/router 0.0.0-experimental.145 → 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 +34 -9
- package/package.json +1 -1
- package/src/cache/cache-scope.ts +30 -1
- package/src/cache/handle-snapshot.ts +22 -1
- package/src/cache/shell-snapshot.ts +47 -0
- package/src/cache/types.ts +13 -0
- 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/mask-nested.ts +19 -3
- package/src/rsc/rsc-rendering.ts +54 -2
- package/src/rsc/shell-capture.ts +94 -4
- package/src/segment-system.tsx +49 -19
- package/src/server/request-context.ts +47 -0
- package/src/ssr/index.tsx +104 -13
- package/src/ssr/inject-rsc-eager.ts +2 -2
- package/src/ssr/preinit-client-references.ts +106 -0
- package/src/vite/index.ts +1 -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/rsc/shell-capture.ts
CHANGED
|
@@ -28,7 +28,11 @@ import {
|
|
|
28
28
|
type RequestContext,
|
|
29
29
|
} from "../server/request-context.js";
|
|
30
30
|
import { createHandleStore, type HandleStore } from "../server/handle-store.js";
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
maskNestedContainerThenables,
|
|
33
|
+
type MaskReport,
|
|
34
|
+
} from "../router/segment-resolution/mask-nested.js";
|
|
35
|
+
import { isInsideLoaderScope } from "../server/context.js";
|
|
32
36
|
import { isThenable } from "../handles/is-thenable.js";
|
|
33
37
|
import type {
|
|
34
38
|
ShellCacheEntry,
|
|
@@ -41,6 +45,7 @@ import {
|
|
|
41
45
|
} from "../router/segment-resolution/loader-snapshot.js";
|
|
42
46
|
import {
|
|
43
47
|
RecordingShellStore,
|
|
48
|
+
SnapshotOnlySegmentStore,
|
|
44
49
|
getRecordingStore,
|
|
45
50
|
} from "../cache/shell-snapshot.js";
|
|
46
51
|
import type { HandlerContext } from "./handler-context.js";
|
|
@@ -701,6 +706,18 @@ async function runShellCapture(
|
|
|
701
706
|
return "no-shell";
|
|
702
707
|
}
|
|
703
708
|
|
|
709
|
+
/** Fold the capture's handle-liveness record into the entry flag (true | undefined). */
|
|
710
|
+
function handlerLayerIsLive(
|
|
711
|
+
liveness: RequestContext["_shellCaptureHandleLiveness"],
|
|
712
|
+
): true | undefined {
|
|
713
|
+
if (!liveness) return undefined;
|
|
714
|
+
return liveness.holes ||
|
|
715
|
+
liveness.pendingPushes > 0 ||
|
|
716
|
+
liveness.handlerInvokedLoader
|
|
717
|
+
? true
|
|
718
|
+
: undefined;
|
|
719
|
+
}
|
|
720
|
+
|
|
704
721
|
/**
|
|
705
722
|
* One capture attempt in a DERIVED request context.
|
|
706
723
|
*
|
|
@@ -764,21 +781,74 @@ async function attemptCapture(
|
|
|
764
781
|
// the store exists only for this capture attempt, so every push wrapper
|
|
765
782
|
// (setupLoaderAccess, createUseFunction, prerender) inherits the policy and
|
|
766
783
|
// the foreground store is untouched.
|
|
784
|
+
// Shell fast path bookkeeping on the same funnel:
|
|
785
|
+
// - handleLiveness: a nested thenable in a push made OUTSIDE a DSL loader
|
|
786
|
+
// scope (attribution read synchronously at push time — handler bodies,
|
|
787
|
+
// handler-invoked ctx.use(loader) callbacks, defers) declares
|
|
788
|
+
// handler-layer per-request data. Its mask is a hole only a handler
|
|
789
|
+
// re-run can fill, so the entry must not serve handler-free
|
|
790
|
+
// (ShellCacheEntry.handlerLiveHoles). Still-pending top-level handler
|
|
791
|
+
// pushes at the putShell barrier count too — their liveness is unknowable.
|
|
792
|
+
// - loaderScopedPushValues: DSL-loader pushes re-run fresh on every HIT, so
|
|
793
|
+
// their captured values must NOT enter a segment record's handle snapshot
|
|
794
|
+
// (replay would duplicate the fresh push, and their masked nested
|
|
795
|
+
// promises would stall the Flight handle encode to its timeout). The set
|
|
796
|
+
// rides the derived context (_shellCaptureLoaderHandleValues) and is
|
|
797
|
+
// applied ONLY at the captureHandles cache-write call site — every other
|
|
798
|
+
// getDataForSegment consumer (the render-barrier snapshot, prerender)
|
|
799
|
+
// sees every push.
|
|
800
|
+
const handleLiveness = {
|
|
801
|
+
holes: false,
|
|
802
|
+
pendingPushes: 0,
|
|
803
|
+
handlerInvokedLoader: false,
|
|
804
|
+
};
|
|
805
|
+
const loaderScopedPushValues = new WeakSet<object>();
|
|
767
806
|
const rawCapturePush = freshHandleStore.push.bind(freshHandleStore);
|
|
768
807
|
freshHandleStore.push = (
|
|
769
808
|
handleName: string,
|
|
770
809
|
segmentId: string,
|
|
771
810
|
value: unknown,
|
|
772
811
|
) => {
|
|
773
|
-
const
|
|
774
|
-
|
|
775
|
-
|
|
812
|
+
const pushedInLoaderScope = isInsideLoaderScope();
|
|
813
|
+
// Single walk: the mask reports whether it masked any nested thenable
|
|
814
|
+
// (the liveness declaration) while building the capture copy.
|
|
815
|
+
const maskWithLiveness = (v: unknown): unknown => {
|
|
816
|
+
const report: MaskReport = { thenable: false };
|
|
817
|
+
const masked = maskNestedContainerThenables(v, undefined, report);
|
|
818
|
+
if (!pushedInLoaderScope && report.thenable) {
|
|
819
|
+
handleLiveness.holes = true;
|
|
820
|
+
}
|
|
821
|
+
return masked;
|
|
822
|
+
};
|
|
823
|
+
let masked: unknown;
|
|
824
|
+
if (isThenable(value)) {
|
|
825
|
+
if (!pushedInLoaderScope) {
|
|
826
|
+
handleLiveness.pendingPushes++;
|
|
827
|
+
const settle = () => handleLiveness.pendingPushes--;
|
|
828
|
+
value.then(settle, settle);
|
|
829
|
+
}
|
|
830
|
+
masked = value.then(maskWithLiveness);
|
|
831
|
+
} else {
|
|
832
|
+
masked = maskWithLiveness(value);
|
|
833
|
+
}
|
|
834
|
+
if (pushedInLoaderScope && typeof masked === "object" && masked !== null) {
|
|
835
|
+
loaderScopedPushValues.add(masked);
|
|
836
|
+
}
|
|
776
837
|
rawCapturePush(handleName, segmentId, masked);
|
|
777
838
|
};
|
|
778
839
|
|
|
779
840
|
const derivedCtx: RequestContext = Object.create(reqCtx);
|
|
780
841
|
derivedCtx._handleStore = freshHandleStore;
|
|
842
|
+
derivedCtx._shellCaptureLoaderHandleValues = loaderScopedPushValues;
|
|
781
843
|
derivedCtx._requestTags = new Set<string>();
|
|
844
|
+
// Own explicit-store registry: cache-store resolutions during the capture
|
|
845
|
+
// (the implicit scope's SnapshotOnlySegmentStore, any per-capture explicit
|
|
846
|
+
// store instance) must NOT register into the handler-lifetime
|
|
847
|
+
// _explicitTaggedStores set — a capture-ephemeral store pinned there would
|
|
848
|
+
// trip the partial-tag-store warning on every later updateTag() and retain
|
|
849
|
+
// the whole capture snapshot in memory. Capture registrations die with this
|
|
850
|
+
// context; module-singleton stores stay registered by normal renders.
|
|
851
|
+
derivedCtx._explicitTaggedStores = new Set();
|
|
782
852
|
derivedCtx._transitionWhen = [];
|
|
783
853
|
derivedCtx._shellCaptureRun = true;
|
|
784
854
|
derivedCtx._metricsStore = undefined;
|
|
@@ -812,6 +882,7 @@ async function attemptCapture(
|
|
|
812
882
|
// forwarding to the parent so the write persists and the worker stays alive),
|
|
813
883
|
// then captureAndStoreShell awaits them before draining. Reads that HIT are
|
|
814
884
|
// recorded synchronously during the render and need none of this.
|
|
885
|
+
derivedCtx._shellCaptureHandleLiveness = handleLiveness;
|
|
815
886
|
if (reqCtx._cacheStore) {
|
|
816
887
|
const recordingStore = new RecordingShellStore(reqCtx._cacheStore);
|
|
817
888
|
derivedCtx._cacheStore = recordingStore;
|
|
@@ -820,6 +891,17 @@ async function attemptCapture(
|
|
|
820
891
|
recordingStore.trackWrite(p);
|
|
821
892
|
reqCtx.waitUntil(() => p);
|
|
822
893
|
};
|
|
894
|
+
// Shell fast path (capture side): the implicit doc-cache scope makes the
|
|
895
|
+
// capture's match write ALL matched non-loader segments as one doc-keyed
|
|
896
|
+
// segment record — into the snapshot only (SnapshotOnlySegmentStore), so
|
|
897
|
+
// the record dies with the shell entry and the next capture's lookup
|
|
898
|
+
// still misses (handlers re-run on recapture). Routes deriving their own
|
|
899
|
+
// cache scope are untouched (resolveShellImplicitCacheScope).
|
|
900
|
+
derivedCtx._shellImplicitCache = {
|
|
901
|
+
ttl: descriptor.ttl,
|
|
902
|
+
swr: descriptor.swr,
|
|
903
|
+
store: new SnapshotOnlySegmentStore(recordingStore),
|
|
904
|
+
};
|
|
823
905
|
}
|
|
824
906
|
|
|
825
907
|
return runWithRequestContext(derivedCtx, async () => {
|
|
@@ -1142,6 +1224,14 @@ async function captureAndStoreShell(
|
|
|
1142
1224
|
// ShellCacheEntry.initialTheme.
|
|
1143
1225
|
initialTheme: reqCtx.theme,
|
|
1144
1226
|
snapshot,
|
|
1227
|
+
// Handler-layer liveness folded at the barrier: nested thenables in
|
|
1228
|
+
// handler-scoped pushes, handler pushes still pending (liveness
|
|
1229
|
+
// unknowable), or a handler-invoked loader execution — any of them
|
|
1230
|
+
// refuses the FAST PATH, not the capture. See
|
|
1231
|
+
// _shellCaptureHandleLiveness.
|
|
1232
|
+
handlerLiveHoles: handlerLayerIsLive(
|
|
1233
|
+
reqCtx._shellCaptureHandleLiveness,
|
|
1234
|
+
),
|
|
1145
1235
|
createdAt: Date.now(),
|
|
1146
1236
|
};
|
|
1147
1237
|
await store.putShell(
|
package/src/segment-system.tsx
CHANGED
|
@@ -18,14 +18,20 @@ import {
|
|
|
18
18
|
} from "./segment-loader-promise.js";
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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.
|
|
25
30
|
*/
|
|
26
31
|
function segDebugLog(msg: string, details?: Record<string, unknown>): void {
|
|
27
|
-
if (!
|
|
28
|
-
const
|
|
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`;
|
|
29
35
|
if (details) {
|
|
30
36
|
console.log(prefix, details);
|
|
31
37
|
return;
|
|
@@ -228,7 +234,7 @@ export async function renderSegments(
|
|
|
228
234
|
rootLayout: RootLayout,
|
|
229
235
|
} = options || {};
|
|
230
236
|
|
|
231
|
-
const segDebug = INTERNAL_RANGO_DEBUG
|
|
237
|
+
const segDebug = INTERNAL_RANGO_DEBUG;
|
|
232
238
|
const segDebugStart = segDebug ? performance.now() : 0;
|
|
233
239
|
if (segDebug) {
|
|
234
240
|
segDebugLog("renderSegments start", {
|
|
@@ -299,15 +305,7 @@ export async function renderSegments(
|
|
|
299
305
|
`Expected layout, route, error, or notFound segment, got ${node.segment.type}`,
|
|
300
306
|
);
|
|
301
307
|
const { component, id, params, loading } = node.segment;
|
|
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
|
-
}
|
|
308
|
+
const segNodeStart = segDebug ? performance.now() : 0;
|
|
311
309
|
|
|
312
310
|
// Param-agnostic keys are opt-in via the transition() DSL (see
|
|
313
311
|
// inTransitionScope above). A route (and its route-owned layouts) inside a
|
|
@@ -350,7 +348,13 @@ export async function renderSegments(
|
|
|
350
348
|
|
|
351
349
|
let resolvedComponent = component;
|
|
352
350
|
if (isAction && component instanceof Promise) {
|
|
351
|
+
const componentAwaitStart = segDebug ? performance.now() : 0;
|
|
353
352
|
resolvedComponent = await component;
|
|
353
|
+
if (segDebug) {
|
|
354
|
+
segDebugLog(`segment ${id}: component awaited (action)`, {
|
|
355
|
+
ms: Math.round(performance.now() - componentAwaitStart),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
354
358
|
}
|
|
355
359
|
|
|
356
360
|
let nodeContent: ReactNode = null;
|
|
@@ -367,9 +371,16 @@ export async function renderSegments(
|
|
|
367
371
|
// suspends on mount inside the content still reveals a fallback (it is not
|
|
368
372
|
// pre-resolved).
|
|
369
373
|
const contentPromise = getMemoizedContentPromise(resolvedComponent);
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
:
|
|
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
|
+
}
|
|
373
384
|
nodeContent = createElement(RouteContentWrapper, {
|
|
374
385
|
key: `suspense-loading-${id}`,
|
|
375
386
|
content: loadingContent,
|
|
@@ -492,10 +503,19 @@ export async function renderSegments(
|
|
|
492
503
|
ms: Math.round(performance.now() - layoutAwaitStart),
|
|
493
504
|
});
|
|
494
505
|
}
|
|
506
|
+
const decodeStart = segDebug ? performance.now() : 0;
|
|
495
507
|
const { loaderData, errorFallback } = decodeLoaderResults(
|
|
496
508
|
resolvedData,
|
|
497
509
|
layoutLoaderIds,
|
|
498
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
|
+
}
|
|
499
519
|
|
|
500
520
|
if (parallelOwnedLoaders.length > 0) {
|
|
501
521
|
const loadersByParallelNamespace = new Map<string, ResolvedSegment[]>();
|
|
@@ -568,6 +588,16 @@ export async function renderSegments(
|
|
|
568
588
|
children: content,
|
|
569
589
|
});
|
|
570
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
|
+
}
|
|
571
601
|
}
|
|
572
602
|
|
|
573
603
|
const errorBoundaryWrapped = createElement(RootErrorBoundary, {
|
|
@@ -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,7 +1,11 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { createSsrRootComponent } from "./ssr-root.js";
|
|
3
3
|
import { injectRSCPayloadEager } from "./inject-rsc-eager.js";
|
|
4
|
+
import { runWithPreinitNonce } from "./preinit-client-references.js";
|
|
4
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";
|
|
5
9
|
|
|
6
10
|
/**
|
|
7
11
|
* Options for injectRSCPayload
|
|
@@ -18,6 +22,7 @@ export interface InjectRSCPayloadOptions {
|
|
|
18
22
|
*/
|
|
19
23
|
interface RenderToReadableStreamOptions {
|
|
20
24
|
bootstrapScriptContent?: string;
|
|
25
|
+
bootstrapModules?: string[];
|
|
21
26
|
nonce?: string;
|
|
22
27
|
formState?: unknown;
|
|
23
28
|
}
|
|
@@ -35,6 +40,7 @@ interface ReactDOMReadableStream extends ReadableStream<Uint8Array> {
|
|
|
35
40
|
interface PrerenderOptions {
|
|
36
41
|
signal?: AbortSignal;
|
|
37
42
|
bootstrapScriptContent?: string;
|
|
43
|
+
bootstrapModules?: string[];
|
|
38
44
|
onError?: (error: unknown) => void;
|
|
39
45
|
}
|
|
40
46
|
|
|
@@ -133,6 +139,17 @@ export interface SSRDependencies<TEnv = unknown> {
|
|
|
133
139
|
*/
|
|
134
140
|
loadBootstrapScriptContent: () => Promise<string>;
|
|
135
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
|
+
|
|
136
153
|
/**
|
|
137
154
|
* prerender from react-dom/static.edge. Optional; required only by
|
|
138
155
|
* {@link createShellCaptureHandler} for PPR shell capture.
|
|
@@ -326,6 +343,47 @@ interface ShellResumeOptions {
|
|
|
326
343
|
nonce?: string;
|
|
327
344
|
}
|
|
328
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
|
+
|
|
329
387
|
/**
|
|
330
388
|
* Create an SSR handler that converts RSC streams to HTML.
|
|
331
389
|
*
|
|
@@ -383,12 +441,16 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
|
|
|
383
441
|
|
|
384
442
|
// Render React tree to HTML stream
|
|
385
443
|
// Pass formState for useActionState progressive enhancement if provided
|
|
386
|
-
// Pass nonce for CSP if provided
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
+
);
|
|
392
454
|
|
|
393
455
|
// Wait for all Suspense boundaries to resolve when streamMode is "allReady".
|
|
394
456
|
// This buffers the entire HTML before flushing — used for bots that
|
|
@@ -490,7 +552,7 @@ export function createShellCaptureHandler<TEnv = unknown>(
|
|
|
490
552
|
const abortReason = { rangoShellCaptureAbort: true };
|
|
491
553
|
const prerenderPromise = prerender(<SsrRoot />, {
|
|
492
554
|
signal: controller.signal,
|
|
493
|
-
bootstrapScriptContent,
|
|
555
|
+
...resolveBootstrapOptions(bootstrapScriptContent, deps.headScripts),
|
|
494
556
|
// Abort is how capture WORKS: once the shell is quiet we abort() to
|
|
495
557
|
// freeze the prelude and let the still-pending holes postpone. React
|
|
496
558
|
// reports the abort reason for each pending boundary through onError.
|
|
@@ -637,11 +699,6 @@ export function createShellResumeHandler<TEnv = unknown>(
|
|
|
637
699
|
nonce,
|
|
638
700
|
});
|
|
639
701
|
|
|
640
|
-
const resumed = await resume(<SsrRoot />, JSON.parse(postponed), {
|
|
641
|
-
onError: (error) => reportRenderError(onError, error),
|
|
642
|
-
nonce,
|
|
643
|
-
});
|
|
644
|
-
|
|
645
702
|
// EAGER injection (resume-only): the stored prelude — a complete document
|
|
646
703
|
// through </body></html> — is already on the wire ahead of this stream,
|
|
647
704
|
// so a Flight <script> is valid as the first tail byte. The stock
|
|
@@ -649,7 +706,41 @@ export function createShellResumeHandler<TEnv = unknown>(
|
|
|
649
706
|
// first hole's loaders resolve — parking the whole hydration payload
|
|
650
707
|
// (root row included) behind the slowest live loader. See
|
|
651
708
|
// inject-rsc-eager.ts for the measured failure mode.
|
|
652
|
-
|
|
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;
|
|
653
744
|
} catch (error) {
|
|
654
745
|
reportRenderError(onError, error);
|
|
655
746
|
throw error;
|
|
@@ -81,7 +81,7 @@ export function injectRSCPayloadEager(
|
|
|
81
81
|
if (INTERNAL_RANGO_DEBUG && !loggedFirstHtml && buffered.length > 0) {
|
|
82
82
|
loggedFirstHtml = true;
|
|
83
83
|
console.log(
|
|
84
|
-
`[Server][ppr] eager-inject: first resumed HTML batch +${Math.round(performance.now() - t0)}ms`,
|
|
84
|
+
`[Server][ppr] eager-inject: first resumed HTML batch +${Math.round(performance.now() - t0)}ms (abs ${Math.round(performance.now())})`,
|
|
85
85
|
);
|
|
86
86
|
}
|
|
87
87
|
for (const chunk of buffered) {
|
|
@@ -123,7 +123,7 @@ export function injectRSCPayloadEager(
|
|
|
123
123
|
if (INTERNAL_RANGO_DEBUG && !loggedFirstFlight) {
|
|
124
124
|
loggedFirstFlight = true;
|
|
125
125
|
console.log(
|
|
126
|
-
`[Server][ppr] eager-inject: first flight script +${Math.round(performance.now() - t0)}ms`,
|
|
126
|
+
`[Server][ppr] eager-inject: first flight script +${Math.round(performance.now() - t0)}ms (abs ${Math.round(performance.now())})`,
|
|
127
127
|
);
|
|
128
128
|
}
|
|
129
129
|
writeScript(controller, jsExpr, nonce);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { preinitModule } from "react-dom";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* JS/CSS asset deps plugin-rsc resolves for a client reference. Structural
|
|
6
|
+
* mirror of @vitejs/plugin-rsc's ResolvedAssetDeps — deliberately not imported:
|
|
7
|
+
* @rangojs/router/ssr never imports plugin-rsc directly; every plugin binding
|
|
8
|
+
* is injected by the virtual SSR entry (see SSRDependencies).
|
|
9
|
+
*/
|
|
10
|
+
export interface ClientReferenceDeps {
|
|
11
|
+
js: string[];
|
|
12
|
+
css: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Callback shape of @vitejs/plugin-rsc/ssr's setOnClientReference. Fired
|
|
17
|
+
* (synchronously, inside the Fizz render) whenever a client reference module
|
|
18
|
+
* is accessed during SSR — the same moment plugin-rsc issues its own
|
|
19
|
+
* ReactDOM.preloadModule calls for the reference's chunks.
|
|
20
|
+
*/
|
|
21
|
+
export type OnClientReference = (reference: {
|
|
22
|
+
id: string;
|
|
23
|
+
deps: ClientReferenceDeps;
|
|
24
|
+
}) => void;
|
|
25
|
+
|
|
26
|
+
/** setOnClientReference from @vitejs/plugin-rsc/ssr (injected). */
|
|
27
|
+
export type SetOnClientReference = (
|
|
28
|
+
callback: OnClientReference | undefined,
|
|
29
|
+
) => void;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Per-request CSP nonce channel for the preinit hook. The hook is installed
|
|
33
|
+
* once per isolate while the nonce is per request, and Fizz interleaves
|
|
34
|
+
* concurrent renders at task granularity — a module-scoped variable would race
|
|
35
|
+
* across requests and stamp request A's scripts with request B's nonce. ALS is
|
|
36
|
+
* the only channel that survives from the render call into the client-reference
|
|
37
|
+
* proxy access. A lost context degrades to nonce-less preinit (CSP blocks the
|
|
38
|
+
* head script; hydration still works through the nonce'd bootstrap), never to a
|
|
39
|
+
* wrong nonce.
|
|
40
|
+
*/
|
|
41
|
+
const preinitNonceStorage = new AsyncLocalStorage<string | undefined>();
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Run a Fizz render (renderToReadableStream / prerender / resume) with the
|
|
45
|
+
* request's nonce visible to the client-reference preinit hook. Nonce-less
|
|
46
|
+
* requests (the common non-CSP configuration) skip the ALS frame entirely —
|
|
47
|
+
* getStore() on an unentered storage already returns undefined, so the hook
|
|
48
|
+
* reads the same value either way.
|
|
49
|
+
*/
|
|
50
|
+
export function runWithPreinitNonce<T>(
|
|
51
|
+
nonce: string | undefined,
|
|
52
|
+
fn: () => T,
|
|
53
|
+
): T {
|
|
54
|
+
return nonce === undefined ? fn() : preinitNonceStorage.run(nonce, fn);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Upgrade plugin-rsc's client-reference modulepreload hints to executing
|
|
59
|
+
* scripts: for every JS chunk a client reference needs, emit
|
|
60
|
+
* `<script type="module" src async>` hoisted into the document head instead of
|
|
61
|
+
* only `<link rel="modulepreload">`.
|
|
62
|
+
*
|
|
63
|
+
* Why: modulepreload fetches + compiles but never executes; the chunks then
|
|
64
|
+
* execute only when the entry's hydration import walks the graph — after the
|
|
65
|
+
* whole document has streamed. preinitModule starts execution as soon as each
|
|
66
|
+
* chunk arrives, overlapping it with body streaming (the pattern Next.js uses
|
|
67
|
+
* via ReactDOM.preinit for all non-bootstrap chunks). Under PPR the preinits
|
|
68
|
+
* run during shell capture, so the executing tags live in the stored prelude
|
|
69
|
+
* and chunk execution starts on the first flushed bytes.
|
|
70
|
+
*
|
|
71
|
+
* No duplicate tags: plugin-rsc's preloadModule fires first in the same
|
|
72
|
+
* synchronous block; Fizz's preinitModuleScript then clears the queued preload
|
|
73
|
+
* chunks for that URL and adopts its credentials (ReactFizzConfigDOM,
|
|
74
|
+
* renderState.preloads.moduleScripts). Capture→resume double-emission is
|
|
75
|
+
* prevented the same way: the `moduleScriptResources[src] = null` markers
|
|
76
|
+
* serialize inside the postponed state, so the resume pass preinits only
|
|
77
|
+
* references the shell never saw.
|
|
78
|
+
*
|
|
79
|
+
* crossOrigin "" matches plugin-rsc's preloadModule creds so the upgrade path
|
|
80
|
+
* reuses the same resource instead of forking on credential mismatch.
|
|
81
|
+
*
|
|
82
|
+
* Known trades (deliberate, measured neutral-to-positive on the e2e apps —
|
|
83
|
+
* PR #694 has the Lighthouse/hydration numbers):
|
|
84
|
+
* - Fetch priority: an executing async module script fetches at Chromium's
|
|
85
|
+
* async-script priority, below a bare modulepreload hint; preinitModule
|
|
86
|
+
* forwards no fetchPriority (react-dom's public API drops it — only
|
|
87
|
+
* `preinit` forwards it). Execution-overlap is bought with hint priority,
|
|
88
|
+
* the same trade Next.js ships via ReactDOM.preinit.
|
|
89
|
+
* - Build only: plugin-rsc's dev load path reports `js: []` per reference, so
|
|
90
|
+
* dev documents have no head chunk scripts — a client module whose module
|
|
91
|
+
* scope assumes body-parsed DOM can break in production only. The
|
|
92
|
+
* `rango({ headScripts: "preload" })` escape hatch restores hint-only.
|
|
93
|
+
* - plugin-rsc's setOnClientReference is a single-slot, last-write-wins
|
|
94
|
+
* setter: another registrant in the SSR environment silently replaces this
|
|
95
|
+
* hook (or is replaced by it). No composition API exists upstream yet.
|
|
96
|
+
*/
|
|
97
|
+
export function installClientReferencePreinit(
|
|
98
|
+
setOnClientReference: SetOnClientReference,
|
|
99
|
+
): void {
|
|
100
|
+
setOnClientReference(({ deps }) => {
|
|
101
|
+
const nonce = preinitNonceStorage.getStore();
|
|
102
|
+
for (const href of deps.js) {
|
|
103
|
+
preinitModule(href, { as: "script", crossOrigin: "", nonce });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|