@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.
@@ -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 { maskNestedContainerThenables } from "../router/segment-resolution/mask-nested.js";
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 masked = isThenable(value)
774
- ? value.then((v: unknown) => maskNestedContainerThenables(v))
775
- : maskNestedContainerThenables(value);
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(
@@ -18,14 +18,20 @@ import {
18
18
  } from "./segment-loader-promise.js";
19
19
 
20
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.
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 (!(INTERNAL_RANGO_DEBUG && typeof window === "object")) return;
28
- const prefix = `[Browser][segments] ${msg} @ ${Math.round(performance.now())}ms`;
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 && typeof window === "object";
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
- const loadingContent: Promise<ReactNode> | ReactNode = forceAwait
371
- ? await contentPromise
372
- : contentPromise;
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
- const htmlStream = await renderToReadableStream(<SsrRoot />, {
388
- bootstrapScriptContent,
389
- formState,
390
- nonce,
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
- return resumed.pipeThrough(injectRSCPayloadEager(rscStream2, { nonce }));
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
+ }
package/src/vite/index.ts CHANGED
@@ -24,6 +24,7 @@ export type {
24
24
  RangoOptions,
25
25
  ClientChunks,
26
26
  ClientChunkMeta,
27
+ HeadScriptsOption,
27
28
  BuildEnvOption,
28
29
  BuildEnvFactory,
29
30
  BuildEnvFactoryContext,