@rangojs/router 0.0.0-experimental.144 → 0.0.0-experimental.145
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/vite/index.js +2 -1
- package/package.json +1 -1
- package/skills/ppr/SKILL.md +29 -23
- package/src/browser/logging.ts +18 -0
- package/src/browser/rsc-router.tsx +43 -0
- package/src/cache/cache-runtime.ts +41 -51
- package/src/cache/cf/cf-cache-store.ts +4 -0
- package/src/cache/types.ts +18 -4
- package/src/cache/vercel/vercel-cache-store.ts +6 -1
- package/src/router/segment-resolution/loader-cache.ts +19 -3
- package/src/router/segment-resolution/loader-mask.ts +4 -11
- package/src/router/segment-resolution/loader-snapshot.ts +14 -6
- package/src/router/segment-resolution/mask-nested.ts +83 -0
- package/src/rsc/rsc-rendering.ts +87 -16
- package/src/rsc/shell-capture.ts +32 -0
- package/src/rsc/shell-serve.ts +37 -6
- package/src/segment-loader-promise.ts +18 -0
- package/src/segment-system.tsx +90 -6
- package/src/ssr/index.tsx +15 -6
- package/src/ssr/inject-rsc-eager.ts +167 -0
- package/src/vite/index.ts +7 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture-side nested-thenable masking — the mechanism behind "nested-promise
|
|
3
|
+
* shape is the liveness declaration" for BOTH bake-lane loader containers
|
|
4
|
+
* (loader-cache.ts) and pushed handle containers (request-context.ts
|
|
5
|
+
* createUseFunction).
|
|
6
|
+
*
|
|
7
|
+
* Deliberately a LEAF module: request-context needs the mask for handle
|
|
8
|
+
* pushes, and loader-mask (the other natural home) imports request-context —
|
|
9
|
+
* importing from there would cycle. This module imports only is-thenable.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { isThenable } from "../../handles/is-thenable.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A promise that never settles — the masked stand-in for a per-request value
|
|
16
|
+
* during shell capture. The consuming Suspense subtree suspends forever, so
|
|
17
|
+
* the static prerender postpones it as a hole instead of baking a per-request
|
|
18
|
+
* value into the shared shell. The capture abort (`maxWaitMs` in
|
|
19
|
+
* captureShellHTML) bounds how long the prerender waits before it freezes the
|
|
20
|
+
* prelude, so this never hangs the request.
|
|
21
|
+
*/
|
|
22
|
+
export function createMaskedLoaderPromise<T = unknown>(): Promise<T> {
|
|
23
|
+
return new Promise<T>(() => {});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
27
|
+
if (typeof value !== "object" || value === null) return false;
|
|
28
|
+
const proto = Object.getPrototypeOf(value);
|
|
29
|
+
return proto === Object.prototype || proto === null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deep-copy a container with every NESTED thenable replaced by a masked
|
|
34
|
+
* (never-resolving) promise. Applied during shell capture to (a) bake-lane
|
|
35
|
+
* loader containers (loader-cache.ts) and (b) pushed handle containers
|
|
36
|
+
* (createUseFunction) — the two rango-owned funnels where consumers declare
|
|
37
|
+
* per-request data by promise SHAPE.
|
|
38
|
+
*
|
|
39
|
+
* Why: a nested promise that happened to SETTLE before the capture's quiet
|
|
40
|
+
* window closed used to bake its value into the SHARED shell (and, for
|
|
41
|
+
* loaders, the snapshot pinned it for every HIT) — per-request data frozen
|
|
42
|
+
* and served cross-session (found live: a storefront basket, carrying the
|
|
43
|
+
* capturing session's basketId/customer identifiers, served to anonymous
|
|
44
|
+
* visitors). The window waits for the slowest shared material on the page, so
|
|
45
|
+
* any real data source (a 5ms SQL read, a 200ms basket API) lost the race.
|
|
46
|
+
* Masking makes the consuming subtree postpone as a hole no matter when the
|
|
47
|
+
* promise settles: liveness by declaration, not by racing the window.
|
|
48
|
+
*
|
|
49
|
+
* Only plain objects/arrays are traversed; other values are leaves. The INPUT
|
|
50
|
+
* IS NEVER MUTATED — handler-side loader consumption (the consumption-lane
|
|
51
|
+
* rule, semantic-matrix PPR3) shares the raw container and must keep real
|
|
52
|
+
* values. Cycles are preserved as cycles in the copy.
|
|
53
|
+
*/
|
|
54
|
+
export function maskNestedContainerThenables(
|
|
55
|
+
value: unknown,
|
|
56
|
+
seen: Map<object, unknown> = new Map(),
|
|
57
|
+
): unknown {
|
|
58
|
+
if (isThenable(value)) return createMaskedLoaderPromise();
|
|
59
|
+
|
|
60
|
+
if (Array.isArray(value)) {
|
|
61
|
+
const cached = seen.get(value);
|
|
62
|
+
if (cached !== undefined) return cached;
|
|
63
|
+
const out: unknown[] = new Array(value.length);
|
|
64
|
+
seen.set(value, out);
|
|
65
|
+
for (let i = 0; i < value.length; i++) {
|
|
66
|
+
out[i] = maskNestedContainerThenables(value[i], seen);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (isPlainObject(value)) {
|
|
72
|
+
const cached = seen.get(value);
|
|
73
|
+
if (cached !== undefined) return cached;
|
|
74
|
+
const out: Record<string, unknown> = {};
|
|
75
|
+
seen.set(value, out);
|
|
76
|
+
for (const key of Object.keys(value)) {
|
|
77
|
+
out[key] = maskNestedContainerThenables(value[key], seen);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return value;
|
|
83
|
+
}
|
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
resolvePprConfig,
|
|
39
39
|
buildShellKey,
|
|
40
40
|
isValidShellHit,
|
|
41
|
+
hasIntactShellPayload,
|
|
41
42
|
base64ToBytes,
|
|
42
43
|
hasShellFamily,
|
|
43
44
|
warnShellStoreMissingOnce,
|
|
@@ -164,6 +165,7 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
164
165
|
) {
|
|
165
166
|
const descriptor: ShellCaptureDescriptor = {
|
|
166
167
|
key,
|
|
168
|
+
buildVersion: ctx.version,
|
|
167
169
|
ttl: pprConfig.ttl,
|
|
168
170
|
swr: pprConfig.swr,
|
|
169
171
|
tags: pprConfig.tags,
|
|
@@ -177,30 +179,46 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
177
179
|
// A failing store read degrades to axis 1 (MISS), never a 500.
|
|
178
180
|
reportCacheError(error, "cache-read", "[ShellServe] getShell");
|
|
179
181
|
}
|
|
180
|
-
if (cached && isValidShellHit(cached.entry)) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
182
|
+
if (cached && isValidShellHit(cached.entry, ctx.version)) {
|
|
183
|
+
if (!hasIntactShellPayload(cached.entry)) {
|
|
184
|
+
// Corrupt stored payload (undecodable prelude / unparseable
|
|
185
|
+
// postponed): a store-layer fault worth a diagnostic, unlike the
|
|
186
|
+
// silent version-mismatch lifecycle misses above. Degrade to MISS
|
|
187
|
+
// — pprMiss below schedules the recapture that overwrites it.
|
|
188
|
+
reportCacheError(
|
|
189
|
+
new Error(
|
|
190
|
+
`corrupt shell entry for "${key}": prelude/postponed failed ` +
|
|
191
|
+
"the integrity check; serving axis 1 and recapturing",
|
|
192
|
+
),
|
|
193
|
+
"cache-read",
|
|
194
|
+
"[ShellServe] getShell",
|
|
195
|
+
);
|
|
196
|
+
} else {
|
|
197
|
+
// Stale (SWR) hit: serve the stale shell now, recapture in the
|
|
198
|
+
// background (stampede-guarded + backoff inside scheduleShellCapture).
|
|
199
|
+
if (cached.shouldRevalidate) {
|
|
200
|
+
scheduleShellCapture(
|
|
201
|
+
ctx,
|
|
202
|
+
request,
|
|
203
|
+
env,
|
|
204
|
+
url,
|
|
205
|
+
reqCtx,
|
|
206
|
+
ssrModule,
|
|
207
|
+
descriptor,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return serveShellHit(
|
|
185
211
|
ctx,
|
|
186
212
|
request,
|
|
187
213
|
env,
|
|
188
214
|
url,
|
|
189
215
|
reqCtx,
|
|
216
|
+
handleStore,
|
|
190
217
|
ssrModule,
|
|
218
|
+
cached.entry,
|
|
191
219
|
descriptor,
|
|
192
220
|
);
|
|
193
221
|
}
|
|
194
|
-
return serveShellHit(
|
|
195
|
-
ctx,
|
|
196
|
-
request,
|
|
197
|
-
env,
|
|
198
|
-
url,
|
|
199
|
-
reqCtx,
|
|
200
|
-
handleStore,
|
|
201
|
-
ssrModule,
|
|
202
|
-
cached.entry,
|
|
203
|
-
);
|
|
204
222
|
}
|
|
205
223
|
// MISS (no entry, invalid reactVersion, or store read failure): axis 1
|
|
206
224
|
// + a background capture scheduled once the response is known servable.
|
|
@@ -468,6 +486,7 @@ function serveShellHit(
|
|
|
468
486
|
handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
|
|
469
487
|
ssrModule: SSRModule,
|
|
470
488
|
entry: ShellCacheEntry,
|
|
489
|
+
descriptor: ShellCaptureDescriptor,
|
|
471
490
|
): Response {
|
|
472
491
|
const preludeBytes = base64ToBytes(entry.prelude);
|
|
473
492
|
|
|
@@ -493,11 +512,32 @@ function serveShellHit(
|
|
|
493
512
|
}
|
|
494
513
|
// Full Flight render per request: hydration needs the whole payload (there
|
|
495
514
|
// is no Flight-side resume — a React limitation, not ours).
|
|
496
|
-
|
|
515
|
+
let rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
|
|
497
516
|
onError: (error: unknown) => {
|
|
498
517
|
ctx.callOnError(error, "rendering", { request, url, env });
|
|
499
518
|
},
|
|
500
519
|
});
|
|
520
|
+
// Timing tap: when does the Flight render produce its FIRST byte? Compared
|
|
521
|
+
// with the eager-inject/first-tail logs this proves whether hydration-start
|
|
522
|
+
// latency is genuine server work (loaders) or stream plumbing holding
|
|
523
|
+
// ready bytes back.
|
|
524
|
+
if (INTERNAL_RANGO_DEBUG) {
|
|
525
|
+
const tapStart = performance.now();
|
|
526
|
+
let first = false;
|
|
527
|
+
rscStream = rscStream.pipeThrough(
|
|
528
|
+
new TransformStream({
|
|
529
|
+
transform(chunk, controller) {
|
|
530
|
+
if (!first) {
|
|
531
|
+
first = true;
|
|
532
|
+
console.log(
|
|
533
|
+
`[Server][ppr] flight render: first chunk +${Math.round(performance.now() - tapStart)}ms`,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
controller.enqueue(chunk);
|
|
537
|
+
},
|
|
538
|
+
}),
|
|
539
|
+
);
|
|
540
|
+
}
|
|
501
541
|
return observePhase(PHASES.ssr, () =>
|
|
502
542
|
ssrModule.resumeShellHTML!(rscStream, {
|
|
503
543
|
postponed: entry.postponed,
|
|
@@ -541,17 +581,30 @@ function serveShellHit(
|
|
|
541
581
|
// failure before the stream is pulled never surfaces as an unhandled rejection.
|
|
542
582
|
tailPromise.catch(() => {});
|
|
543
583
|
|
|
584
|
+
const serveStart = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
|
|
544
585
|
const body = new ReadableStream<Uint8Array>({
|
|
545
586
|
async start(controller) {
|
|
546
587
|
controller.enqueue(preludeBytes);
|
|
588
|
+
if (INTERNAL_RANGO_DEBUG) {
|
|
589
|
+
console.log(
|
|
590
|
+
`[Server][ppr] shell HIT: prelude enqueued (${preludeBytes.length}b) +${Math.round(performance.now() - serveStart)}ms`,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
547
593
|
try {
|
|
548
594
|
const tail = await tailPromise;
|
|
549
595
|
if (tail instanceof ReadableStream) {
|
|
550
596
|
const reader = tail.getReader();
|
|
597
|
+
let firstTailChunk = true;
|
|
551
598
|
try {
|
|
552
599
|
for (;;) {
|
|
553
600
|
const { done, value } = await reader.read();
|
|
554
601
|
if (done) break;
|
|
602
|
+
if (INTERNAL_RANGO_DEBUG && firstTailChunk) {
|
|
603
|
+
firstTailChunk = false;
|
|
604
|
+
console.log(
|
|
605
|
+
`[Server][ppr] shell HIT: first tail chunk on the wire +${Math.round(performance.now() - serveStart)}ms`,
|
|
606
|
+
);
|
|
607
|
+
}
|
|
555
608
|
controller.enqueue(value);
|
|
556
609
|
}
|
|
557
610
|
} finally {
|
|
@@ -577,6 +630,24 @@ function serveShellHit(
|
|
|
577
630
|
}
|
|
578
631
|
controller.close();
|
|
579
632
|
} catch (error) {
|
|
633
|
+
// Self-heal on a failed tail: the pre-commit gates (isValidShellHit +
|
|
634
|
+
// hasIntactShellPayload) cannot catch a parseable-but-mismatched
|
|
635
|
+
// postponed blob or a hard render error above the holes — those throw
|
|
636
|
+
// here, AFTER the 200 + prelude flushed, and would otherwise re-fail on
|
|
637
|
+
// every request until the entry ages out (nothing else evicts it).
|
|
638
|
+
// Recapturing overwrites the entry with one the current server
|
|
639
|
+
// produced. A client disconnect mid-stream also lands here and
|
|
640
|
+
// schedules a spurious-but-idempotent recapture — bounded by the
|
|
641
|
+
// stampede guard + backoff inside scheduleShellCapture.
|
|
642
|
+
scheduleShellCapture(
|
|
643
|
+
ctx,
|
|
644
|
+
request,
|
|
645
|
+
env,
|
|
646
|
+
url,
|
|
647
|
+
reqCtx,
|
|
648
|
+
ssrModule,
|
|
649
|
+
descriptor,
|
|
650
|
+
);
|
|
580
651
|
controller.error(error);
|
|
581
652
|
}
|
|
582
653
|
},
|
package/src/rsc/shell-capture.ts
CHANGED
|
@@ -28,6 +28,8 @@ 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";
|
|
32
|
+
import { isThenable } from "../handles/is-thenable.js";
|
|
31
33
|
import type {
|
|
32
34
|
ShellCacheEntry,
|
|
33
35
|
SegmentCacheStore,
|
|
@@ -529,6 +531,13 @@ export function gateFlightForCapture(
|
|
|
529
531
|
*/
|
|
530
532
|
export interface ShellCaptureDescriptor {
|
|
531
533
|
key: string;
|
|
534
|
+
/**
|
|
535
|
+
* The RSC handler's build version (HandlerContext.version), stamped into the
|
|
536
|
+
* stored entry as ShellCacheEntry.buildVersion — the serve-side
|
|
537
|
+
* isValidShellHit gate compares it against the running build so a persistent
|
|
538
|
+
* store can never resume a stale build's postponed blob.
|
|
539
|
+
*/
|
|
540
|
+
buildVersion: string;
|
|
532
541
|
ttl?: number;
|
|
533
542
|
swr?: number;
|
|
534
543
|
tags?: string[];
|
|
@@ -744,6 +753,28 @@ async function attemptCapture(
|
|
|
744
753
|
|
|
745
754
|
const freshHandleStore = createHandleStore();
|
|
746
755
|
freshHandleStore.onError = reqCtx._handleStore.onError;
|
|
756
|
+
// Shape = liveness for handles, exactly as for bake-lane loader containers
|
|
757
|
+
// (mask-nested.ts): nested thenables in a pushed handle container are
|
|
758
|
+
// per-request by declaration, so the CAPTURE's copy masks them — the
|
|
759
|
+
// consuming boundary postpones as a hole regardless of settle timing,
|
|
760
|
+
// instead of a fast-settling nested value baking into the shared shell. A
|
|
761
|
+
// TOP-LEVEL promise push keeps its documented bake contract (awaited
|
|
762
|
+
// pre-SSR, gate held open for it), but the container it RESOLVES to gets
|
|
763
|
+
// the same nested masking. Wrapping THIS store's push is the single funnel:
|
|
764
|
+
// the store exists only for this capture attempt, so every push wrapper
|
|
765
|
+
// (setupLoaderAccess, createUseFunction, prerender) inherits the policy and
|
|
766
|
+
// the foreground store is untouched.
|
|
767
|
+
const rawCapturePush = freshHandleStore.push.bind(freshHandleStore);
|
|
768
|
+
freshHandleStore.push = (
|
|
769
|
+
handleName: string,
|
|
770
|
+
segmentId: string,
|
|
771
|
+
value: unknown,
|
|
772
|
+
) => {
|
|
773
|
+
const masked = isThenable(value)
|
|
774
|
+
? value.then((v: unknown) => maskNestedContainerThenables(v))
|
|
775
|
+
: maskNestedContainerThenables(value);
|
|
776
|
+
rawCapturePush(handleName, segmentId, masked);
|
|
777
|
+
};
|
|
747
778
|
|
|
748
779
|
const derivedCtx: RequestContext = Object.create(reqCtx);
|
|
749
780
|
derivedCtx._handleStore = freshHandleStore;
|
|
@@ -1104,6 +1135,7 @@ async function captureAndStoreShell(
|
|
|
1104
1135
|
prelude: bufferToBase64(result.prelude.slice().buffer as ArrayBuffer),
|
|
1105
1136
|
postponed: result.postponed,
|
|
1106
1137
|
reactVersion: React.version,
|
|
1138
|
+
buildVersion: capture.buildVersion,
|
|
1107
1139
|
// The theme this capture's payload was built with (buildFullPayload
|
|
1108
1140
|
// reads reqCtx.theme off the derived context). The serve tail replays
|
|
1109
1141
|
// it so the resume tree matches the frozen prelude — see
|
package/src/rsc/shell-serve.ts
CHANGED
|
@@ -75,13 +75,44 @@ export function buildShellKey(url: URL): string {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
78
|
+
* Version gates for a stored shell: reactVersion AND buildVersion must both
|
|
79
|
+
* match the running server. The postponed blob encodes hole positions against
|
|
80
|
+
* one exact tree, so resuming it under a different React OR a different app
|
|
81
|
+
* build tree-mismatches inside resume() — after the 200 + prelude committed,
|
|
82
|
+
* with no recovery. Either mismatch is a miss: the recapture overwrites the
|
|
83
|
+
* same key (self-healing) and the entry otherwise ages out via TTL. An entry
|
|
84
|
+
* with no buildVersion (stored before the field existed) is a miss for the
|
|
85
|
+
* same reason — its build is unknown, so it cannot be proven resumable.
|
|
82
86
|
*/
|
|
83
|
-
export function isValidShellHit(
|
|
84
|
-
|
|
87
|
+
export function isValidShellHit(
|
|
88
|
+
entry: ShellCacheEntry,
|
|
89
|
+
buildVersion: string,
|
|
90
|
+
): boolean {
|
|
91
|
+
return (
|
|
92
|
+
entry.reactVersion === React.version && entry.buildVersion === buildVersion
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Payload integrity gate, run BEFORE the HIT response commits: a stored entry
|
|
98
|
+
* whose prelude is not decodable base64 or whose postponed blob is not
|
|
99
|
+
* parseable JSON would otherwise throw AFTER the 200 + full static prelude
|
|
100
|
+
* flushed (`serveShellHit` decodes at stream construction, `resumeShellHTML`
|
|
101
|
+
* parses in the tail) — the client gets a visually complete page that never
|
|
102
|
+
* hydrates, re-served on every request until the entry ages out (no eviction
|
|
103
|
+
* path exists; failure schedules no recapture by itself). Checking here turns
|
|
104
|
+
* a corrupt entry (store-layer fault) into a plain MISS the recapture
|
|
105
|
+
* overwrites. Cost: one duplicate decode/parse per HIT, sub-ms against a
|
|
106
|
+
* prelude flush that dominates the path.
|
|
107
|
+
*/
|
|
108
|
+
export function hasIntactShellPayload(entry: ShellCacheEntry): boolean {
|
|
109
|
+
try {
|
|
110
|
+
base64ToBytes(entry.prelude);
|
|
111
|
+
if (entry.postponed !== null) JSON.parse(entry.postponed);
|
|
112
|
+
return true;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
85
116
|
}
|
|
86
117
|
|
|
87
118
|
/** Decode a base64 prelude back into bytes for stream composition. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ResolvedSegment } from "./types.js";
|
|
2
|
+
import { INTERNAL_RANGO_DEBUG } from "./internal-debug.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Cache of aggregate Promise.all results keyed on the first loader's
|
|
@@ -83,6 +84,23 @@ export function buildLoaderPromise(loaders: ResolvedSegment[]): Promise<any[]> {
|
|
|
83
84
|
if (loaders.length === 0) {
|
|
84
85
|
return Promise.resolve([]);
|
|
85
86
|
}
|
|
87
|
+
// Debug tap (browser only): log when each PENDING loader promise settles —
|
|
88
|
+
// i.e. when its data actually lands from the flight stream — independent of
|
|
89
|
+
// when the tree build awaits it. `.then(cb, cb)` observes on a branch, so
|
|
90
|
+
// rejections still propagate to the real consumers untouched.
|
|
91
|
+
if (INTERNAL_RANGO_DEBUG && IS_BROWSER) {
|
|
92
|
+
const tapStart = performance.now();
|
|
93
|
+
for (const loader of loaders) {
|
|
94
|
+
if (loader.loaderData instanceof Promise) {
|
|
95
|
+
const settle = (outcome: string) => () =>
|
|
96
|
+
console.log(
|
|
97
|
+
`[Browser][segments] loader ${loader.loaderId} ${outcome} @ ${Math.round(performance.now())}ms`,
|
|
98
|
+
{ msSinceRequested: Math.round(performance.now() - tapStart) },
|
|
99
|
+
);
|
|
100
|
+
loader.loaderData.then(settle("settled"), settle("rejected"));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
86
104
|
return Promise.all(
|
|
87
105
|
loaders.map((loader) =>
|
|
88
106
|
loader.loaderData instanceof Promise
|
package/src/segment-system.tsx
CHANGED
|
@@ -17,6 +17,22 @@ import {
|
|
|
17
17
|
getMemoizedLoaderPromise,
|
|
18
18
|
} from "./segment-loader-promise.js";
|
|
19
19
|
|
|
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.
|
|
25
|
+
*/
|
|
26
|
+
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`;
|
|
29
|
+
if (details) {
|
|
30
|
+
console.log(prefix, details);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
console.log(prefix);
|
|
34
|
+
}
|
|
35
|
+
|
|
20
36
|
// ViewTransition is only available in React experimental.
|
|
21
37
|
// Access via namespace import to avoid compile-time errors on stable React.
|
|
22
38
|
const ReactViewTransition: any =
|
|
@@ -212,6 +228,17 @@ export async function renderSegments(
|
|
|
212
228
|
rootLayout: RootLayout,
|
|
213
229
|
} = options || {};
|
|
214
230
|
|
|
231
|
+
const segDebug = INTERNAL_RANGO_DEBUG && typeof window === "object";
|
|
232
|
+
const segDebugStart = segDebug ? performance.now() : 0;
|
|
233
|
+
if (segDebug) {
|
|
234
|
+
segDebugLog("renderSegments start", {
|
|
235
|
+
segments: segments.map((s) => `${s.id}:${s.type}`),
|
|
236
|
+
isAction: !!isAction,
|
|
237
|
+
forceAwait: !!forceAwait,
|
|
238
|
+
intercepts: interceptSegments?.length ?? 0,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
215
242
|
const temporalLazyRefs: Promise<any>[] = [];
|
|
216
243
|
const normalizedSegments = restoreParallelLoaderMarkers(segments);
|
|
217
244
|
const normalizedInterceptSegments = interceptSegments
|
|
@@ -273,6 +300,15 @@ export async function renderSegments(
|
|
|
273
300
|
);
|
|
274
301
|
const { component, id, params, loading } = node.segment;
|
|
275
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
|
+
}
|
|
311
|
+
|
|
276
312
|
// Param-agnostic keys are opt-in via the transition() DSL (see
|
|
277
313
|
// inTransitionScope above). A route (and its route-owned layouts) inside a
|
|
278
314
|
// transition scope drops the param from its key, so navigating between two
|
|
@@ -403,10 +439,25 @@ export async function renderSegments(
|
|
|
403
439
|
|
|
404
440
|
if (loading !== undefined && loading !== null) {
|
|
405
441
|
const loaderDataPromise = getMemoizedLoaderPromise(loaderEntries);
|
|
442
|
+
let boundaryLoaderData: Promise<any[]> | any[] = loaderDataPromise;
|
|
443
|
+
if (forceAwait || isAction) {
|
|
444
|
+
const awaitStart = segDebug ? performance.now() : 0;
|
|
445
|
+
boundaryLoaderData = await loaderDataPromise;
|
|
446
|
+
if (segDebug) {
|
|
447
|
+
segDebugLog(`segment ${id}: loaders awaited (forceAwait/action)`, {
|
|
448
|
+
loaderIds,
|
|
449
|
+
ms: Math.round(performance.now() - awaitStart),
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
} else if (segDebug) {
|
|
453
|
+
segDebugLog(
|
|
454
|
+
`segment ${id}: streaming loaders via LoaderBoundary (suspense)`,
|
|
455
|
+
{ loaderIds },
|
|
456
|
+
);
|
|
457
|
+
}
|
|
406
458
|
content = createElement(LoaderBoundary, {
|
|
407
459
|
key: `loader-boundary-${key}`,
|
|
408
|
-
loaderDataPromise:
|
|
409
|
-
forceAwait || isAction ? await loaderDataPromise : loaderDataPromise,
|
|
460
|
+
loaderDataPromise: boundaryLoaderData,
|
|
410
461
|
loaderIds,
|
|
411
462
|
fallback: loading,
|
|
412
463
|
outletKey: key,
|
|
@@ -430,7 +481,17 @@ export async function renderSegments(
|
|
|
430
481
|
);
|
|
431
482
|
|
|
432
483
|
const layoutLoaderIds = layoutLoaders.map((l) => l.loaderId!);
|
|
484
|
+
// No loading() on this segment, so its loader data cannot stream behind
|
|
485
|
+
// a Suspense fallback — the tree build BLOCKS here until the data
|
|
486
|
+
// arrives. On the initial document this await runs before hydrateRoot.
|
|
487
|
+
const layoutAwaitStart = segDebug ? performance.now() : 0;
|
|
433
488
|
const resolvedData = await buildLoaderPromise(layoutLoaders);
|
|
489
|
+
if (segDebug) {
|
|
490
|
+
segDebugLog(`segment ${id}: layout loaders awaited (blocking)`, {
|
|
491
|
+
loaderIds: layoutLoaderIds,
|
|
492
|
+
ms: Math.round(performance.now() - layoutAwaitStart),
|
|
493
|
+
});
|
|
494
|
+
}
|
|
434
495
|
const { loaderData, errorFallback } = decodeLoaderResults(
|
|
435
496
|
resolvedData,
|
|
436
497
|
layoutLoaderIds,
|
|
@@ -463,10 +524,27 @@ export async function renderSegments(
|
|
|
463
524
|
|
|
464
525
|
p.loaderIds = ownedLoaders.map((l) => l.loaderId!);
|
|
465
526
|
const aggregated = getMemoizedLoaderPromise(ownedLoaders);
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
527
|
+
if ((forceAwait || isAction) && aggregated instanceof Promise) {
|
|
528
|
+
const parallelAwaitStart = segDebug ? performance.now() : 0;
|
|
529
|
+
p.loaderDataPromise = await aggregated;
|
|
530
|
+
if (segDebug) {
|
|
531
|
+
segDebugLog(
|
|
532
|
+
`segment ${id}: parallel ${p.id} loaders awaited (forceAwait/action)`,
|
|
533
|
+
{
|
|
534
|
+
loaderIds: p.loaderIds,
|
|
535
|
+
ms: Math.round(performance.now() - parallelAwaitStart),
|
|
536
|
+
},
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
} else {
|
|
540
|
+
p.loaderDataPromise = aggregated;
|
|
541
|
+
if (segDebug) {
|
|
542
|
+
segDebugLog(
|
|
543
|
+
`segment ${id}: parallel ${p.id} loaders streaming (suspense)`,
|
|
544
|
+
{ loaderIds: p.loaderIds },
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
470
548
|
}
|
|
471
549
|
}
|
|
472
550
|
|
|
@@ -524,6 +602,12 @@ export async function renderSegments(
|
|
|
524
602
|
});
|
|
525
603
|
}
|
|
526
604
|
|
|
605
|
+
if (segDebug) {
|
|
606
|
+
segDebugLog("renderSegments complete", {
|
|
607
|
+
ms: Math.round(performance.now() - segDebugStart),
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
527
611
|
return result;
|
|
528
612
|
}
|
|
529
613
|
|
package/src/ssr/index.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { createSsrRootComponent } from "./ssr-root.js";
|
|
3
|
+
import { injectRSCPayloadEager } from "./inject-rsc-eager.js";
|
|
3
4
|
import type { ErrorPhase } from "../types.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -594,7 +595,7 @@ export function createShellCaptureHandler<TEnv = unknown>(
|
|
|
594
595
|
export function createShellResumeHandler<TEnv = unknown>(
|
|
595
596
|
deps: SSRDependencies<TEnv>,
|
|
596
597
|
) {
|
|
597
|
-
const { createFromReadableStream,
|
|
598
|
+
const { createFromReadableStream, resume, onError } = deps;
|
|
598
599
|
|
|
599
600
|
/**
|
|
600
601
|
* @param rscStream - Fresh full Flight stream for this request.
|
|
@@ -609,11 +610,12 @@ export function createShellResumeHandler<TEnv = unknown>(
|
|
|
609
610
|
try {
|
|
610
611
|
if (postponed === null) {
|
|
611
612
|
// DATA variant: the stored prelude is the complete shell. No fizz runs;
|
|
612
|
-
//
|
|
613
|
-
//
|
|
614
|
-
//
|
|
613
|
+
// the eager injector pumps the fresh Flight payload scripts without
|
|
614
|
+
// needing an HTML chunk to trigger it (the stock injector deadlocked on
|
|
615
|
+
// a chunkless stream — see createDataVariantHtmlStream, kept for the
|
|
616
|
+
// batching invariant's sake).
|
|
615
617
|
return createDataVariantHtmlStream().pipeThrough(
|
|
616
|
-
|
|
618
|
+
injectRSCPayloadEager(rscStream, { nonce }),
|
|
617
619
|
);
|
|
618
620
|
}
|
|
619
621
|
|
|
@@ -640,7 +642,14 @@ export function createShellResumeHandler<TEnv = unknown>(
|
|
|
640
642
|
nonce,
|
|
641
643
|
});
|
|
642
644
|
|
|
643
|
-
|
|
645
|
+
// EAGER injection (resume-only): the stored prelude — a complete document
|
|
646
|
+
// through </body></html> — is already on the wire ahead of this stream,
|
|
647
|
+
// so a Flight <script> is valid as the first tail byte. The stock
|
|
648
|
+
// injector waits for the first fizz chunk, which only appears when the
|
|
649
|
+
// first hole's loaders resolve — parking the whole hydration payload
|
|
650
|
+
// (root row included) behind the slowest live loader. See
|
|
651
|
+
// inject-rsc-eager.ts for the measured failure mode.
|
|
652
|
+
return resumed.pipeThrough(injectRSCPayloadEager(rscStream2, { nonce }));
|
|
644
653
|
} catch (error) {
|
|
645
654
|
reportRenderError(onError, error);
|
|
646
655
|
throw error;
|