@rangojs/router 0.0.0-experimental.145 → 0.0.0-experimental.147
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/rango.js +8 -40
- package/dist/vite/index.js +840 -243
- package/package.json +6 -1
- package/src/browser/event-controller.ts +16 -2
- package/src/browser/rsc-router.tsx +11 -0
- package/src/cache/cache-scope.ts +41 -3
- package/src/cache/cf/cf-cache-store.ts +23 -0
- package/src/cache/handle-snapshot.ts +22 -1
- package/src/cache/memory-segment-store.ts +32 -0
- package/src/cache/segment-codec.ts +47 -0
- package/src/cache/shell-snapshot.ts +47 -0
- package/src/cache/types.ts +27 -0
- package/src/cache/vercel/vercel-cache-store.ts +71 -2
- package/src/deps/ssr.ts +4 -1
- package/src/prerender/build-shell-capture.ts +237 -0
- package/src/prerender/shell-manifest-key.ts +20 -0
- package/src/prerender/store.ts +10 -1
- package/src/router/loader-resolution.ts +16 -0
- package/src/router/match-api.ts +9 -2
- package/src/router/match-handlers.ts +13 -0
- package/src/router/match-middleware/cache-lookup.ts +12 -1
- package/src/router/prerender-match.ts +21 -0
- package/src/router/segment-resolution/mask-nested.ts +19 -3
- package/src/rsc/capture-queue.ts +67 -0
- package/src/rsc/rsc-rendering.ts +136 -25
- package/src/rsc/shell-build-manifest.ts +244 -0
- package/src/rsc/shell-capture.ts +194 -43
- package/src/segment-fragments.ts +124 -0
- package/src/segment-system.tsx +49 -19
- package/src/server/request-context.ts +112 -11
- package/src/ssr/index.tsx +151 -22
- package/src/ssr/inject-rsc-eager.ts +2 -2
- package/src/ssr/preinit-client-references.ts +106 -0
- package/src/ssr/ssr-root.tsx +35 -2
- package/src/vite/discovery/discover-routers.ts +27 -0
- package/src/vite/discovery/prerender-collection.ts +16 -0
- package/src/vite/discovery/shell-prerender-phase.ts +395 -0
- package/src/vite/discovery/state.ts +42 -0
- package/src/vite/index.ts +1 -0
- package/src/vite/plugin-types.ts +33 -0
- package/src/vite/plugins/version-plugin.ts +8 -0
- package/src/vite/plugins/virtual-entries.ts +37 -4
- package/src/vite/rango.ts +11 -2
- package/src/vite/router-discovery.ts +292 -8
- package/src/vite/utils/prerender-utils.ts +25 -6
- package/src/vite/utils/shared-utils.ts +4 -2
package/src/rsc/shell-capture.ts
CHANGED
|
@@ -20,15 +20,21 @@ import React from "react";
|
|
|
20
20
|
import { bufferToBase64 } from "../cache/cf/cf-base64.js";
|
|
21
21
|
import { reportCacheError } from "../cache/cache-error.js";
|
|
22
22
|
import { runBackground } from "../cache/background-task.js";
|
|
23
|
+
import { enqueueSerializedCapture } from "./capture-queue.js";
|
|
23
24
|
import { observePhase, PHASES } from "../router/instrument.js";
|
|
24
25
|
import {
|
|
25
26
|
runWithRequestContext,
|
|
26
27
|
setRequestContextParams,
|
|
28
|
+
wireRenderBarrier,
|
|
27
29
|
UNTRACKED_BACKGROUND_TASK,
|
|
28
30
|
type RequestContext,
|
|
29
31
|
} from "../server/request-context.js";
|
|
30
32
|
import { createHandleStore, type HandleStore } from "../server/handle-store.js";
|
|
31
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
maskNestedContainerThenables,
|
|
35
|
+
type MaskReport,
|
|
36
|
+
} from "../router/segment-resolution/mask-nested.js";
|
|
37
|
+
import { isInsideLoaderScope } from "../server/context.js";
|
|
32
38
|
import { isThenable } from "../handles/is-thenable.js";
|
|
33
39
|
import type {
|
|
34
40
|
ShellCacheEntry,
|
|
@@ -41,6 +47,7 @@ import {
|
|
|
41
47
|
} from "../router/segment-resolution/loader-snapshot.js";
|
|
42
48
|
import {
|
|
43
49
|
RecordingShellStore,
|
|
50
|
+
SnapshotOnlySegmentStore,
|
|
44
51
|
getRecordingStore,
|
|
45
52
|
} from "../cache/shell-snapshot.js";
|
|
46
53
|
import type { HandlerContext } from "./handler-context.js";
|
|
@@ -599,14 +606,20 @@ export function scheduleShellCapture(
|
|
|
599
606
|
inFlightCaptures.delete(key);
|
|
600
607
|
}
|
|
601
608
|
};
|
|
609
|
+
// Serialize capture EXECUTION per isolate (capture-queue.ts): concurrent
|
|
610
|
+
// captures starve each other's task-quantized quiet windows — one grinding
|
|
611
|
+
// capture makes the sibling freeze a trivial prelude and store nothing
|
|
612
|
+
// (rotating eternal-MISS victims on GH runners). The stampede guard above
|
|
613
|
+
// stays per-key (dedupe while queued); the queue is cross-key.
|
|
614
|
+
const serializedTask = () => enqueueSerializedCapture(captureTask);
|
|
602
615
|
// The capture's own task must NOT enter reqCtx._pendingBackgroundTasks: the
|
|
603
616
|
// capture drains that list before rendering (the write-barrier ordering edge),
|
|
604
617
|
// and awaiting its own still-running promise would burn the whole barrier
|
|
605
618
|
// deadline on every capture.
|
|
606
|
-
(
|
|
619
|
+
(serializedTask as { [UNTRACKED_BACKGROUND_TASK]?: boolean })[
|
|
607
620
|
UNTRACKED_BACKGROUND_TASK
|
|
608
621
|
] = true;
|
|
609
|
-
runBackground(reqCtx,
|
|
622
|
+
runBackground(reqCtx, serializedTask);
|
|
610
623
|
}
|
|
611
624
|
|
|
612
625
|
/**
|
|
@@ -701,6 +714,18 @@ async function runShellCapture(
|
|
|
701
714
|
return "no-shell";
|
|
702
715
|
}
|
|
703
716
|
|
|
717
|
+
/** Fold the capture's handle-liveness record into the entry flag (true | undefined). */
|
|
718
|
+
function handlerLayerIsLive(
|
|
719
|
+
liveness: RequestContext["_shellCaptureHandleLiveness"],
|
|
720
|
+
): true | undefined {
|
|
721
|
+
if (!liveness) return undefined;
|
|
722
|
+
return liveness.holes ||
|
|
723
|
+
liveness.pendingPushes > 0 ||
|
|
724
|
+
liveness.handlerInvokedLoader
|
|
725
|
+
? true
|
|
726
|
+
: undefined;
|
|
727
|
+
}
|
|
728
|
+
|
|
704
729
|
/**
|
|
705
730
|
* One capture attempt in a DERIVED request context.
|
|
706
731
|
*
|
|
@@ -720,6 +745,9 @@ async function runShellCapture(
|
|
|
720
745
|
* - _shellCaptureRun: true — the switch loaders/cookies/headers guards read.
|
|
721
746
|
* - _metricsStore: undefined so the capture never appends to the foreground's
|
|
722
747
|
* (already-finalized) metrics.
|
|
748
|
+
* - _renderBarrier family: an own barrier wired to the fresh handle store
|
|
749
|
+
* (wireRenderBarrier), plus _treeHasStreaming/deadlock-guard resets — the
|
|
750
|
+
* capture's rendered() lifecycle is its own, not the foreground's.
|
|
723
751
|
*
|
|
724
752
|
* The capture is MIXED-CHAIN: its match() behaves like a normal render with
|
|
725
753
|
* respect to the segment cache — cache()'d segments replay from ring 3, UNCACHED
|
|
@@ -751,6 +779,74 @@ async function attemptCapture(
|
|
|
751
779
|
// attempt (the retry re-checks; already-settled promises are free).
|
|
752
780
|
await settleTrackedBackgroundTasks(reqCtx, SHELL_CAPTURE_WRITE_BARRIER_MS);
|
|
753
781
|
|
|
782
|
+
const { derivedCtx, freshHandleStore } = deriveShellCaptureContext(
|
|
783
|
+
reqCtx,
|
|
784
|
+
descriptor,
|
|
785
|
+
);
|
|
786
|
+
|
|
787
|
+
return runWithRequestContext(derivedCtx, async () => {
|
|
788
|
+
const match = await ctx.router.match(request, { env });
|
|
789
|
+
// A route that redirects has no shell to capture — bail (no store write, no
|
|
790
|
+
// retry: a redirect is deterministic).
|
|
791
|
+
if (match.redirect) return "redirect";
|
|
792
|
+
|
|
793
|
+
setRequestContextParams(match.params, match.routeName);
|
|
794
|
+
|
|
795
|
+
const payload = buildFullPayload(
|
|
796
|
+
match,
|
|
797
|
+
ctx,
|
|
798
|
+
url,
|
|
799
|
+
derivedCtx,
|
|
800
|
+
freshHandleStore,
|
|
801
|
+
);
|
|
802
|
+
const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
|
|
803
|
+
onError: (error: unknown) => {
|
|
804
|
+
ctx.callOnError(error, "rendering", { request, url, env });
|
|
805
|
+
},
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
// Pass the descriptor with its STATIC ppr.tags unchanged. The shell's own
|
|
809
|
+
// render-recorded tags are snapshotted at the putShell WRITE BARRIER inside
|
|
810
|
+
// captureAndStoreShell, not here: a tag recorded AFTER an await in async shell
|
|
811
|
+
// content (and tags propagated by async cache()/"use cache" reads) lands after
|
|
812
|
+
// this synchronous construction point, so snapshotting here dropped it — the
|
|
813
|
+
// shell-tag snapshot must sit behind the quiesce gate (issue #676).
|
|
814
|
+
return captureAndStoreShell(
|
|
815
|
+
ssrModule,
|
|
816
|
+
rscStream,
|
|
817
|
+
freshHandleStore,
|
|
818
|
+
derivedCtx,
|
|
819
|
+
descriptor,
|
|
820
|
+
);
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* The derived capture context and its fresh (mask-funneled) handle store,
|
|
826
|
+
* shared by BOTH shell producers: the runtime background capture
|
|
827
|
+
* (attemptCapture, producer A) and the build-time prerender shell capture
|
|
828
|
+
* (prerender/build-shell-capture.ts, producer B — issue #699). One
|
|
829
|
+
* implementation so the capture semantics — the nested-thenable mask funnel,
|
|
830
|
+
* handler-liveness bookkeeping, snapshot recording, the implicit doc-cache
|
|
831
|
+
* scope — cannot drift between producers.
|
|
832
|
+
*/
|
|
833
|
+
export interface CaptureContextDerivation {
|
|
834
|
+
derivedCtx: RequestContext;
|
|
835
|
+
freshHandleStore: HandleStore;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Derive the capture request context from a base context. Producer A passes
|
|
840
|
+
* the foreground request's post-middleware context (the derived context
|
|
841
|
+
* inherits its variables/env/cookie machinery through the prototype);
|
|
842
|
+
* producer B passes a synthetic build-request context created by
|
|
843
|
+
* createRequestContext over the build env, with a fresh MemorySegmentCacheStore
|
|
844
|
+
* as `_cacheStore` so the recording/snapshot machinery arms identically.
|
|
845
|
+
*/
|
|
846
|
+
export function deriveShellCaptureContext(
|
|
847
|
+
reqCtx: RequestContext<any>,
|
|
848
|
+
descriptor: Pick<ShellCaptureDescriptor, "ttl" | "swr">,
|
|
849
|
+
): CaptureContextDerivation {
|
|
754
850
|
const freshHandleStore = createHandleStore();
|
|
755
851
|
freshHandleStore.onError = reqCtx._handleStore.onError;
|
|
756
852
|
// Shape = liveness for handles, exactly as for bake-lane loader containers
|
|
@@ -764,21 +860,83 @@ async function attemptCapture(
|
|
|
764
860
|
// the store exists only for this capture attempt, so every push wrapper
|
|
765
861
|
// (setupLoaderAccess, createUseFunction, prerender) inherits the policy and
|
|
766
862
|
// the foreground store is untouched.
|
|
863
|
+
// Shell fast path bookkeeping on the same funnel:
|
|
864
|
+
// - handleLiveness: a nested thenable in a push made OUTSIDE a DSL loader
|
|
865
|
+
// scope (attribution read synchronously at push time — handler bodies,
|
|
866
|
+
// handler-invoked ctx.use(loader) callbacks, defers) declares
|
|
867
|
+
// handler-layer per-request data. Its mask is a hole only a handler
|
|
868
|
+
// re-run can fill, so the entry must not serve handler-free
|
|
869
|
+
// (ShellCacheEntry.handlerLiveHoles). Still-pending top-level handler
|
|
870
|
+
// pushes at the putShell barrier count too — their liveness is unknowable.
|
|
871
|
+
// - loaderScopedPushValues: DSL-loader pushes re-run fresh on every HIT, so
|
|
872
|
+
// their captured values must NOT enter a segment record's handle snapshot
|
|
873
|
+
// (replay would duplicate the fresh push, and their masked nested
|
|
874
|
+
// promises would stall the Flight handle encode to its timeout). The set
|
|
875
|
+
// rides the derived context (_shellCaptureLoaderHandleValues) and is
|
|
876
|
+
// applied ONLY at the captureHandles cache-write call site — every other
|
|
877
|
+
// getDataForSegment consumer (the render-barrier snapshot, prerender)
|
|
878
|
+
// sees every push.
|
|
879
|
+
const handleLiveness = {
|
|
880
|
+
holes: false,
|
|
881
|
+
pendingPushes: 0,
|
|
882
|
+
handlerInvokedLoader: false,
|
|
883
|
+
};
|
|
884
|
+
const loaderScopedPushValues = new WeakSet<object>();
|
|
767
885
|
const rawCapturePush = freshHandleStore.push.bind(freshHandleStore);
|
|
768
886
|
freshHandleStore.push = (
|
|
769
887
|
handleName: string,
|
|
770
888
|
segmentId: string,
|
|
771
889
|
value: unknown,
|
|
772
890
|
) => {
|
|
773
|
-
const
|
|
774
|
-
|
|
775
|
-
|
|
891
|
+
const pushedInLoaderScope = isInsideLoaderScope();
|
|
892
|
+
// Single walk: the mask reports whether it masked any nested thenable
|
|
893
|
+
// (the liveness declaration) while building the capture copy.
|
|
894
|
+
const maskWithLiveness = (v: unknown): unknown => {
|
|
895
|
+
const report: MaskReport = { thenable: false };
|
|
896
|
+
const masked = maskNestedContainerThenables(v, undefined, report);
|
|
897
|
+
if (!pushedInLoaderScope && report.thenable) {
|
|
898
|
+
handleLiveness.holes = true;
|
|
899
|
+
}
|
|
900
|
+
return masked;
|
|
901
|
+
};
|
|
902
|
+
let masked: unknown;
|
|
903
|
+
if (isThenable(value)) {
|
|
904
|
+
if (!pushedInLoaderScope) {
|
|
905
|
+
handleLiveness.pendingPushes++;
|
|
906
|
+
const settle = () => handleLiveness.pendingPushes--;
|
|
907
|
+
value.then(settle, settle);
|
|
908
|
+
}
|
|
909
|
+
masked = value.then(maskWithLiveness);
|
|
910
|
+
} else {
|
|
911
|
+
masked = maskWithLiveness(value);
|
|
912
|
+
}
|
|
913
|
+
if (pushedInLoaderScope && typeof masked === "object" && masked !== null) {
|
|
914
|
+
loaderScopedPushValues.add(masked);
|
|
915
|
+
}
|
|
776
916
|
rawCapturePush(handleName, segmentId, masked);
|
|
777
917
|
};
|
|
778
918
|
|
|
779
919
|
const derivedCtx: RequestContext = Object.create(reqCtx);
|
|
780
920
|
derivedCtx._handleStore = freshHandleStore;
|
|
921
|
+
// Own render barrier, closure-bound to the derived ctx and the fresh store
|
|
922
|
+
// (issue #684, plan 009). Without this every _renderBarrier* read fell
|
|
923
|
+
// through the prototype to the foreground's ALREADY-RESOLVED barrier: a
|
|
924
|
+
// bake-lane loader's `await ctx.rendered()` resolved instantly and
|
|
925
|
+
// ctx.use(handle) read the FOREGROUND handle snapshot — foreground
|
|
926
|
+
// per-request handle data could bake into the shared shell. wireRenderBarrier
|
|
927
|
+
// also resets _treeHasStreaming (recomputed for the capture's tree) and the
|
|
928
|
+
// deadlock-guard fields as own properties.
|
|
929
|
+
wireRenderBarrier(derivedCtx, freshHandleStore);
|
|
930
|
+
derivedCtx._shellCaptureLoaderHandleValues = loaderScopedPushValues;
|
|
781
931
|
derivedCtx._requestTags = new Set<string>();
|
|
932
|
+
// Own explicit-store registry: cache-store resolutions during the capture
|
|
933
|
+
// (the implicit scope's SnapshotOnlySegmentStore, any per-capture explicit
|
|
934
|
+
// store instance) must NOT register into the handler-lifetime
|
|
935
|
+
// _explicitTaggedStores set — a capture-ephemeral store pinned there would
|
|
936
|
+
// trip the partial-tag-store warning on every later updateTag() and retain
|
|
937
|
+
// the whole capture snapshot in memory. Capture registrations die with this
|
|
938
|
+
// context; module-singleton stores stay registered by normal renders.
|
|
939
|
+
derivedCtx._explicitTaggedStores = new Set();
|
|
782
940
|
derivedCtx._transitionWhen = [];
|
|
783
941
|
derivedCtx._shellCaptureRun = true;
|
|
784
942
|
derivedCtx._metricsStore = undefined;
|
|
@@ -812,6 +970,7 @@ async function attemptCapture(
|
|
|
812
970
|
// forwarding to the parent so the write persists and the worker stays alive),
|
|
813
971
|
// then captureAndStoreShell awaits them before draining. Reads that HIT are
|
|
814
972
|
// recorded synchronously during the render and need none of this.
|
|
973
|
+
derivedCtx._shellCaptureHandleLiveness = handleLiveness;
|
|
815
974
|
if (reqCtx._cacheStore) {
|
|
816
975
|
const recordingStore = new RecordingShellStore(reqCtx._cacheStore);
|
|
817
976
|
derivedCtx._cacheStore = recordingStore;
|
|
@@ -820,43 +979,20 @@ async function attemptCapture(
|
|
|
820
979
|
recordingStore.trackWrite(p);
|
|
821
980
|
reqCtx.waitUntil(() => p);
|
|
822
981
|
};
|
|
982
|
+
// Shell fast path (capture side): the implicit doc-cache scope makes the
|
|
983
|
+
// capture's match write ALL matched non-loader segments as one doc-keyed
|
|
984
|
+
// segment record — into the snapshot only (SnapshotOnlySegmentStore), so
|
|
985
|
+
// the record dies with the shell entry and the next capture's lookup
|
|
986
|
+
// still misses (handlers re-run on recapture). Routes deriving their own
|
|
987
|
+
// cache scope are untouched (resolveShellImplicitCacheScope).
|
|
988
|
+
derivedCtx._shellImplicitCache = {
|
|
989
|
+
ttl: descriptor.ttl,
|
|
990
|
+
swr: descriptor.swr,
|
|
991
|
+
store: new SnapshotOnlySegmentStore(recordingStore),
|
|
992
|
+
};
|
|
823
993
|
}
|
|
824
994
|
|
|
825
|
-
return
|
|
826
|
-
const match = await ctx.router.match(request, { env });
|
|
827
|
-
// A route that redirects has no shell to capture — bail (no store write, no
|
|
828
|
-
// retry: a redirect is deterministic).
|
|
829
|
-
if (match.redirect) return "redirect";
|
|
830
|
-
|
|
831
|
-
setRequestContextParams(match.params, match.routeName);
|
|
832
|
-
|
|
833
|
-
const payload = buildFullPayload(
|
|
834
|
-
match,
|
|
835
|
-
ctx,
|
|
836
|
-
url,
|
|
837
|
-
derivedCtx,
|
|
838
|
-
freshHandleStore,
|
|
839
|
-
);
|
|
840
|
-
const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
|
|
841
|
-
onError: (error: unknown) => {
|
|
842
|
-
ctx.callOnError(error, "rendering", { request, url, env });
|
|
843
|
-
},
|
|
844
|
-
});
|
|
845
|
-
|
|
846
|
-
// Pass the descriptor with its STATIC ppr.tags unchanged. The shell's own
|
|
847
|
-
// render-recorded tags are snapshotted at the putShell WRITE BARRIER inside
|
|
848
|
-
// captureAndStoreShell, not here: a tag recorded AFTER an await in async shell
|
|
849
|
-
// content (and tags propagated by async cache()/"use cache" reads) lands after
|
|
850
|
-
// this synchronous construction point, so snapshotting here dropped it — the
|
|
851
|
-
// shell-tag snapshot must sit behind the quiesce gate (issue #676).
|
|
852
|
-
return captureAndStoreShell(
|
|
853
|
-
ssrModule,
|
|
854
|
-
rscStream,
|
|
855
|
-
freshHandleStore,
|
|
856
|
-
derivedCtx,
|
|
857
|
-
descriptor,
|
|
858
|
-
);
|
|
859
|
-
});
|
|
995
|
+
return { derivedCtx, freshHandleStore };
|
|
860
996
|
}
|
|
861
997
|
|
|
862
998
|
/**
|
|
@@ -1142,6 +1278,14 @@ async function captureAndStoreShell(
|
|
|
1142
1278
|
// ShellCacheEntry.initialTheme.
|
|
1143
1279
|
initialTheme: reqCtx.theme,
|
|
1144
1280
|
snapshot,
|
|
1281
|
+
// Handler-layer liveness folded at the barrier: nested thenables in
|
|
1282
|
+
// handler-scoped pushes, handler pushes still pending (liveness
|
|
1283
|
+
// unknowable), or a handler-invoked loader execution — any of them
|
|
1284
|
+
// refuses the FAST PATH, not the capture. See
|
|
1285
|
+
// _shellCaptureHandleLiveness.
|
|
1286
|
+
handlerLiveHoles: handlerLayerIsLive(
|
|
1287
|
+
reqCtx._shellCaptureHandleLiveness,
|
|
1288
|
+
),
|
|
1145
1289
|
createdAt: Date.now(),
|
|
1146
1290
|
};
|
|
1147
1291
|
await store.putShell(
|
|
@@ -1172,8 +1316,15 @@ async function captureAndStoreShell(
|
|
|
1172
1316
|
}
|
|
1173
1317
|
}
|
|
1174
1318
|
|
|
1175
|
-
// Exported for unit tests that drive the capture core directly
|
|
1176
|
-
|
|
1319
|
+
// Exported for unit tests that drive the capture core directly, and — with the
|
|
1320
|
+
// cold-graph retry pieces — for producer B (prerender/build-shell-capture.ts),
|
|
1321
|
+
// which mirrors the runtime capture's retry-in-place with the same delay.
|
|
1322
|
+
export {
|
|
1323
|
+
runShellCapture,
|
|
1324
|
+
captureAndStoreShell,
|
|
1325
|
+
delay,
|
|
1326
|
+
SHELL_CAPTURE_RETRY_DELAY_MS,
|
|
1327
|
+
};
|
|
1177
1328
|
|
|
1178
1329
|
// Exported for unit tests that pin the refused-capture backoff policy directly
|
|
1179
1330
|
// (dev cap vs production exponential growth, stored-clears, cold-start re-probe).
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Segment Flight-fragment envelopes (PPR fast-path payload splice, issue #700).
|
|
3
|
+
*
|
|
4
|
+
* On a shell-HIT tail, replayed (baked) segments do NOT round-trip through the
|
|
5
|
+
* server codec (deserialize -> re-serialize) per request. The stored RSC
|
|
6
|
+
* fragment STRINGS ride the outgoing payload verbatim inside these envelopes
|
|
7
|
+
* — the outer Flight render copies a string instead of re-serializing a whole
|
|
8
|
+
* element tree — and the CONSUMER expands each envelope through its own Flight
|
|
9
|
+
* deserializer: the SSR resume pass (ssr/ssr-root.tsx) and browser hydration
|
|
10
|
+
* (browser/rsc-router.tsx). Each fragment is its own row space, decoded
|
|
11
|
+
* independently, exactly like the segment codec's per-record decode — so
|
|
12
|
+
* there is no Flight row-id collision or shared-row dedupe hazard.
|
|
13
|
+
*
|
|
14
|
+
* Envelopes appear ONLY on fields that hold ReactNodes (component / layout /
|
|
15
|
+
* loading). A plain object is never a valid ReactNode, so the marker cannot
|
|
16
|
+
* collide with legitimate segment content; loader data fields (consumer data,
|
|
17
|
+
* any shape) are never enveloped.
|
|
18
|
+
*
|
|
19
|
+
* This module is shared client/server code (browser + SSR + RSC import it) —
|
|
20
|
+
* it must stay dependency-free: no plugin-rsc, no request-context.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { ResolvedSegment } from "./types.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One RSC-encoded fragment traveling inside an RscPayload segment field.
|
|
27
|
+
* `f` is the stored Flight document (segment-codec output) for that field.
|
|
28
|
+
*/
|
|
29
|
+
export interface SegmentFlightFragment {
|
|
30
|
+
__rangoFragment: 1;
|
|
31
|
+
f: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Wrap a stored fragment string for the wire. */
|
|
35
|
+
export function segmentFragment(encoded: string): SegmentFlightFragment {
|
|
36
|
+
return { __rangoFragment: 1, f: encoded };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** True iff `value` is a fragment envelope (see collision note in the header). */
|
|
40
|
+
export function isSegmentFragment(
|
|
41
|
+
value: unknown,
|
|
42
|
+
): value is SegmentFlightFragment {
|
|
43
|
+
return (
|
|
44
|
+
typeof value === "object" &&
|
|
45
|
+
value !== null &&
|
|
46
|
+
(value as { __rangoFragment?: unknown }).__rangoFragment === 1 &&
|
|
47
|
+
typeof (value as { f?: unknown }).f === "string"
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A consumer-side Flight deserializer: the browser's or the SSR runtime's
|
|
53
|
+
* createFromReadableStream (each resolves client references through its own
|
|
54
|
+
* module map, exactly as it does for the outer payload stream). Non-generic
|
|
55
|
+
* so both environments' generic createFromReadableStream signatures assign
|
|
56
|
+
* directly.
|
|
57
|
+
*/
|
|
58
|
+
export type FragmentDecoder = (
|
|
59
|
+
stream: ReadableStream<Uint8Array>,
|
|
60
|
+
) => Promise<unknown>;
|
|
61
|
+
|
|
62
|
+
/** One-chunk byte stream over an encoded fragment (local: this module must not
|
|
63
|
+
* import segment-codec, which pulls plugin-rsc into client bundles). */
|
|
64
|
+
function fragmentToStream(encoded: string): ReadableStream<Uint8Array> {
|
|
65
|
+
const bytes = new TextEncoder().encode(encoded);
|
|
66
|
+
return new ReadableStream<Uint8Array>({
|
|
67
|
+
start(controller) {
|
|
68
|
+
controller.enqueue(bytes);
|
|
69
|
+
controller.close();
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The segment fields that may carry an envelope (ReactNode fields only). */
|
|
75
|
+
const FRAGMENT_FIELDS = ["component", "layout", "loading"] as const;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Expand every fragment envelope in `segments` IN PLACE via `decode`,
|
|
79
|
+
* in parallel. A payload with no envelopes (every non-shell-HIT payload) costs
|
|
80
|
+
* one synchronous field scan. A failing fragment decode rejects the whole
|
|
81
|
+
* expansion: the caller's payload promise rejects, which on the SSR side
|
|
82
|
+
* errors the HIT tail (serveShellHit then schedules the healing recapture) —
|
|
83
|
+
* the same posture as a parseable-but-mismatched postponed blob.
|
|
84
|
+
*/
|
|
85
|
+
export async function expandSegmentFragments(
|
|
86
|
+
segments: ResolvedSegment[] | undefined,
|
|
87
|
+
decode: FragmentDecoder,
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
if (!segments || segments.length === 0) return;
|
|
90
|
+
const decodes: Promise<void>[] = [];
|
|
91
|
+
for (const segment of segments) {
|
|
92
|
+
for (const field of FRAGMENT_FIELDS) {
|
|
93
|
+
const value = segment[field];
|
|
94
|
+
if (isSegmentFragment(value)) {
|
|
95
|
+
// Promise.resolve() adoption is load-bearing: some deserializers
|
|
96
|
+
// (react-server-dom's Flight client) return a THENABLE Chunk whose
|
|
97
|
+
// .then returns undefined — chaining directly would push undefined
|
|
98
|
+
// into the barrier and Promise.all would not wait for the decode.
|
|
99
|
+
decodes.push(
|
|
100
|
+
Promise.resolve(decode(fragmentToStream(value.f))).then((node) => {
|
|
101
|
+
segment[field] = node as ResolvedSegment[typeof field];
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (decodes.length > 0) await Promise.all(decodes);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* True when any segment carries an unexpanded envelope. Cheap scan used by
|
|
112
|
+
* consumers that only need to know whether an expansion pass is required.
|
|
113
|
+
*/
|
|
114
|
+
export function hasSegmentFragments(
|
|
115
|
+
segments: ResolvedSegment[] | undefined,
|
|
116
|
+
): boolean {
|
|
117
|
+
if (!segments) return false;
|
|
118
|
+
for (const segment of segments) {
|
|
119
|
+
for (const field of FRAGMENT_FIELDS) {
|
|
120
|
+
if (isSegmentFragment(segment[field])) return true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
package/src/segment-system.tsx
CHANGED
|
@@ -18,14 +18,20 @@ import {
|
|
|
18
18
|
} from "./segment-loader-promise.js";
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
21
|
+
* Debug log for the segment tree build, gated on the baked flag. Runs on BOTH
|
|
22
|
+
* sides now, environment-tagged: `[Browser][segments]` lines up with the
|
|
23
|
+
* `[Browser][boot]` sequence around hydrateRoot; `[Server][segments]` exposes
|
|
24
|
+
* the SSR/RSC tree-build stalls (blocking loader awaits during fizz are what
|
|
25
|
+
* dominate MISS TTFB) that used to be invisible because the logs were
|
|
26
|
+
* window-gated. Server lines have no request correlation — segment-system is
|
|
27
|
+
* shared client code and cannot import request-context (node:async_hooks
|
|
28
|
+
* would enter the browser bundle) — so on a busy server, correlate by
|
|
29
|
+
* timestamp + segment ids.
|
|
25
30
|
*/
|
|
26
31
|
function segDebugLog(msg: string, details?: Record<string, unknown>): void {
|
|
27
|
-
if (!
|
|
28
|
-
const
|
|
32
|
+
if (!INTERNAL_RANGO_DEBUG) return;
|
|
33
|
+
const env = typeof window === "object" ? "[Browser]" : "[Server]";
|
|
34
|
+
const prefix = `${env}[segments] ${msg} @ ${Math.round(performance.now())}ms`;
|
|
29
35
|
if (details) {
|
|
30
36
|
console.log(prefix, details);
|
|
31
37
|
return;
|
|
@@ -228,7 +234,7 @@ export async function renderSegments(
|
|
|
228
234
|
rootLayout: RootLayout,
|
|
229
235
|
} = options || {};
|
|
230
236
|
|
|
231
|
-
const segDebug = INTERNAL_RANGO_DEBUG
|
|
237
|
+
const segDebug = INTERNAL_RANGO_DEBUG;
|
|
232
238
|
const segDebugStart = segDebug ? performance.now() : 0;
|
|
233
239
|
if (segDebug) {
|
|
234
240
|
segDebugLog("renderSegments start", {
|
|
@@ -299,15 +305,7 @@ export async function renderSegments(
|
|
|
299
305
|
`Expected layout, route, error, or notFound segment, got ${node.segment.type}`,
|
|
300
306
|
);
|
|
301
307
|
const { component, id, params, loading } = node.segment;
|
|
302
|
-
|
|
303
|
-
if (segDebug) {
|
|
304
|
-
segDebugLog(`segment ${id}`, {
|
|
305
|
-
type: node.segment.type,
|
|
306
|
-
loaders: node.loaders.map((l) => l.loaderId).filter(Boolean),
|
|
307
|
-
hasLoading: loading !== undefined && loading !== null,
|
|
308
|
-
parallel: node.parallel.map((p) => p.id),
|
|
309
|
-
});
|
|
310
|
-
}
|
|
308
|
+
const segNodeStart = segDebug ? performance.now() : 0;
|
|
311
309
|
|
|
312
310
|
// Param-agnostic keys are opt-in via the transition() DSL (see
|
|
313
311
|
// inTransitionScope above). A route (and its route-owned layouts) inside a
|
|
@@ -350,7 +348,13 @@ export async function renderSegments(
|
|
|
350
348
|
|
|
351
349
|
let resolvedComponent = component;
|
|
352
350
|
if (isAction && component instanceof Promise) {
|
|
351
|
+
const componentAwaitStart = segDebug ? performance.now() : 0;
|
|
353
352
|
resolvedComponent = await component;
|
|
353
|
+
if (segDebug) {
|
|
354
|
+
segDebugLog(`segment ${id}: component awaited (action)`, {
|
|
355
|
+
ms: Math.round(performance.now() - componentAwaitStart),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
354
358
|
}
|
|
355
359
|
|
|
356
360
|
let nodeContent: ReactNode = null;
|
|
@@ -367,9 +371,16 @@ export async function renderSegments(
|
|
|
367
371
|
// suspends on mount inside the content still reveals a fallback (it is not
|
|
368
372
|
// pre-resolved).
|
|
369
373
|
const contentPromise = getMemoizedContentPromise(resolvedComponent);
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
:
|
|
374
|
+
let loadingContent: Promise<ReactNode> | ReactNode = contentPromise;
|
|
375
|
+
if (forceAwait) {
|
|
376
|
+
const contentAwaitStart = segDebug ? performance.now() : 0;
|
|
377
|
+
loadingContent = await contentPromise;
|
|
378
|
+
if (segDebug) {
|
|
379
|
+
segDebugLog(`segment ${id}: content awaited (forceAwait)`, {
|
|
380
|
+
ms: Math.round(performance.now() - contentAwaitStart),
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
373
384
|
nodeContent = createElement(RouteContentWrapper, {
|
|
374
385
|
key: `suspense-loading-${id}`,
|
|
375
386
|
content: loadingContent,
|
|
@@ -492,10 +503,19 @@ export async function renderSegments(
|
|
|
492
503
|
ms: Math.round(performance.now() - layoutAwaitStart),
|
|
493
504
|
});
|
|
494
505
|
}
|
|
506
|
+
const decodeStart = segDebug ? performance.now() : 0;
|
|
495
507
|
const { loaderData, errorFallback } = decodeLoaderResults(
|
|
496
508
|
resolvedData,
|
|
497
509
|
layoutLoaderIds,
|
|
498
510
|
);
|
|
511
|
+
if (segDebug) {
|
|
512
|
+
const decodeMs = Math.round(performance.now() - decodeStart);
|
|
513
|
+
if (decodeMs > 0) {
|
|
514
|
+
segDebugLog(`segment ${id}: loader results decoded`, {
|
|
515
|
+
ms: decodeMs,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
499
519
|
|
|
500
520
|
if (parallelOwnedLoaders.length > 0) {
|
|
501
521
|
const loadersByParallelNamespace = new Map<string, ResolvedSegment[]>();
|
|
@@ -568,6 +588,16 @@ export async function renderSegments(
|
|
|
568
588
|
children: content,
|
|
569
589
|
});
|
|
570
590
|
}
|
|
591
|
+
|
|
592
|
+
if (segDebug) {
|
|
593
|
+
segDebugLog(`segment ${id} built`, {
|
|
594
|
+
type: node.segment.type,
|
|
595
|
+
ms: Math.round(performance.now() - segNodeStart),
|
|
596
|
+
loaders: node.loaders.map((l) => l.loaderId).filter(Boolean),
|
|
597
|
+
hasLoading: loading !== undefined && loading !== null,
|
|
598
|
+
parallel: node.parallel.map((p) => p.id),
|
|
599
|
+
});
|
|
600
|
+
}
|
|
571
601
|
}
|
|
572
602
|
|
|
573
603
|
const errorBoundaryWrapped = createElement(RootErrorBoundary, {
|