@rangojs/router 0.9.0 → 0.10.1

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.
Files changed (68) hide show
  1. package/AGENTS.md +1 -1
  2. package/LICENSE +21 -0
  3. package/README.md +17 -15
  4. package/dist/types/cache/cf/cf-cache-constants.d.ts +1 -1
  5. package/dist/types/cache/cf/cf-cache-store.d.ts +1 -1
  6. package/dist/types/outlet-context.d.ts +9 -0
  7. package/dist/types/outlet-provider.d.ts +2 -1
  8. package/dist/types/route-content-wrapper.d.ts +4 -1
  9. package/dist/types/rsc/nonce.d.ts +10 -0
  10. package/dist/types/rsc/shell-capture-constants.d.ts +1 -1
  11. package/dist/types/rsc/types.d.ts +6 -2
  12. package/dist/types/ssr/index.d.ts +18 -0
  13. package/dist/types/ssr/ssr-root.d.ts +16 -0
  14. package/dist/types/ssr-suspension-warning.d.ts +20 -0
  15. package/dist/types/types/loader-types.d.ts +5 -1
  16. package/dist/types/types/segments.d.ts +17 -0
  17. package/dist/types/vite/discovery/state.d.ts +6 -0
  18. package/dist/types/vite/plugin-types.d.ts +27 -0
  19. package/dist/types/vite/plugins/virtual-entries.d.ts +1 -1
  20. package/dist/types/vite/utils/shared-utils.d.ts +1 -0
  21. package/dist/vite/index.js +56 -26
  22. package/package.json +26 -26
  23. package/skills/bundle-analysis/SKILL.md +1 -1
  24. package/skills/loader/SKILL.md +17 -2
  25. package/skills/testing/SKILL.md +1 -1
  26. package/skills/testing/bindings.md +1 -1
  27. package/skills/testing/cache-prerender.md +1 -1
  28. package/skills/testing/client-components.md +1 -1
  29. package/skills/testing/e2e-parity.md +1 -1
  30. package/skills/testing/flight.md +1 -1
  31. package/skills/testing/handles.md +1 -1
  32. package/skills/testing/loader.md +1 -1
  33. package/skills/testing/middleware.md +1 -1
  34. package/skills/testing/render-handler.md +1 -1
  35. package/skills/testing/response-routes.md +1 -1
  36. package/skills/testing/reverse-and-types.md +1 -1
  37. package/skills/testing/server-actions.md +1 -1
  38. package/skills/testing/server-tree.md +1 -1
  39. package/skills/testing/setup.md +1 -1
  40. package/src/browser/merge-segment-loaders.ts +4 -0
  41. package/src/cache/cache-runtime.ts +2 -2
  42. package/src/cache/cf/cf-cache-constants.ts +1 -1
  43. package/src/cache/cf/cf-cache-store.ts +1 -1
  44. package/src/client.tsx +2 -0
  45. package/src/outlet-context.ts +9 -0
  46. package/src/outlet-provider.tsx +4 -0
  47. package/src/route-content-wrapper.tsx +7 -0
  48. package/src/router/segment-resolution/fresh.ts +48 -17
  49. package/src/router.ts +7 -2
  50. package/src/rsc/handler.ts +4 -4
  51. package/src/rsc/nonce.ts +16 -0
  52. package/src/rsc/shell-capture-constants.ts +1 -1
  53. package/src/rsc/shell-capture.ts +41 -8
  54. package/src/rsc/types.ts +6 -2
  55. package/src/segment-system.tsx +57 -12
  56. package/src/ssr/index.tsx +53 -1
  57. package/src/ssr/ssr-root.tsx +50 -22
  58. package/src/ssr-suspension-warning.ts +68 -0
  59. package/src/types/loader-types.ts +5 -1
  60. package/src/types/segments.ts +17 -0
  61. package/src/use-loader.tsx +24 -1
  62. package/src/vite/discovery/shell-prerender-phase.ts +5 -0
  63. package/src/vite/discovery/state.ts +6 -0
  64. package/src/vite/plugin-types.ts +28 -0
  65. package/src/vite/plugins/virtual-entries.ts +26 -3
  66. package/src/vite/rango.ts +3 -0
  67. package/src/vite/router-discovery.ts +31 -19
  68. package/src/vite/utils/shared-utils.ts +8 -2
@@ -45,7 +45,10 @@ import {
45
45
  maskNestedContainerThenables,
46
46
  type MaskReport,
47
47
  } from "../router/segment-resolution/mask-nested.js";
48
- import { isInsideLoaderScope } from "../server/context.js";
48
+ import {
49
+ getCurrentLoaderBodyId,
50
+ isInsideLoaderScope,
51
+ } from "../server/context.js";
49
52
  import { isThenable } from "../handles/is-thenable.js";
50
53
  import type {
51
54
  ShellCacheEntry,
@@ -378,7 +381,7 @@ function warnNullCaptureOnce(key: string): void {
378
381
  "a <Suspense> above the consuming component.\n" +
379
382
  'See the /ppr skill (node_modules/@rangojs/router/skills/ppr/SKILL.md), "The hole ' +
380
383
  'doctrine" and "The layout-with-loaders playbook", or the design docs: ' +
381
- "https://github.com/ivogt/vite-rsc/blob/main/packages/rangojs-router/docs/design/ppr-shell-resume.md",
384
+ "https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/design/ppr-shell-resume.md",
382
385
  );
383
386
  }
384
387
 
@@ -1046,7 +1049,7 @@ export function scheduleShellCapture(
1046
1049
  // Hard-capped: SHELL_CAPTURE_MAX_WAIT_MS arms only inside
1047
1050
  // captureShellHTML, AFTER the capture's router.match() — a handler
1048
1051
  // wedged on a never-settling upstream await has no deadline in force
1049
- // and would strand the stampede guard and the queue slot (autobarn
1052
+ // and would strand the stampede guard and the queue slot (production
1050
1053
  // pilot). The cap rejects, riding the existing catch: backoff +
1051
1054
  // reportCacheError + token-guarded release. The abandoned attempt keeps
1052
1055
  // running until its context dies; nothing awaits it.
@@ -1503,6 +1506,15 @@ export function deriveShellCaptureContext(
1503
1506
  // applied ONLY at the captureHandles cache-write call site — every other
1504
1507
  // getDataForSegment consumer (the render-barrier snapshot, prerender)
1505
1508
  // sees every push.
1509
+ // EXCEPTION — bake-lane { ssr: false } loaders: they execute at capture
1510
+ // and never re-run on a HIT, so neither exclusion rationale applies. The
1511
+ // capture awaits them (fresh.ts), their pushes are in the captured HTML
1512
+ // (<head> title/meta, useHandle echoes), and the stored snapshot MUST
1513
+ // carry the same values or HIT hydration mismatches against its own
1514
+ // prelude (server text present, client handle empty). Only their
1515
+ // settled, thenable-free pushes qualify — a deferred (thenable) push or
1516
+ // one with masked nested promises keeps the exclusion so the handle
1517
+ // encode cannot stall on a never-resolving mask.
1506
1518
  const handleLiveness = {
1507
1519
  holes: false,
1508
1520
  pendingPushes: 0,
@@ -1518,11 +1530,13 @@ export function deriveShellCaptureContext(
1518
1530
  const pushedInLoaderScope = isInsideLoaderScope();
1519
1531
  // Single walk: the mask reports whether it masked any nested thenable
1520
1532
  // (the liveness declaration) while building the capture copy.
1533
+ let maskedNestedThenable = false;
1521
1534
  const maskWithLiveness = (v: unknown): unknown => {
1522
1535
  const report: MaskReport = { thenable: false };
1523
1536
  const masked = maskNestedContainerThenables(v, undefined, report);
1524
- if (!pushedInLoaderScope && report.thenable) {
1525
- handleLiveness.holes = true;
1537
+ if (report.thenable) {
1538
+ maskedNestedThenable = true;
1539
+ if (!pushedInLoaderScope) handleLiveness.holes = true;
1526
1540
  }
1527
1541
  return masked;
1528
1542
  };
@@ -1534,11 +1548,30 @@ export function deriveShellCaptureContext(
1534
1548
  value.then(settle, settle);
1535
1549
  }
1536
1550
  masked = value.then(maskWithLiveness);
1551
+ // Deferred (thenable) loader pushes always keep the exclusion — the
1552
+ // bake-lane carve-out below is for settled values only.
1553
+ if (pushedInLoaderScope) {
1554
+ loaderScopedPushValues.add(masked as object);
1555
+ }
1537
1556
  } else {
1538
1557
  masked = maskWithLiveness(value);
1539
- }
1540
- if (pushedInLoaderScope && typeof masked === "object" && masked !== null) {
1541
- loaderScopedPushValues.add(masked);
1558
+ if (
1559
+ pushedInLoaderScope &&
1560
+ typeof masked === "object" &&
1561
+ masked !== null
1562
+ ) {
1563
+ // Bake-lane { ssr: false } loader pushes are shell material (see the
1564
+ // funnel comment above): keep them IN the stored snapshot when
1565
+ // settled and thenable-free; everything else stays excluded.
1566
+ const bodyLoaderId = getCurrentLoaderBodyId();
1567
+ const baked =
1568
+ bodyLoaderId !== undefined &&
1569
+ !maskedNestedThenable &&
1570
+ derivedCtx._awaitBeforeFlushLoaderIds?.has(bodyLoaderId) === true;
1571
+ if (!baked) {
1572
+ loaderScopedPushValues.add(masked);
1573
+ }
1574
+ }
1542
1575
  }
1543
1576
  rawCapturePush(handleName, segmentId, masked);
1544
1577
  };
package/src/rsc/types.ts CHANGED
@@ -276,12 +276,16 @@ export interface HandlerCacheConfig {
276
276
 
277
277
  /**
278
278
  * Nonce provider function type.
279
- * Can return a nonce string, or true to auto-generate one.
279
+ * Return a nonce string to use verbatim, `true` to auto-generate one, or
280
+ * `false` (or an empty string) to serve THIS request without a nonce — the
281
+ * per-request opt-out for apps that nonce globally but declare `ppr` on some
282
+ * routes: a shell is shared per host+URL and can never bake a per-request
283
+ * nonce, so a ppr route only captures when its requests opt out here.
280
284
  */
281
285
  export type NonceProvider<TEnv = unknown> = (
282
286
  request: Request,
283
287
  env: TEnv,
284
- ) => string | true | Promise<string | true>;
288
+ ) => string | boolean | Promise<string | boolean>;
285
289
 
286
290
  /**
287
291
  * Options for creating an RSC handler
@@ -179,6 +179,30 @@ function wrapDefaultOutletContent(
179
179
  return createViewTransitionBoundary(transition, content);
180
180
  }
181
181
 
182
+ /**
183
+ * Per-loader stream map for read-site suspension. { ssr: false } loaders were
184
+ * awaited before flush (fresh.ts), so deliver the SETTLED result, not the
185
+ * settled promise: the read site decodes synchronously instead of use()ing a
186
+ * Flight chunk whose fulfilled-at-read-time status is a scheduling race the
187
+ * SSR-completeness contract must not depend on. Unflagged siblings keep the
188
+ * promise (deliberate streaming). Flagged ids are collected as input for the
189
+ * dev SSR-suspension diagnostic (ssr-suspension-warning.ts).
190
+ */
191
+ async function buildLoaderStreams(loaders: ResolvedSegment[]): Promise<{
192
+ streams: Record<string, unknown>;
193
+ awaitedIds: string[] | undefined;
194
+ }> {
195
+ const streams: Record<string, unknown> = {};
196
+ let awaitedIds: string[] | undefined;
197
+ for (const l of loaders) {
198
+ streams[l.loaderId!] = l.awaitBeforeFlush
199
+ ? await l.loaderData
200
+ : l.loaderData;
201
+ if (l.awaitBeforeFlush) (awaitedIds ??= []).push(l.loaderId!);
202
+ }
203
+ return { streams, awaitedIds };
204
+ }
205
+
182
206
  /**
183
207
  * Render segments into a React tree with proper layout nesting
184
208
  *
@@ -477,6 +501,7 @@ export async function renderSegments(
477
501
  // microtask suspension is what makes SSR emit the loading() fallback
478
502
  // for content-suspending routes (segment-loader-promise.ts).
479
503
  let boundaryLoaderStreams: Record<string, unknown> | undefined;
504
+ let boundaryAwaitedLoaderIds: string[] | undefined;
480
505
  if (forceAwait || isAction) {
481
506
  const awaitStart = segDebug ? performance.now() : 0;
482
507
  boundaryLoaderData = await loaderDataPromise;
@@ -487,10 +512,10 @@ export async function renderSegments(
487
512
  });
488
513
  }
489
514
  } else if (loaderEntries.length > 0) {
490
- boundaryLoaderStreams = {};
491
- for (const l of loaderEntries) {
492
- boundaryLoaderStreams[l.loaderId!] = l.loaderData;
493
- }
515
+ ({
516
+ streams: boundaryLoaderStreams,
517
+ awaitedIds: boundaryAwaitedLoaderIds,
518
+ } = await buildLoaderStreams(loaderEntries));
494
519
  if (segDebug) {
495
520
  segDebugLog(
496
521
  `segment ${id}: per-loader streams via LoaderBoundary (read-site suspense)`,
@@ -503,6 +528,7 @@ export async function renderSegments(
503
528
  loaderDataPromise: boundaryLoaderData,
504
529
  loaderIds,
505
530
  loaderStreams: boundaryLoaderStreams,
531
+ awaitedLoaderIds: boundaryAwaitedLoaderIds,
506
532
  fallback: loading,
507
533
  outletKey: key,
508
534
  outletContent,
@@ -537,6 +563,7 @@ export async function renderSegments(
537
563
  let loaderData: Record<string, any> = {};
538
564
  let errorFallback: ReactNode = null;
539
565
  let loaderStreams: Record<string, unknown> | undefined;
566
+ let awaitedLoaderIds: string[] | undefined;
540
567
  if (forceAwait || isAction) {
541
568
  const layoutAwaitStart = segDebug ? performance.now() : 0;
542
569
  const resolvedData = await buildLoaderPromise(layoutLoaders);
@@ -560,10 +587,8 @@ export async function renderSegments(
560
587
  }
561
588
  }
562
589
  } else if (layoutLoaders.length > 0) {
563
- loaderStreams = {};
564
- for (const l of layoutLoaders) {
565
- loaderStreams[l.loaderId!] = l.loaderData;
566
- }
590
+ ({ streams: loaderStreams, awaitedIds: awaitedLoaderIds } =
591
+ await buildLoaderStreams(layoutLoaders));
567
592
  if (segDebug) {
568
593
  segDebugLog(
569
594
  `segment ${id}: layout loaders streaming to read sites (no loading())`,
@@ -599,12 +624,29 @@ export async function renderSegments(
599
624
 
600
625
  p.loaderIds = ownedLoaders.map((l) => l.loaderId!);
601
626
  const aggregated = getMemoizedLoaderPromise(ownedLoaders);
602
- if ((forceAwait || isAction) && aggregated instanceof Promise) {
627
+ // Parallel slots must NOT take the loaderStreams path.
628
+ // LoaderResolver skips use(aggregate) when streams are set, so
629
+ // the already-rendered slot handler commits immediately. That
630
+ // bakes a live-lane hole into the shell (semantic-matrix PPR3 /
631
+ // shell-cache slot-use: masked loaderData is what pins
632
+ // "srv badge pending...") and, on reuse, leaves a stale stream
633
+ // map that ignores the post-action aggregate (mini @cart).
634
+ // Flagged (ssr:false) delivery still holds: when every owned
635
+ // loader paid the pre-flush await, settle the aggregate so
636
+ // LoaderResolver decodes the array without a Flight-chunk use().
637
+ const settleParallelAggregate =
638
+ forceAwait ||
639
+ isAction ||
640
+ ownedLoaders.every((l) => l.awaitBeforeFlush === true);
641
+ if (settleParallelAggregate) {
603
642
  const parallelAwaitStart = segDebug ? performance.now() : 0;
604
- p.loaderDataPromise = await aggregated;
643
+ p.loaderDataPromise =
644
+ aggregated instanceof Promise ? await aggregated : aggregated;
645
+ p.loaderStreams = undefined;
646
+ p.awaitedLoaderIds = undefined;
605
647
  if (segDebug) {
606
648
  segDebugLog(
607
- `segment ${id}: parallel ${p.id} loaders awaited (forceAwait/action)`,
649
+ `segment ${id}: parallel ${p.id} loaders awaited (forceAwait/action/ssr:false)`,
608
650
  {
609
651
  loaderIds: p.loaderIds,
610
652
  ms: Math.round(performance.now() - parallelAwaitStart),
@@ -613,9 +655,11 @@ export async function renderSegments(
613
655
  }
614
656
  } else {
615
657
  p.loaderDataPromise = aggregated;
658
+ p.loaderStreams = undefined;
659
+ p.awaitedLoaderIds = undefined;
616
660
  if (segDebug) {
617
661
  segDebugLog(
618
- `segment ${id}: parallel ${p.id} loaders streaming (suspense)`,
662
+ `segment ${id}: parallel ${p.id} loaders via aggregate (suspense)`,
619
663
  { loaderIds: p.loaderIds },
620
664
  );
621
665
  }
@@ -630,6 +674,7 @@ export async function renderSegments(
630
674
  parallel: node.parallel,
631
675
  loaderData: Object.keys(loaderData).length > 0 ? loaderData : undefined,
632
676
  loaderStreams,
677
+ awaitedLoaderIds,
633
678
  pending: outletPending,
634
679
  children: errorFallback ?? nodeContent,
635
680
  });
package/src/ssr/index.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { createSsrRootComponent } from "./ssr-root.js";
2
+ import { createSsrRootComponent, deserializeSsrPayload } from "./ssr-root.js";
3
3
  import { injectRSCPayloadEager } from "./inject-rsc-eager.js";
4
4
  import { runWithPreinitNonce } from "./preinit-client-references.js";
5
5
  import { SHELL_CAPTURE_MAX_WAIT_MS } from "../rsc/shell-capture-constants.js";
@@ -26,6 +26,7 @@ interface RenderToReadableStreamOptions {
26
26
  bootstrapModules?: string[];
27
27
  nonce?: string;
28
28
  formState?: unknown;
29
+ progressiveChunkSize?: number;
29
30
  }
30
31
 
31
32
  /**
@@ -43,6 +44,7 @@ interface PrerenderOptions {
43
44
  bootstrapScriptContent?: string;
44
45
  bootstrapModules?: string[];
45
46
  onError?: (error: unknown) => void;
47
+ progressiveChunkSize?: number;
46
48
  }
47
49
 
48
50
  /**
@@ -168,6 +170,23 @@ export interface SSRDependencies<TEnv = unknown> {
168
170
  */
169
171
  headScripts?: HeadScriptsOption;
170
172
 
173
+ /**
174
+ * Fizz `progressiveChunkSize`, forwarded verbatim to renderToReadableStream
175
+ * (live SSR) and prerender (shell capture); resume() inherits the capture
176
+ * value from the postponed state. The generated virtual SSR entry threads
177
+ * the `rango({ progressiveChunkSize })` plugin option here (canonical docs
178
+ * on `RangoBaseOptions.progressiveChunkSize` in vite/plugin-types.ts).
179
+ *
180
+ * When UNSET, createSSRHandler auto-raises to Number.MAX_SAFE_INTEGER for
181
+ * document renders whose matched chain has a `loader(Def, { ssr: false })`
182
+ * entry (payload segments carry `awaitBeforeFlush`): the loader was awaited
183
+ * before first flush precisely so its content ships in-place, and React
184
+ * 19.2's Fizz would otherwise move any completed boundary over ~500 bytes
185
+ * to an end-of-stream `<div hidden>` + $RC reveal once the shell saturates
186
+ * the default 12800 budget. An explicit value disables the auto-raise.
187
+ */
188
+ progressiveChunkSize?: number;
189
+
171
190
  /**
172
191
  * prerender from react-dom/static.edge. Optional; required only by
173
192
  * {@link createShellCaptureHandler} for PPR shell capture.
@@ -497,9 +516,21 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
497
516
  // - rscStream2: For browser hydration (inject as __FLIGHT_DATA__)
498
517
  const [rscStream1, rscStream2] = rscStream.tee();
499
518
 
519
+ // Deserialization starts here (not lazily on SsrRoot's first render) so
520
+ // the auto-raise below can read payload metadata before the fizz call.
521
+ // The parallel no-op catch keeps a payload rejection off the
522
+ // unhandledRejection path if fizz errors before consuming the promise;
523
+ // SsrRoot's React.use() on the SAME promise still observes the rejection.
524
+ const payload = deserializeSsrPayload(
525
+ createFromReadableStream,
526
+ rscStream1,
527
+ );
528
+ payload.catch(() => {});
529
+
500
530
  const SsrRoot = createSsrRootComponent({
501
531
  createFromReadableStream,
502
532
  rscStream: rscStream1,
533
+ payload,
503
534
  nonce,
504
535
  // Live fizz seeds the request's RAW search. The shell capture pass
505
536
  // below and its resume twin seed the SHELL KEY's search instead
@@ -513,6 +544,18 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
513
544
  // Get bootstrap script content
514
545
  const bootstrapScriptContent = await loadBootstrapScriptContent();
515
546
 
547
+ // ssr:false auto-raise (see SSRDependencies.progressiveChunkSize).
548
+ // Awaiting the payload here is latency-neutral: fizz cannot emit even
549
+ // <html> until this same promise settles. A rejection lands in the
550
+ // catch below — the same reportRenderError path a shell error takes.
551
+ let progressiveChunkSize = deps.progressiveChunkSize;
552
+ if (progressiveChunkSize === undefined) {
553
+ const resolved = await payload;
554
+ if (resolved.metadata?.segments?.some((s) => s.awaitBeforeFlush)) {
555
+ progressiveChunkSize = Number.MAX_SAFE_INTEGER;
556
+ }
557
+ }
558
+
516
559
  // Render React tree to HTML stream
517
560
  // Pass formState for useActionState progressive enhancement if provided
518
561
  // Pass nonce for CSP if provided. runWithPreinitNonce makes the same
@@ -523,6 +566,7 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
523
566
  ...resolveBootstrapOptions(bootstrapScriptContent, deps.headScripts),
524
567
  formState,
525
568
  nonce,
569
+ ...(progressiveChunkSize !== undefined && { progressiveChunkSize }),
526
570
  }),
527
571
  );
528
572
 
@@ -646,6 +690,14 @@ export function createShellCaptureHandler<TEnv = unknown>(
646
690
  const prerenderPromise = prerender(<SsrRoot />, {
647
691
  signal: controller.signal,
648
692
  ...resolveBootstrapOptions(bootstrapScriptContent, deps.headScripts),
693
+ // Explicit option only — the ssr:false auto-raise is live-SSR scoped
694
+ // (RangoBaseOptions.progressiveChunkSize documents the contract); the
695
+ // capture handler starts prerender without deserializing the payload,
696
+ // so segment metadata is not read here. The captured value rides the
697
+ // postponed state into every later resume().
698
+ ...(deps.progressiveChunkSize !== undefined && {
699
+ progressiveChunkSize: deps.progressiveChunkSize,
700
+ }),
649
701
  // Abort is how capture WORKS: once the shell is quiet we abort() to
650
702
  // freeze the prelude and let the still-pending holes postpone. React
651
703
  // reports the abort reason for each pending boundary through onError.
@@ -139,6 +139,37 @@ function createSsrEventController(opts: {
139
139
  };
140
140
  }
141
141
 
142
+ /**
143
+ * Deserialize the Flight stream branch into the payload SsrRoot renders,
144
+ * fragment expansion included. Factored out of SsrRoot so createSSRHandler can
145
+ * create the promise BEFORE fizz starts and read metadata that must influence
146
+ * the fizz call itself (the ssr:false progressiveChunkSize auto-raise) —
147
+ * awaiting it there is latency-neutral because fizz cannot emit even <html>
148
+ * until this same promise settles.
149
+ */
150
+ export function deserializeSsrPayload(
151
+ createFromReadableStream: CreateFromReadableStream,
152
+ rscStream: ReadableStream<Uint8Array>,
153
+ ): Promise<RscPayload> {
154
+ // Shell-HIT tails carry replayed segments as VERBATIM stored fragments
155
+ // (segment-fragments.ts, issue #700); expand them through this
156
+ // environment's deserializer before anything reads the segments. Every
157
+ // other payload (full render, capture, actions) has no envelopes and
158
+ // pays one field scan.
159
+ // Promise.resolve() adoption is load-bearing: some wirings (the build
160
+ // temp server's vendored Flight client) return a THENABLE Chunk whose
161
+ // .then returns undefined — chaining on it directly yields undefined.
162
+ return Promise.resolve(createFromReadableStream<RscPayload>(rscStream)).then(
163
+ async (resolvedPayload) => {
164
+ await expandSegmentFragments(
165
+ resolvedPayload.metadata?.segments,
166
+ createFromReadableStream,
167
+ );
168
+ return resolvedPayload;
169
+ },
170
+ );
171
+ }
172
+
142
173
  /**
143
174
  * Options for {@link createSsrRootComponent}.
144
175
  */
@@ -147,6 +178,13 @@ export interface SsrRootOptions {
147
178
  createFromReadableStream: CreateFromReadableStream;
148
179
  /** The Flight stream branch to deserialize into the SSR VDOM. */
149
180
  rscStream: ReadableStream<Uint8Array>;
181
+ /**
182
+ * Pre-created payload promise (deserializeSsrPayload over the SAME
183
+ * rscStream). When present SsrRoot adopts it instead of deserializing
184
+ * lazily on first render — the stream can only be consumed once, so the
185
+ * caller that already started deserialization MUST pass the promise in.
186
+ */
187
+ payload?: Promise<RscPayload>;
150
188
  /** Nonce for CSP; propagated to NonceContext. */
151
189
  nonce?: string;
152
190
  /**
@@ -205,33 +243,23 @@ export function createSsrRootComponent(opts: SsrRootOptions): React.FC {
205
243
  origin,
206
244
  } = opts;
207
245
 
208
- let payload: Promise<RscPayload> | undefined;
246
+ // onPayloadSettled fires AFTER fragment expansion (inside
247
+ // deserializeSsrPayload): the capture's fizz-readiness gate must include
248
+ // fragment module loads.
249
+ const adoptPayload = (p: Promise<RscPayload>): Promise<RscPayload> => {
250
+ if (onPayloadSettled) p.then(onPayloadSettled, onPayloadSettled);
251
+ return p;
252
+ };
253
+ let payload: Promise<RscPayload> | undefined =
254
+ opts.payload && adoptPayload(opts.payload);
209
255
  let handlesPromise: Promise<HandleData> | undefined;
210
256
  let ssrContextValue: NavigationStoreContextValue | undefined;
211
257
  let rootPromise: Promise<React.ReactNode> | undefined;
212
258
 
213
259
  return function SsrRoot() {
214
- if (payload === undefined) {
215
- // Shell-HIT tails carry replayed segments as VERBATIM stored fragments
216
- // (segment-fragments.ts, issue #700); expand them through this
217
- // environment's deserializer before anything reads the segments. Every
218
- // other payload (full render, capture, actions) has no envelopes and
219
- // pays one field scan. onPayloadSettled fires AFTER expansion: the
220
- // capture's fizz-readiness gate must include fragment module loads.
221
- // Promise.resolve() adoption is load-bearing: some wirings (the build
222
- // temp server's vendored Flight client) return a THENABLE Chunk whose
223
- // .then returns undefined — chaining on it directly yields undefined.
224
- payload = Promise.resolve(
225
- createFromReadableStream<RscPayload>(rscStream),
226
- ).then(async (resolvedPayload) => {
227
- await expandSegmentFragments(
228
- resolvedPayload.metadata?.segments,
229
- createFromReadableStream,
230
- );
231
- return resolvedPayload;
232
- });
233
- if (onPayloadSettled) payload.then(onPayloadSettled, onPayloadSettled);
234
- }
260
+ payload ??= adoptPayload(
261
+ deserializeSsrPayload(createFromReadableStream, rscStream),
262
+ );
235
263
  const resolved = React.use(payload);
236
264
 
237
265
  const themeConfig = resolved.metadata?.themeConfig ?? null;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Dev-only diagnostic for the { ssr: false } SSR-completeness opt-in.
3
+ *
4
+ * A document render that awaits flagged loaders before first flush is usually
5
+ * expected to produce an SSR-complete boundary — but the await is scoped per
6
+ * LOADER (see LoaderOptions in types/loader-types.ts), so one unflagged read
7
+ * under the same Suspense boundary still suspends it and the boundary streams
8
+ * as its fallback, invisible without JS. That failure mode looks exactly like
9
+ * the flag not working; a consumer app burned hours on byte-level archaeology
10
+ * before the unflagged sibling read was identified. This warning names the
11
+ * culprit at the suspension site instead.
12
+ *
13
+ * Fires at most once per loader id per process, only during the SSR pass
14
+ * (`window` undefined), only in dev, and only when the render actually awaited
15
+ * flagged loaders (`awaitedLoaderIds`, stamped by segment resolution on
16
+ * document AND shell-capture renders — capture awaits the same flagged lane;
17
+ * its masked live-lane reads suspend by design and dedupe against the live
18
+ * render's warnings through the once-per-id set).
19
+ */
20
+
21
+ const warned = new Set<string>();
22
+
23
+ /** Thenable statuses `use()` unwraps without suspending: React's re-check
24
+ * after `.then()` handles "fulfilled", and Flight chunks in the resolved_*
25
+ * states initialize synchronously inside that same call. Anything else
26
+ * (pending, blocked, an unstamped native promise) suspends at least once. */
27
+ function unwrapsSynchronously(stream: Promise<unknown>): boolean {
28
+ const status = (stream as { status?: string }).status;
29
+ return (
30
+ status === "fulfilled" ||
31
+ status === "resolved_model" ||
32
+ status === "resolved_module"
33
+ );
34
+ }
35
+
36
+ export function warnAwaitedSsrSuspension(
37
+ loaderId: string,
38
+ awaitedLoaderIds: readonly string[] | undefined,
39
+ stream: Promise<unknown>,
40
+ isServer: boolean = typeof window === "undefined",
41
+ ): void {
42
+ if (process.env.NODE_ENV === "production") return;
43
+ if (!isServer) return;
44
+ if (!awaitedLoaderIds || awaitedLoaderIds.length === 0) return;
45
+ if (unwrapsSynchronously(stream)) return;
46
+ if (warned.has(loaderId)) return;
47
+ warned.add(loaderId);
48
+
49
+ if (awaitedLoaderIds.includes(loaderId)) {
50
+ console.error(
51
+ `[rango] Loader "${loaderId}" is flagged { ssr: false } and was awaited ` +
52
+ `before first flush, but its useLoader read observed a still-pending ` +
53
+ `promise during SSR. If its Suspense fallback appears in the SSR'd HTML, ` +
54
+ `please report it to @rangojs/router with a reproduction.`,
55
+ );
56
+ return;
57
+ }
58
+
59
+ console.warn(
60
+ `[rango] The document render awaited { ssr: false } loader(s) ` +
61
+ `${JSON.stringify(awaitedLoaderIds)} before first flush, but ` +
62
+ `useLoader("${loaderId}") still suspended during SSR. "${loaderId}" is not ` +
63
+ `flagged, so the Suspense boundary above that read streams as its fallback ` +
64
+ `and the content under it stays hidden until JS runs. If the boundary must ` +
65
+ `be SSR-complete, flag "${loaderId}" with { ssr: false } too, or move its ` +
66
+ `read behind its own boundary. If it streams deliberately, ignore this.`,
67
+ );
68
+ }
@@ -195,7 +195,11 @@ export type LoaderFn<
195
195
  *
196
196
  * Scoped per LOADER, not per segment: an `ssr: false` loader alongside a
197
197
  * deliberately streaming sibling awaits only itself, and the sibling keeps
198
- * streaming behind its `loading()`/Suspense boundary.
198
+ * streaming behind its `loading()`/Suspense boundary. Watch the scoping when
199
+ * the boundary must be SSR-complete: ONE unflagged read under it still
200
+ * suspends the whole boundary, which then streams as its fallback — looking
201
+ * exactly like the flag not working. In dev the router warns at the read
202
+ * site naming the unflagged loader (ssr-suspension-warning.ts).
199
203
  */
200
204
  export type LoaderOptions = {
201
205
  ssr?: boolean;
@@ -166,10 +166,27 @@ export interface ResolvedSegment {
166
166
  loaderId?: string; // For loaders: the loader $$id identifier
167
167
  _inherited?: boolean; // For inherited loaders: dedup marker for buildMatchResult
168
168
  loaderData?: any; // For loaders: the resolved data from loader execution
169
+ /**
170
+ * True when this loader was awaited before first flush
171
+ * (loader(Def, { ssr: false })). Stamped by resolveLoaders (fresh.ts) on
172
+ * document AND shell-capture renders — capture bakes flagged loaders and
173
+ * awaits the same lane. Feeds segment-system's settled-value delivery and
174
+ * the dev SSR suspension warning (ssr-suspension-warning.ts).
175
+ */
176
+ awaitBeforeFlush?: true;
169
177
  parallelLoading?: ReactNode; // For parallel-owned loaders: the parallel's loading fallback
170
178
  // Intercept loader fields (for streaming loader data in parallel segments)
171
179
  loaderDataPromise?: Promise<any[]> | any[]; // Loader data promise or resolved array
172
180
  loaderIds?: string[]; // IDs ($$id) of loaders for this segment
181
+ /**
182
+ * Per-loader UNDECODED results for a layout/route stream map.
183
+ * Flagged (ssr:false) entries are settled values; unflagged siblings
184
+ * stay promises. Parallel slots do not use this channel — they pin
185
+ * loading() / live-lane holes via use(loaderDataPromise).
186
+ */
187
+ loaderStreams?: Record<string, unknown>;
188
+ /** $$ids of loaders this segment awaited before flush. Dev diagnostic. */
189
+ awaitedLoaderIds?: string[];
173
190
  // Error-specific fields
174
191
  error?: ErrorInfo; // For error segments: the error information
175
192
  // NotFound-specific fields
@@ -15,6 +15,7 @@ import {
15
15
  import { OutletContext, type OutletContextValue } from "./outlet-context.js";
16
16
  import { loaderStore, type LoaderEntry } from "./loader-store.js";
17
17
  import { decodeLoaderEntry } from "./decode-loader-results.js";
18
+ import { warnAwaitedSsrSuspension } from "./ssr-suspension-warning.js";
18
19
  import type { LoaderDefinition, LoadOptions } from "./types.js";
19
20
 
20
21
  function isShareableGet(options: LoadOptions | undefined): boolean {
@@ -64,11 +65,18 @@ function lookupLoaderStreams(
64
65
  function extractContentLoaderData(
65
66
  node: ReactNode,
66
67
  loaderId: string,
68
+ awaitedAcc?: string[],
67
69
  ): LoaderLookup {
68
70
  if (!isValidElement(node)) return NOT_FOUND;
69
71
  const props = node.props as Record<string, any> | undefined;
70
72
  if (!props) return NOT_FOUND;
71
73
 
74
+ // Collect BEFORE the lookups so ids on the element that owns the stream
75
+ // entry count for the SSR suspension diagnostic.
76
+ if (awaitedAcc && Array.isArray(props.awaitedLoaderIds)) {
77
+ awaitedAcc.push(...props.awaitedLoaderIds);
78
+ }
79
+
72
80
  // Direct OutletProvider with loaderData
73
81
  if (props.loaderData && loaderId in props.loaderData) {
74
82
  return { value: props.loaderData[loaderId] };
@@ -93,7 +101,9 @@ function extractContentLoaderData(
93
101
  }
94
102
  }
95
103
 
96
- if (props.children) return extractContentLoaderData(props.children, loaderId);
104
+ if (props.children) {
105
+ return extractContentLoaderData(props.children, loaderId, awaitedAcc);
106
+ }
97
107
  return NOT_FOUND;
98
108
  }
99
109
 
@@ -136,9 +146,14 @@ function useLoaderInternal<T>(
136
146
  contextData: T | undefined;
137
147
  hasContextData: boolean;
138
148
  pendingStream?: Promise<unknown>;
149
+ /** { ssr: false } loader ids seen on the chain up to (and including) the
150
+ * level that owned the read — input for the dev SSR suspension warning. */
151
+ awaitedLoaderIds?: string[];
139
152
  } => {
153
+ const awaited: string[] = [];
140
154
  let current: OutletContextValue | null | undefined = context;
141
155
  while (current) {
156
+ if (current.awaitedLoaderIds) awaited.push(...current.awaitedLoaderIds);
142
157
  if (current.loaderData && loader.$$id in current.loaderData) {
143
158
  return {
144
159
  contextData: current.loaderData[loader.$$id] as T,
@@ -152,6 +167,7 @@ function useLoaderInternal<T>(
152
167
  contextData: undefined,
153
168
  hasContextData: true,
154
169
  pendingStream: streamed.stream,
170
+ awaitedLoaderIds: awaited,
155
171
  };
156
172
  }
157
173
  return { contextData: streamed.value as T, hasContextData: true };
@@ -159,6 +175,7 @@ function useLoaderInternal<T>(
159
175
  const contentData = extractContentLoaderData(
160
176
  current.content,
161
177
  loader.$$id,
178
+ awaited,
162
179
  );
163
180
  if (contentData !== NOT_FOUND) {
164
181
  if ("stream" in contentData) {
@@ -166,6 +183,7 @@ function useLoaderInternal<T>(
166
183
  contextData: undefined,
167
184
  hasContextData: true,
168
185
  pendingStream: contentData.stream,
186
+ awaitedLoaderIds: awaited,
169
187
  };
170
188
  }
171
189
  return { contextData: contentData.value as T, hasContextData: true };
@@ -184,6 +202,11 @@ function useLoaderInternal<T>(
184
202
  const { hasContextData } = walk;
185
203
  let contextData = walk.contextData;
186
204
  if (walk.pendingStream) {
205
+ warnAwaitedSsrSuspension(
206
+ loader.$$id,
207
+ walk.awaitedLoaderIds,
208
+ walk.pendingStream,
209
+ );
187
210
  contextData = decodeLoaderEntry(use(walk.pendingStream)) as T;
188
211
  }
189
212
 
@@ -196,6 +196,11 @@ export async function runShellPrerenderPhase(
196
196
  prerender: reactDomStatic.prerender ?? reactDomStatic.default?.prerender,
197
197
  injectRSCPayload: htmlStream.injectRSCPayload,
198
198
  headScripts: "preinit",
199
+ // Build-captured shells must honor the explicit outlining budget: the
200
+ // capture value rides the postponed state into every runtime resume().
201
+ ...(s.opts?.progressiveChunkSize !== undefined && {
202
+ progressiveChunkSize: s.opts.progressiveChunkSize,
203
+ }),
199
204
  loadBootstrapScriptContent: async () => bootstrapContent,
200
205
  });
201
206