@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.
- package/AGENTS.md +1 -1
- package/LICENSE +21 -0
- package/README.md +17 -15
- package/dist/types/cache/cf/cf-cache-constants.d.ts +1 -1
- package/dist/types/cache/cf/cf-cache-store.d.ts +1 -1
- package/dist/types/outlet-context.d.ts +9 -0
- package/dist/types/outlet-provider.d.ts +2 -1
- package/dist/types/route-content-wrapper.d.ts +4 -1
- package/dist/types/rsc/nonce.d.ts +10 -0
- package/dist/types/rsc/shell-capture-constants.d.ts +1 -1
- package/dist/types/rsc/types.d.ts +6 -2
- package/dist/types/ssr/index.d.ts +18 -0
- package/dist/types/ssr/ssr-root.d.ts +16 -0
- package/dist/types/ssr-suspension-warning.d.ts +20 -0
- package/dist/types/types/loader-types.d.ts +5 -1
- package/dist/types/types/segments.d.ts +17 -0
- package/dist/types/vite/discovery/state.d.ts +6 -0
- package/dist/types/vite/plugin-types.d.ts +27 -0
- package/dist/types/vite/plugins/virtual-entries.d.ts +1 -1
- package/dist/types/vite/utils/shared-utils.d.ts +1 -0
- package/dist/vite/index.js +56 -26
- package/package.json +26 -26
- package/skills/bundle-analysis/SKILL.md +1 -1
- package/skills/loader/SKILL.md +17 -2
- package/skills/testing/SKILL.md +1 -1
- package/skills/testing/bindings.md +1 -1
- package/skills/testing/cache-prerender.md +1 -1
- package/skills/testing/client-components.md +1 -1
- package/skills/testing/e2e-parity.md +1 -1
- package/skills/testing/flight.md +1 -1
- package/skills/testing/handles.md +1 -1
- package/skills/testing/loader.md +1 -1
- package/skills/testing/middleware.md +1 -1
- package/skills/testing/render-handler.md +1 -1
- package/skills/testing/response-routes.md +1 -1
- package/skills/testing/reverse-and-types.md +1 -1
- package/skills/testing/server-actions.md +1 -1
- package/skills/testing/server-tree.md +1 -1
- package/skills/testing/setup.md +1 -1
- package/src/browser/merge-segment-loaders.ts +4 -0
- package/src/cache/cache-runtime.ts +2 -2
- package/src/cache/cf/cf-cache-constants.ts +1 -1
- package/src/cache/cf/cf-cache-store.ts +1 -1
- package/src/client.tsx +2 -0
- package/src/outlet-context.ts +9 -0
- package/src/outlet-provider.tsx +4 -0
- package/src/route-content-wrapper.tsx +7 -0
- package/src/router/segment-resolution/fresh.ts +48 -17
- package/src/router.ts +7 -2
- package/src/rsc/handler.ts +4 -4
- package/src/rsc/nonce.ts +16 -0
- package/src/rsc/shell-capture-constants.ts +1 -1
- package/src/rsc/shell-capture.ts +41 -8
- package/src/rsc/types.ts +6 -2
- package/src/segment-system.tsx +57 -12
- package/src/ssr/index.tsx +53 -1
- package/src/ssr/ssr-root.tsx +50 -22
- package/src/ssr-suspension-warning.ts +68 -0
- package/src/types/loader-types.ts +5 -1
- package/src/types/segments.ts +17 -0
- package/src/use-loader.tsx +24 -1
- package/src/vite/discovery/shell-prerender-phase.ts +5 -0
- package/src/vite/discovery/state.ts +6 -0
- package/src/vite/plugin-types.ts +28 -0
- package/src/vite/plugins/virtual-entries.ts +26 -3
- package/src/vite/rango.ts +3 -0
- package/src/vite/router-discovery.ts +31 -19
- package/src/vite/utils/shared-utils.ts +8 -2
package/src/rsc/shell-capture.ts
CHANGED
|
@@ -45,7 +45,10 @@ import {
|
|
|
45
45
|
maskNestedContainerThenables,
|
|
46
46
|
type MaskReport,
|
|
47
47
|
} from "../router/segment-resolution/mask-nested.js";
|
|
48
|
-
import {
|
|
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/
|
|
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 (
|
|
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 (
|
|
1525
|
-
|
|
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
|
-
|
|
1541
|
-
|
|
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
|
-
*
|
|
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 |
|
|
288
|
+
) => string | boolean | Promise<string | boolean>;
|
|
285
289
|
|
|
286
290
|
/**
|
|
287
291
|
* Options for creating an RSC handler
|
package/src/segment-system.tsx
CHANGED
|
@@ -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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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.
|
package/src/ssr/ssr-root.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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;
|
package/src/types/segments.ts
CHANGED
|
@@ -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
|
package/src/use-loader.tsx
CHANGED
|
@@ -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)
|
|
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
|
|