@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.
Files changed (46) hide show
  1. package/dist/bin/rango.js +8 -40
  2. package/dist/vite/index.js +840 -243
  3. package/package.json +6 -1
  4. package/src/browser/event-controller.ts +16 -2
  5. package/src/browser/rsc-router.tsx +11 -0
  6. package/src/cache/cache-scope.ts +41 -3
  7. package/src/cache/cf/cf-cache-store.ts +23 -0
  8. package/src/cache/handle-snapshot.ts +22 -1
  9. package/src/cache/memory-segment-store.ts +32 -0
  10. package/src/cache/segment-codec.ts +47 -0
  11. package/src/cache/shell-snapshot.ts +47 -0
  12. package/src/cache/types.ts +27 -0
  13. package/src/cache/vercel/vercel-cache-store.ts +71 -2
  14. package/src/deps/ssr.ts +4 -1
  15. package/src/prerender/build-shell-capture.ts +237 -0
  16. package/src/prerender/shell-manifest-key.ts +20 -0
  17. package/src/prerender/store.ts +10 -1
  18. package/src/router/loader-resolution.ts +16 -0
  19. package/src/router/match-api.ts +9 -2
  20. package/src/router/match-handlers.ts +13 -0
  21. package/src/router/match-middleware/cache-lookup.ts +12 -1
  22. package/src/router/prerender-match.ts +21 -0
  23. package/src/router/segment-resolution/mask-nested.ts +19 -3
  24. package/src/rsc/capture-queue.ts +67 -0
  25. package/src/rsc/rsc-rendering.ts +136 -25
  26. package/src/rsc/shell-build-manifest.ts +244 -0
  27. package/src/rsc/shell-capture.ts +194 -43
  28. package/src/segment-fragments.ts +124 -0
  29. package/src/segment-system.tsx +49 -19
  30. package/src/server/request-context.ts +112 -11
  31. package/src/ssr/index.tsx +151 -22
  32. package/src/ssr/inject-rsc-eager.ts +2 -2
  33. package/src/ssr/preinit-client-references.ts +106 -0
  34. package/src/ssr/ssr-root.tsx +35 -2
  35. package/src/vite/discovery/discover-routers.ts +27 -0
  36. package/src/vite/discovery/prerender-collection.ts +16 -0
  37. package/src/vite/discovery/shell-prerender-phase.ts +395 -0
  38. package/src/vite/discovery/state.ts +42 -0
  39. package/src/vite/index.ts +1 -0
  40. package/src/vite/plugin-types.ts +33 -0
  41. package/src/vite/plugins/version-plugin.ts +8 -0
  42. package/src/vite/plugins/virtual-entries.ts +37 -4
  43. package/src/vite/rango.ts +11 -2
  44. package/src/vite/router-discovery.ts +292 -8
  45. package/src/vite/utils/prerender-utils.ts +25 -6
  46. package/src/vite/utils/shared-utils.ts +4 -2
@@ -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 { maskNestedContainerThenables } from "../router/segment-resolution/mask-nested.js";
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
- (captureTask as { [UNTRACKED_BACKGROUND_TASK]?: boolean })[
619
+ (serializedTask as { [UNTRACKED_BACKGROUND_TASK]?: boolean })[
607
620
  UNTRACKED_BACKGROUND_TASK
608
621
  ] = true;
609
- runBackground(reqCtx, captureTask);
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 masked = isThenable(value)
774
- ? value.then((v: unknown) => maskNestedContainerThenables(v))
775
- : maskNestedContainerThenables(value);
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 runWithRequestContext(derivedCtx, async () => {
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
- export { runShellCapture, captureAndStoreShell };
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
+ }
@@ -18,14 +18,20 @@ import {
18
18
  } from "./segment-loader-promise.js";
19
19
 
20
20
  /**
21
- * Client-only debug log for the segment tree build. Gated on the baked flag
22
- * AND `typeof window` (renderSegments also runs during SSR/RSC, which must
23
- * stay silent). Timestamped so tree-build steps line up with the
24
- * `[Browser][boot]` sequence around hydrateRoot.
21
+ * Debug log for the segment tree build, gated on the baked flag. Runs on BOTH
22
+ * sides now, environment-tagged: `[Browser][segments]` lines up with the
23
+ * `[Browser][boot]` sequence around hydrateRoot; `[Server][segments]` exposes
24
+ * the SSR/RSC tree-build stalls (blocking loader awaits during fizz are what
25
+ * dominate MISS TTFB) that used to be invisible because the logs were
26
+ * window-gated. Server lines have no request correlation — segment-system is
27
+ * shared client code and cannot import request-context (node:async_hooks
28
+ * would enter the browser bundle) — so on a busy server, correlate by
29
+ * timestamp + segment ids.
25
30
  */
26
31
  function segDebugLog(msg: string, details?: Record<string, unknown>): void {
27
- if (!(INTERNAL_RANGO_DEBUG && typeof window === "object")) return;
28
- const prefix = `[Browser][segments] ${msg} @ ${Math.round(performance.now())}ms`;
32
+ if (!INTERNAL_RANGO_DEBUG) return;
33
+ const env = typeof window === "object" ? "[Browser]" : "[Server]";
34
+ const prefix = `${env}[segments] ${msg} @ ${Math.round(performance.now())}ms`;
29
35
  if (details) {
30
36
  console.log(prefix, details);
31
37
  return;
@@ -228,7 +234,7 @@ export async function renderSegments(
228
234
  rootLayout: RootLayout,
229
235
  } = options || {};
230
236
 
231
- const segDebug = INTERNAL_RANGO_DEBUG && typeof window === "object";
237
+ const segDebug = INTERNAL_RANGO_DEBUG;
232
238
  const segDebugStart = segDebug ? performance.now() : 0;
233
239
  if (segDebug) {
234
240
  segDebugLog("renderSegments start", {
@@ -299,15 +305,7 @@ export async function renderSegments(
299
305
  `Expected layout, route, error, or notFound segment, got ${node.segment.type}`,
300
306
  );
301
307
  const { component, id, params, loading } = node.segment;
302
-
303
- if (segDebug) {
304
- segDebugLog(`segment ${id}`, {
305
- type: node.segment.type,
306
- loaders: node.loaders.map((l) => l.loaderId).filter(Boolean),
307
- hasLoading: loading !== undefined && loading !== null,
308
- parallel: node.parallel.map((p) => p.id),
309
- });
310
- }
308
+ const segNodeStart = segDebug ? performance.now() : 0;
311
309
 
312
310
  // Param-agnostic keys are opt-in via the transition() DSL (see
313
311
  // inTransitionScope above). A route (and its route-owned layouts) inside a
@@ -350,7 +348,13 @@ export async function renderSegments(
350
348
 
351
349
  let resolvedComponent = component;
352
350
  if (isAction && component instanceof Promise) {
351
+ const componentAwaitStart = segDebug ? performance.now() : 0;
353
352
  resolvedComponent = await component;
353
+ if (segDebug) {
354
+ segDebugLog(`segment ${id}: component awaited (action)`, {
355
+ ms: Math.round(performance.now() - componentAwaitStart),
356
+ });
357
+ }
354
358
  }
355
359
 
356
360
  let nodeContent: ReactNode = null;
@@ -367,9 +371,16 @@ export async function renderSegments(
367
371
  // suspends on mount inside the content still reveals a fallback (it is not
368
372
  // pre-resolved).
369
373
  const contentPromise = getMemoizedContentPromise(resolvedComponent);
370
- const loadingContent: Promise<ReactNode> | ReactNode = forceAwait
371
- ? await contentPromise
372
- : contentPromise;
374
+ let loadingContent: Promise<ReactNode> | ReactNode = contentPromise;
375
+ if (forceAwait) {
376
+ const contentAwaitStart = segDebug ? performance.now() : 0;
377
+ loadingContent = await contentPromise;
378
+ if (segDebug) {
379
+ segDebugLog(`segment ${id}: content awaited (forceAwait)`, {
380
+ ms: Math.round(performance.now() - contentAwaitStart),
381
+ });
382
+ }
383
+ }
373
384
  nodeContent = createElement(RouteContentWrapper, {
374
385
  key: `suspense-loading-${id}`,
375
386
  content: loadingContent,
@@ -492,10 +503,19 @@ export async function renderSegments(
492
503
  ms: Math.round(performance.now() - layoutAwaitStart),
493
504
  });
494
505
  }
506
+ const decodeStart = segDebug ? performance.now() : 0;
495
507
  const { loaderData, errorFallback } = decodeLoaderResults(
496
508
  resolvedData,
497
509
  layoutLoaderIds,
498
510
  );
511
+ if (segDebug) {
512
+ const decodeMs = Math.round(performance.now() - decodeStart);
513
+ if (decodeMs > 0) {
514
+ segDebugLog(`segment ${id}: loader results decoded`, {
515
+ ms: decodeMs,
516
+ });
517
+ }
518
+ }
499
519
 
500
520
  if (parallelOwnedLoaders.length > 0) {
501
521
  const loadersByParallelNamespace = new Map<string, ResolvedSegment[]>();
@@ -568,6 +588,16 @@ export async function renderSegments(
568
588
  children: content,
569
589
  });
570
590
  }
591
+
592
+ if (segDebug) {
593
+ segDebugLog(`segment ${id} built`, {
594
+ type: node.segment.type,
595
+ ms: Math.round(performance.now() - segNodeStart),
596
+ loaders: node.loaders.map((l) => l.loaderId).filter(Boolean),
597
+ hasLoading: loading !== undefined && loading !== null,
598
+ parallel: node.parallel.map((p) => p.id),
599
+ });
600
+ }
571
601
  }
572
602
 
573
603
  const errorBoundaryWrapped = createElement(RootErrorBoundary, {