@rangojs/router 0.0.0-experimental.142 → 0.0.0-experimental.144

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 (55) hide show
  1. package/dist/vite/index.js +25 -6
  2. package/package.json +4 -2
  3. package/skills/cache-guide/SKILL.md +3 -1
  4. package/skills/caching/SKILL.md +41 -2
  5. package/skills/catalog.json +6 -0
  6. package/skills/composability/SKILL.md +32 -0
  7. package/skills/defer-hydration/SKILL.md +235 -0
  8. package/skills/loader/SKILL.md +5 -0
  9. package/skills/migrate-nextjs/SKILL.md +4 -2
  10. package/skills/observability/SKILL.md +8 -0
  11. package/skills/parallel/SKILL.md +4 -0
  12. package/skills/ppr/SKILL.md +110 -20
  13. package/skills/rango/SKILL.md +10 -0
  14. package/skills/route/SKILL.md +8 -0
  15. package/skills/typesafety/SKILL.md +1 -0
  16. package/skills/typesafety/generated-files-and-cli.md +30 -0
  17. package/skills/use-cache/SKILL.md +12 -2
  18. package/src/browser/partial-update.ts +7 -0
  19. package/src/cache/cache-key-utils.ts +29 -0
  20. package/src/cache/cache-scope.ts +2 -17
  21. package/src/cache/cache-tag.ts +60 -14
  22. package/src/cache/cf/cf-cache-store.ts +54 -20
  23. package/src/cache/document-cache.ts +17 -11
  24. package/src/cache/vercel/vercel-cache-store.ts +9 -19
  25. package/src/cloudflare/tracing.ts +7 -8
  26. package/src/index.rsc.ts +1 -0
  27. package/src/index.ts +12 -8
  28. package/src/redirect-origin.ts +14 -0
  29. package/src/route-definition/helpers-types.ts +5 -4
  30. package/src/route-map-builder.ts +41 -4
  31. package/src/router/find-match.ts +15 -1
  32. package/src/router/instrument.ts +9 -4
  33. package/src/router/lazy-includes.ts +8 -2
  34. package/src/router/loader-resolution.ts +14 -2
  35. package/src/router/match-handlers.ts +175 -133
  36. package/src/router/middleware.ts +40 -30
  37. package/src/router/router-interfaces.ts +9 -0
  38. package/src/router/segment-resolution/loader-snapshot.ts +98 -17
  39. package/src/router/telemetry-otel.ts +6 -8
  40. package/src/router/telemetry.ts +9 -1
  41. package/src/router/tracing.ts +14 -5
  42. package/src/router.ts +22 -14
  43. package/src/rsc/handler.ts +55 -32
  44. package/src/rsc/redirect-guard.ts +2 -1
  45. package/src/rsc/rsc-rendering.ts +35 -2
  46. package/src/rsc/shell-capture.ts +98 -20
  47. package/src/server/context.ts +47 -9
  48. package/src/server/cookie-store.ts +26 -5
  49. package/src/server/request-context.ts +22 -0
  50. package/src/ssr/index.tsx +145 -107
  51. package/src/testing/dispatch.ts +149 -37
  52. package/src/urls/path-helper-types.ts +9 -4
  53. package/src/vercel/tracing.ts +7 -7
  54. package/src/vite/inject-client-debug.ts +64 -12
  55. package/src/vite/router-discovery.ts +9 -1
@@ -327,6 +327,39 @@ function warnCaptureRefusedOnce(key: string, reason: string): void {
327
327
  );
328
328
  }
329
329
 
330
+ /** Keys already warned about untagged bake-lane data baked into the shell. */
331
+ const warnedUntaggedShellBakes = new Set<string>();
332
+
333
+ /**
334
+ * Warn once per shell key that a bake-lane loader baked material into the shell
335
+ * but the capture recorded ZERO tags (no render-collected _requestTags, no
336
+ * static ppr.tags). Such data is frozen in the shared shell until TTL and is
337
+ * un-evictable by tag: a server action refreshes the CLIENT only (rotates Rango
338
+ * state, busts the browser HTTP cache) and never touches the server shell store,
339
+ * and updateTag()/revalidateTag() cannot drop data that was baked WITHOUT a tag.
340
+ *
341
+ * Coarse per-shell-key signal, not per-loader attribution — the tag set is
342
+ * unioned globally at the write barrier, not tracked per loader, so we cannot
343
+ * name the offending loader without adding attribution plumbing (deliberately
344
+ * not done). Dev-only + once-per-key so it never spams production or fires on
345
+ * every capture.
346
+ */
347
+ function warnUntaggedShellBakeOnce(key: string): void {
348
+ if (warnedUntaggedShellBakes.has(key)) return;
349
+ warnedUntaggedShellBakes.add(key);
350
+ console.warn(
351
+ `[rango] Shell capture for "${key}" baked bake-lane loader data into the ` +
352
+ "shell with NO cache tag. That data is frozen in the shared shell until " +
353
+ "TTL and cannot be tag-invalidated: a server action refresh touches the " +
354
+ "client only (not the server shell store), and updateTag() cannot evict " +
355
+ "data that was baked without a tag.\n" +
356
+ 'Fix: tag the data (cacheTag() / "use cache" / cache({ tags })) so ' +
357
+ "updateTag() drops the shell, or move the volatile read under a loading() " +
358
+ "hole so it stays on the live lane and is never baked. See the /ppr skill " +
359
+ "(node_modules/@rangojs/router/skills/ppr/SKILL.md).",
360
+ );
361
+ }
362
+
330
363
  export interface FlightCaptureGate {
331
364
  /** Identity passthrough of the source stream; feed this to captureShellHTML. */
332
365
  stream: ReadableStream<Uint8Array>;
@@ -487,9 +520,12 @@ export function gateFlightForCapture(
487
520
  * store, and passed to scheduleShellCapture directly — it is NOT threaded through
488
521
  * the request context. `tags` carries the route's OPERATIONAL `ppr.tags`; the
489
522
  * capture UNIONS them with the shell's own auto-collected (non-loader) request
490
- * tags from its derived render (the collected set stays authoritative). `store`
491
- * is the same store the serve path resolved for its getShell read
492
- * (requestCtx._cacheStore), so the capture writes where the serve reads.
523
+ * tags from its derived render (the collected set stays authoritative). That
524
+ * union happens at the putShell WRITE BARRIER in captureAndStoreShell — after the
525
+ * capture quiesces — not at stream construction, so a tag recorded after an await
526
+ * in async shell content is still collected (issue #676). `store` is the same
527
+ * store the serve path resolved for its getShell read (requestCtx._cacheStore),
528
+ * so the capture writes where the serve reads.
493
529
  */
494
530
  export interface ShellCaptureDescriptor {
495
531
  key: string;
@@ -715,6 +751,11 @@ async function attemptCapture(
715
751
  derivedCtx._transitionWhen = [];
716
752
  derivedCtx._shellCaptureRun = true;
717
753
  derivedCtx._metricsStore = undefined;
754
+ // Spans, like perf metrics above, are a FOREGROUND surface: the capture
755
+ // re-render must not emit a second rango.render/loader/ssr set after the
756
+ // foreground rango.request span ended (orphan spans in the trace).
757
+ // _tracing is otherwise inherited through Object.create(reqCtx).
758
+ derivedCtx._tracing = undefined;
718
759
  // Bake-lane loader containers (loaders on entries with no renderable
719
760
  // loading() execute during capture — docs/design/loader-container-bake.md).
720
761
  // resolveLoaderData registers each container promise here; the drain in
@@ -771,24 +812,18 @@ async function attemptCapture(
771
812
  },
772
813
  });
773
814
 
774
- // Shell tags = the non-loader request tags the capture render recorded on its
775
- // own fresh _requestTags (loaders are masked, so loader cache tags — which
776
- // belong to the holes, not the shell — are correctly excluded), UNIONED with
777
- // the middleware's operational `tags` option (descriptor.tags). The collected
778
- // set is authoritative; the option only adds tags the render cannot know.
779
- const collected = [...derivedCtx._requestTags];
780
- const union = new Set<string>([...(descriptor.tags ?? []), ...collected]);
781
- const tags = union.size > 0 ? [...union] : undefined;
782
-
815
+ // Pass the descriptor with its STATIC ppr.tags unchanged. The shell's own
816
+ // render-recorded tags are snapshotted at the putShell WRITE BARRIER inside
817
+ // captureAndStoreShell, not here: a tag recorded AFTER an await in async shell
818
+ // content (and tags propagated by async cache()/"use cache" reads) lands after
819
+ // this synchronous construction point, so snapshotting here dropped it — the
820
+ // shell-tag snapshot must sit behind the quiesce gate (issue #676).
783
821
  return captureAndStoreShell(
784
822
  ssrModule,
785
823
  rscStream,
786
824
  freshHandleStore,
787
825
  derivedCtx,
788
- {
789
- ...descriptor,
790
- tags,
791
- },
826
+ descriptor,
792
827
  );
793
828
  });
794
829
  }
@@ -873,11 +908,25 @@ async function captureAndStoreShell(
873
908
  const refuseOnGuardTrip = (): "refused" | undefined => {
874
909
  const fnName = reqCtx._shellCaptureGuardTripped;
875
910
  if (!fnName) return undefined;
911
+ // Name the recorded source instead of hardcoding a lane. Under the
912
+ // consumption-lane rule, handler-INVOKED loader bodies are exempt from
913
+ // the guard (their value is a baked shared copy, mirroring cache()), so a
914
+ // trip can only come from a bake-lane SEGMENT loader or from non-loader
915
+ // handler/render code — the old blanket "bake-lane loader" attribution
916
+ // sent a live-lane debugging session down the wrong lane (issue #672).
917
+ const loaderId = reqCtx._shellCaptureGuardTrippedLoaderId;
918
+ const origin = loaderId
919
+ ? `segment loader "${loaderId}"`
920
+ : "handler/render code (no loader body was executing)";
876
921
  warnCaptureRefusedOnce(
877
922
  capture.key,
878
- `a bake-lane loader (a loader on an entry with no loading()) called ${fnName}() during capture. ` +
879
- "Identity must not bake into a shared shell. Give that loader's entry a loading() boundary " +
880
- "(the live lane, exempt from the guard) or move the identity-dependent part into a nested promise.",
923
+ `${origin} called ${fnName}() during capture. Identity must not bake into a shared shell. ` +
924
+ "For a segment loader (bake lane, no loading()): give its entry a loading() boundary " +
925
+ "(the live lane, masked at capture) or move the identity-dependent part into a nested " +
926
+ "promise. For handler/render code: keep the value live by consuming a loader " +
927
+ 'client-side (useLoader in a "use client" component). Note: `await ctx.use(loader)` ' +
928
+ "inside a HANDLER is exempt from this guard — its value bakes into the shared shell " +
929
+ "as a capture-time copy, mirroring cache() semantics (the consumption-lane rule).",
881
930
  );
882
931
  return "refused";
883
932
  };
@@ -976,6 +1025,10 @@ async function captureAndStoreShell(
976
1025
  // gate above already returned no-shell) or postponed under an ANCESTOR
977
1026
  // boundary (it is a hole; omitting the record keeps it live).
978
1027
  const loaderRecords = reqCtx._shellCaptureLoaderRecords;
1028
+ // Set once a bake-lane loader settles with real (non-hole) material: its data
1029
+ // is frozen into the shell prelude regardless of whether snapshot
1030
+ // serialization succeeds. Drives the untagged-bake dev warning below.
1031
+ let bakedLoaderMaterial = false;
979
1032
  if (loaderRecords && loaderRecords.size > 0) {
980
1033
  // The codec import is deferred past the elide probes: a rejected record
981
1034
  // refuses and a never-settled record is omitted WITHOUT touching Flight
@@ -996,6 +1049,9 @@ async function captureAndStoreShell(
996
1049
  // The container itself never settled: it is a hole (under an ancestor
997
1050
  // boundary) or the trivial-prelude gate already fired. Omit — no pin.
998
1051
  if (isLoaderHoleMarker(elided.value)) continue;
1052
+ // Past the hole check: this container settled with real material that
1053
+ // bakes into the shell prelude (independent of the snapshot pin below).
1054
+ bakedLoaderMaterial = true;
999
1055
  try {
1000
1056
  // serializeResult (not rscSerialize): null is a valid container and
1001
1057
  // must round-trip; serializeResult preserves it through Flight.
@@ -1016,6 +1072,28 @@ async function captureAndStoreShell(
1016
1072
  }
1017
1073
  }
1018
1074
 
1075
+ // Shell tags snapshot at the WRITE BARRIER, not at stream construction: by
1076
+ // here the capture has quiesced and the deferred cache writes were awaited, so
1077
+ // tags recorded AFTER an await in async shell content (and by async
1078
+ // cache()/"use cache" reads propagating through recordRequestTags) are
1079
+ // included — issue #676. Loaders are masked, so loader cache tags — which
1080
+ // belong to the holes, not the shell — never execute during capture and cannot
1081
+ // contribute. Union with the route's static ppr.tags (capture.tags); the
1082
+ // collected set is authoritative, the option only adds what the render cannot
1083
+ // know.
1084
+ const collected = [...reqCtx._requestTags];
1085
+ const union = new Set<string>([...(capture.tags ?? []), ...collected]);
1086
+ const shellTags = union.size > 0 ? [...union] : undefined;
1087
+
1088
+ // Untagged-bake diagnostic: a bake-lane loader froze mutable data into the
1089
+ // shell but nothing tags the entry, so it is un-invalidatable except by TTL —
1090
+ // a read-your-own-writes gap on the document channel (an action refresh skips
1091
+ // the server shell; updateTag cannot drop untagged data). Coarse per-shell-key
1092
+ // signal; dev-only and once-per-key so it never spams production.
1093
+ if (isDevMode() && bakedLoaderMaterial && shellTags === undefined) {
1094
+ warnUntaggedShellBakeOnce(capture.key);
1095
+ }
1096
+
1019
1097
  const store = capture.store ?? reqCtx._cacheStore;
1020
1098
  if (store?.putShell) {
1021
1099
  try {
@@ -1039,7 +1117,7 @@ async function captureAndStoreShell(
1039
1117
  entry,
1040
1118
  capture.ttl,
1041
1119
  capture.swr,
1042
- capture.tags,
1120
+ shellTags,
1043
1121
  );
1044
1122
  } catch (error) {
1045
1123
  // Best-effort: a failed put must never throw out of the background task.
@@ -770,14 +770,23 @@ const loaderScopeALS: AsyncLocalStorage<{ active: true }> = ((
770
770
 
771
771
  // Purity-only scope: marks that a loader FUNCTION BODY is executing, regardless
772
772
  // of how the loader was invoked (DSL via runInsideLoaderScope, or handler-
773
- // invoked via ctx.use). Consulted ONLY by isInsideCacheScope() to exempt
774
- // request-scoped reads. It deliberately does NOT affect isInsideLoaderScope(),
775
- // so rendered()/barrier/deadlock gating (which must distinguish DSL from
776
- // handler-invoked loaders) is unchanged.
773
+ // invoked via ctx.use). Consulted by isInsideCacheScope() to exempt
774
+ // request-scoped reads, by getCurrentLoaderBodyId() for guard-warning
775
+ // attribution, and by isInsideHandlerInvokedLoaderBody() for the
776
+ // consumption-lane rule (the shell-capture guard exemption). It deliberately
777
+ // does NOT affect isInsideLoaderScope(), so rendered()/barrier/deadlock
778
+ // gating (which must distinguish DSL from handler-invoked loaders) is
779
+ // unchanged.
777
780
  const LOADER_BODY_SCOPE_KEY = Symbol.for("rangojs-router:loader-body-scope");
778
- const loaderBodyScopeALS: AsyncLocalStorage<{ active: true }> = ((
779
- globalThis as any
780
- )[LOADER_BODY_SCOPE_KEY] ??= new AsyncLocalStorage<{ active: true }>());
781
+ const loaderBodyScopeALS: AsyncLocalStorage<{
782
+ active: true;
783
+ loaderId?: string;
784
+ handlerInvoked?: boolean;
785
+ }> = ((globalThis as any)[LOADER_BODY_SCOPE_KEY] ??= new AsyncLocalStorage<{
786
+ active: true;
787
+ loaderId?: string;
788
+ handlerInvoked?: boolean;
789
+ }>());
781
790
 
782
791
  /**
783
792
  * Check if the current execution is inside a cache() DSL boundary.
@@ -822,8 +831,37 @@ export function runInsideLoaderScope<T>(fn: () => T): T {
822
831
  * and handler-invoked via ctx.use) so request-scoped reads inside a loader
823
832
  * never trip the cache-scope guards — loaders always run fresh.
824
833
  */
825
- export function runInsideLoaderBodyScope<T>(fn: () => T): T {
826
- return loaderBodyScopeALS.run({ active: true }, fn);
834
+ export function runInsideLoaderBodyScope<T>(
835
+ fn: () => T,
836
+ loaderId?: string,
837
+ handlerInvoked?: boolean,
838
+ ): T {
839
+ return loaderBodyScopeALS.run({ active: true, loaderId, handlerInvoked }, fn);
840
+ }
841
+
842
+ /**
843
+ * The $$id of the loader whose body is currently executing, or undefined
844
+ * outside any loader body. Used by the shell-capture identity guard
845
+ * (cookie-store.ts) so its refusal warning can name the loader that read
846
+ * cookies()/headers() instead of blaming a lane it cannot see — the old
847
+ * hardcoded "bake-lane loader" text misled a live-lane debugging session
848
+ * (issue #672, secondary).
849
+ */
850
+ export function getCurrentLoaderBodyId(): string | undefined {
851
+ return loaderBodyScopeALS.getStore()?.loaderId;
852
+ }
853
+
854
+ /**
855
+ * True while a HANDLER-invoked loader body (`await ctx.use(Loader)` from a
856
+ * handler, not the DSL segment funnel) is executing. The consumption-lane
857
+ * rule keys off this: handler consumption yields a BAKED copy in every shared
858
+ * artifact — cache(), "use cache", and the PPR shell — so the shell-capture
859
+ * identity guard (cookie-store.ts) permits cookies()/headers() here, exactly
860
+ * like the cache-purity guards do. DSL segment loaders (live lane masked at
861
+ * capture, bake lane guarded) never set the flag.
862
+ */
863
+ export function isInsideHandlerInvokedLoaderBody(): boolean {
864
+ return loaderBodyScopeALS.getStore()?.handlerInvoked === true;
827
865
  }
828
866
 
829
867
  // Scope for handle PUSH CALLBACKS (push(() => ...), including async ones).
@@ -9,7 +9,11 @@
9
9
 
10
10
  import type { CookieOptions } from "../router/middleware-types.js";
11
11
  import { getRequestContext, _getRequestContext } from "./request-context.js";
12
- import { isInsideCacheScope } from "./context.js";
12
+ import {
13
+ isInsideCacheScope,
14
+ getCurrentLoaderBodyId,
15
+ isInsideHandlerInvokedLoaderBody,
16
+ } from "./context.js";
13
17
  import { INSIDE_CACHE_EXEC } from "../cache/taint.js";
14
18
 
15
19
  /**
@@ -139,9 +143,19 @@ function assertNotInsideCacheContext(ctx: unknown, fnName: string): void {
139
143
  * shell-capture.ts). The captured shell prelude is shared across every user
140
144
  * hitting the URL, so a request-scoped read here would bake one user's
141
145
  * cookies/headers into markup served to others — same hazard as the cache
142
- * scopes above, at the document tier. Loaders need no exemption: they are
143
- * masked (never executed) during capture and remain the per-request holes of
144
- * the shell.
146
+ * scopes above, at the document tier. DSL segment loaders need no exemption:
147
+ * the live lane is masked (never executed) during capture, and the bake lane
148
+ * is exactly what this guard exists for.
149
+ *
150
+ * HANDLER-INVOKED loader bodies (`await ctx.use(Loader)` from a handler) are
151
+ * EXEMPT — the consumption-lane rule: handler consumption yields a BAKED
152
+ * shared copy in every artifact tier, and the cache-purity guards above
153
+ * already permit identity reads there (cache()/"use cache" bake the same
154
+ * reads today). Guarding only the PPR tier made the same code legal under
155
+ * cache() but capture-refusing under ppr (issue #672 / #674). The trade is
156
+ * documented: an identity read in a handler-consumed loader bakes the CAPTURE
157
+ * request's value into the shared shell; client-side consumption (useLoader)
158
+ * is the live lane.
145
159
  *
146
160
  * Keys off `_shellCaptureRun`, NOT the `_shellCapture` descriptor: the descriptor
147
161
  * is also present during the FOREGROUND render (it means "a capture is wanted"),
@@ -164,12 +178,19 @@ function assertNotInsideShellCapture(ctx: unknown, fnName: string): void {
164
178
  typeof ctx === "object" &&
165
179
  (ctx as { _shellCaptureRun?: unknown })._shellCaptureRun === true
166
180
  ) {
181
+ if (isInsideHandlerInvokedLoaderBody()) return;
167
182
  // Flag the capture context BEFORE throwing: inside an executing bake-lane
168
183
  // loader this throw is swallowed by wrapLoaderPromise into per-loader error
169
184
  // UI, which would bake silently into the shared shell. The capture checks
170
- // the flag after the render and refuses (shell-capture.ts).
185
+ // the flag after the render and refuses (shell-capture.ts). Also record
186
+ // WHICH loader body (if any) made the read, so the refusal warning can
187
+ // name the real source instead of hardcoding a lane — the read may come
188
+ // from a bake-lane loader OR from handler/render code (issue #672).
171
189
  (ctx as { _shellCaptureGuardTripped?: string })._shellCaptureGuardTripped =
172
190
  fnName;
191
+ (
192
+ ctx as { _shellCaptureGuardTrippedLoaderId?: string }
193
+ )._shellCaptureGuardTrippedLoaderId = getCurrentLoaderBodyId();
173
194
  throw new Error(
174
195
  `${fnName}() cannot be called while capturing a shared shell ` +
175
196
  `(shell-cache middleware). The captured shell is served to every user ` +
@@ -220,6 +220,16 @@ export interface RequestContext<
220
220
  */
221
221
  _shellCaptureGuardTripped?: string;
222
222
 
223
+ /**
224
+ * @internal The loader $$id whose BODY was executing when the capture guard
225
+ * tripped (read off the loader-body ALS scope at trip time), or undefined
226
+ * when the read came from handler/render code. Only used to make the
227
+ * once-per-key refusal warning name the real source — the old text
228
+ * hardcoded "a bake-lane loader", which misattributed handler-land reads
229
+ * and sent users debugging the wrong lane (issue #672, secondary).
230
+ */
231
+ _shellCaptureGuardTrippedLoaderId?: string;
232
+
223
233
  /**
224
234
  * @internal Handler-owned registry of explicit per-scope stores from
225
235
  * cache({ store }). Created once per createRSCHandler() and threaded into
@@ -468,6 +478,15 @@ export interface RequestContext<
468
478
  /** @internal Request-scoped performance metrics store */
469
479
  _metricsStore?: MetricsStore;
470
480
 
481
+ /**
482
+ * @internal True request entry timestamp (performance.now() at handler entry).
483
+ * Set once at request-context creation (rsc/handler.ts) so a metrics store
484
+ * created MID-request — ctx.debugPerformance() or the getMetricsStore wrapper —
485
+ * anchors its timeline to the real request start instead of the opt-in moment,
486
+ * keeping phases that began before the opt-in at their true (non-negative) offset.
487
+ */
488
+ _handlerStart?: number;
489
+
471
490
  /** @internal Resolved platform phase-span tracing for this request (Cloudflare or OTel) */
472
491
  _tracing?: ResolvedTracing;
473
492
 
@@ -509,6 +528,7 @@ export type PublicRequestContext<
509
528
  | "_transitionWhen"
510
529
  | "_cacheStore"
511
530
  | "_shellCaptureRun"
531
+ | "_shellCaptureGuardTrippedLoaderId"
512
532
  | "_explicitTaggedStores"
513
533
  | "_requestTags"
514
534
  | "_cacheProfiles"
@@ -536,6 +556,7 @@ export type PublicRequestContext<
536
556
  | "_reportBackgroundError"
537
557
  | "_debugPerformance"
538
558
  | "_metricsStore"
559
+ | "_handlerStart"
539
560
  | "_basename"
540
561
  | "_setStatus"
541
562
  | "_rotateStateCookie"
@@ -1028,6 +1049,7 @@ export function createRequestContext<TEnv>(
1028
1049
 
1029
1050
  _reportedErrors: new WeakSet<object>(),
1030
1051
  _metricsStore: undefined,
1052
+ _handlerStart: undefined,
1031
1053
 
1032
1054
  _renderBarrier: null as any,
1033
1055
  _resolveRenderBarrier: null as any,
package/src/ssr/index.tsx CHANGED
@@ -247,11 +247,22 @@ async function readStreamToUint8Array(
247
247
  const reader = stream.getReader();
248
248
  const chunks: Uint8Array[] = [];
249
249
  let total = 0;
250
- while (true) {
251
- const { done, value } = await reader.read();
252
- if (done) break;
253
- chunks.push(value);
254
- total += value.length;
250
+ try {
251
+ while (true) {
252
+ const { done, value } = await reader.read();
253
+ if (done) break;
254
+ chunks.push(value);
255
+ total += value.length;
256
+ }
257
+ } catch (error) {
258
+ // Mid-read abort path (documented): the prelude stream errors with our
259
+ // abort reason while we are still reading it. Cancel the source before
260
+ // rethrowing so it is not left uncancelled; releaseLock always runs in
261
+ // finally. Mirrors src/rsc/rsc-rendering.ts's serve-side reader cleanup.
262
+ reader.cancel(error).catch(() => {});
263
+ throw error;
264
+ } finally {
265
+ reader.releaseLock();
255
266
  }
256
267
  const out = new Uint8Array(total);
257
268
  let offset = 0;
@@ -434,113 +445,140 @@ export function createShellCaptureHandler<TEnv = unknown>(
434
445
  ): Promise<ShellCaptureResult | null> {
435
446
  const maxWaitMs = opts.maxWaitMs ?? DEFAULT_SHELL_CAPTURE_MAX_WAIT_MS;
436
447
 
437
- // No nonce (nonce'd requests never reach capture); no formState.
438
- const SsrRoot = createSsrRootComponent({
439
- createFromReadableStream,
440
- rscStream,
441
- });
442
-
443
- const bootstrapScriptContent = await loadBootstrapScriptContent();
444
-
445
- // Start prerender first, then run the abort schedule concurrently. When
446
- // holes are pending, prerender's promise settles only after abort(); when
447
- // the shell completes with no holes it settles on its own and the later
448
- // abort() is a harmless no-op (the DATA variant).
449
- const controller = new AbortController();
450
- const prerenderPromise = prerender(<SsrRoot />, {
451
- signal: controller.signal,
452
- bootstrapScriptContent,
453
- // Abort is how capture WORKS: once the shell is quiet we abort() to freeze
454
- // the prelude and let the still-pending holes postpone. React reports the
455
- // abort reason for each pending boundary through onError. Without an onError
456
- // here React falls back to console.error, so every capture that still has a
457
- // live hole at abort time (the normal case, and every cold-module capture
458
- // where the shell is not yet done) dumps a DOMException [AbortError] stack —
459
- // once per pending boundary. That is EXPECTED degradation, not a failure, so
460
- // swallow the abort here. Genuine shell render errors (a component throwing)
461
- // are NOT the abort and still surface through the deps.onError channel, the
462
- // same one renderHTML uses. See docs/design/ppr-shell-resume.md.
463
- onError: (error: unknown) => {
464
- if (
465
- controller.signal.aborted &&
466
- (error as { name?: string } | null)?.name === "AbortError"
467
- ) {
468
- return;
469
- }
470
- reportRenderError(onError, error);
471
- },
472
- });
473
- // Pre-attach a no-op catch: the real await sits AFTER quiesce + the
474
- // post-quiesce hops, so an early prerender rejection (e.g. a bake-lane
475
- // loader tripping the identity guard within milliseconds) would otherwise
476
- // spend several turns handler-less and crash the worker as an unhandled
477
- // rejection. The actual rejection handling still happens at the await
478
- // below; this parallel handler only keeps the gap crash-free.
479
- prerenderPromise.catch(() => {});
480
-
481
- // Wait for the caller's quiesce signal. By the time it resolves the Flight
482
- // input is byte-quiet and FROZEN by the capture gate (shell-capture.ts
483
- // gateFlightForCapture), so there is no wall-clock debounce here — maxWaitMs
484
- // is only the pathological guard for a shell that never goes quiet (a root
485
- // postpone / hung handle), and should never fire in tests.
486
- const timer = createCancelableTimeout(maxWaitMs);
487
- try {
488
- await Promise.race([opts.quiesce, timer.promise]);
489
- } finally {
490
- timer.cancel();
491
- }
492
- // Fixed task hops before the abort: give React's fizz worker turns to flush
493
- // the now-complete shell and mark the still-pending boundaries as POSTPONED
494
- // rather than errored. Deterministic (the byte set is already frozen), so a
495
- // fixed count of turns suffices — no wall-clock.
496
- for (let i = 0; i < POST_QUIESCE_TASK_HOPS; i++) {
497
- await macrotask();
498
- }
499
- controller.abort();
500
-
501
- // A hard prerender rejection (fatal shell error) propagates. Expected
502
- // degradation surfaces three ways and all return null: a trivial prelude
503
- // (sanity gate below), the prerender REJECTING with an AbortError, or the
504
- // prelude STREAM erroring with the abort reason mid-read — both abort
505
- // shapes happen when our own abort lands before the shell completed (seen
506
- // on dev cold paths, where module transform / first-render latency
507
- // outlasts flight quiesce; a later request re-captures against warm
508
- // modules and succeeds).
509
- let prelude: Uint8Array;
510
- let postponed: unknown;
448
+ // Arm the maxWaitMs deadline BEFORE the first await so it bounds the ENTIRE
449
+ // capture, the bootstrap-script load included. loadBootstrapScriptContent()
450
+ // used to run before the timer, so a hung/slow bootstrap load hung
451
+ // captureShellHTML with no upper bound and held the background capture task
452
+ // open. One deadline, shared by the bootstrap race below and the quiesce
453
+ // race, keeps the whole path "bounded by maxWaitMs like every quiesce input".
454
+ const deadline = createCancelableTimeout(maxWaitMs);
511
455
  try {
512
- const result = await prerenderPromise;
513
- prelude = await readStreamToUint8Array(result.prelude);
514
- postponed = result.postponed;
515
- } catch (error) {
516
- // Name-based match: the rejection is a DOMException on workerd/Node,
517
- // which is not an Error subclass there, so instanceof Error would let
518
- // the abort escape as a spurious reported error.
519
- if (
520
- controller.signal.aborted &&
521
- (error as { name?: string } | null)?.name === "AbortError"
522
- ) {
456
+ // No nonce (nonce'd requests never reach capture); no formState.
457
+ const SsrRoot = createSsrRootComponent({
458
+ createFromReadableStream,
459
+ rscStream,
460
+ });
461
+
462
+ // Bootstrap load raced against the deadline. A load that never resolves
463
+ // within maxWaitMs is the same bounded no-shell degrade as a shell that
464
+ // never goes quiet: return null, do not hang. A load that REJECTS is a
465
+ // genuine error and still propagates (it is not the deadline). `null` is
466
+ // the deadline sentinel — disjoint from the load's `Promise<string>`, so
467
+ // the race narrows to `string | null` with no wrapper. The no-op catch
468
+ // keeps a late rejection off the unhandledRejection path when the deadline
469
+ // already won; a rejection that lands first still propagates out.
470
+ const load = loadBootstrapScriptContent();
471
+ load.catch(() => {});
472
+ const bootstrapScriptContent = await Promise.race([
473
+ load,
474
+ deadline.promise.then(() => null),
475
+ ]);
476
+ if (bootstrapScriptContent === null) {
523
477
  return null;
524
478
  }
525
- throw error;
526
- }
527
479
 
528
- // Sanity gate: a prelude with no `<body` is the no-shell failure mode.
529
- // Return null and store nothing; the request falls back to axis 1 and a
530
- // later request re-captures. The dominant real-world cause is a loader
531
- // route WITHOUT a route-level loading() boundary: renderSegments' loading-
532
- // less branch awaits loader data at TREE-BUILD, so the masked loader pins
533
- // the whole tree above <body> (root postpone). Root-postponing layouts and
534
- // hung handles degrade the same way. shell-capture.ts logs a once-per-key
535
- // warning so the eternal-MISS shape is diagnosable.
536
- if (!new TextDecoder().decode(prelude).includes("<body")) {
537
- return null;
538
- }
480
+ // Start prerender first, then run the abort schedule concurrently. When
481
+ // holes are pending, prerender's promise settles only after abort(); when
482
+ // the shell completes with no holes it settles on its own and the later
483
+ // abort() is a harmless no-op (the DATA variant).
484
+ const controller = new AbortController();
485
+ // Private reason object: the deliberate abort is identified by object
486
+ // IDENTITY in both the onError below and the post-await catch. React
487
+ // propagates this EXACT object to onError for every still-pending boundary
488
+ // (verified identity-preserving), and rejects/errors the prelude with it.
489
+ const abortReason = { rangoShellCaptureAbort: true };
490
+ const prerenderPromise = prerender(<SsrRoot />, {
491
+ signal: controller.signal,
492
+ bootstrapScriptContent,
493
+ // Abort is how capture WORKS: once the shell is quiet we abort() to
494
+ // freeze the prelude and let the still-pending holes postpone. React
495
+ // reports the abort reason for each pending boundary through onError.
496
+ // Without an onError here React falls back to console.error, so every
497
+ // capture that still has a live hole at abort time (the normal case)
498
+ // dumps a stack once per pending boundary. That is EXPECTED degradation,
499
+ // so swallow OUR abort — matched by IDENTITY (error === abortReason).
500
+ // Discriminate by identity, NOT error.name: capture aborts before
501
+ // awaiting, so signal.aborted is unconditionally true and a name check
502
+ // swallowed genuine AbortError-named throws (a component's own
503
+ // fetch/AbortController cancellation) as if they were our abort. Genuine
504
+ // render errors are NOT our sentinel and still surface through
505
+ // deps.onError, the same channel renderHTML uses. See
506
+ // docs/design/ppr-shell-resume.md.
507
+ onError: (error: unknown) => {
508
+ if (error === abortReason) {
509
+ return;
510
+ }
511
+ reportRenderError(onError, error);
512
+ },
513
+ });
514
+ // Pre-attach a no-op catch: the real await sits AFTER quiesce + the
515
+ // post-quiesce hops, so an early prerender rejection (e.g. a bake-lane
516
+ // loader tripping the identity guard within milliseconds) would otherwise
517
+ // spend several turns handler-less and crash the worker as an unhandled
518
+ // rejection. The actual rejection handling still happens at the await
519
+ // below; this parallel handler only keeps the gap crash-free.
520
+ prerenderPromise.catch(() => {});
521
+
522
+ // Wait for the caller's quiesce signal, bounded by the SAME deadline. By
523
+ // the time it resolves the Flight input is byte-quiet and FROZEN by the
524
+ // capture gate (shell-capture.ts gateFlightForCapture), so there is no
525
+ // wall-clock debounce here — maxWaitMs is only the pathological guard for a
526
+ // shell that never goes quiet (a root postpone / hung handle).
527
+ await Promise.race([opts.quiesce, deadline.promise]);
528
+ // Fixed task hops before the abort: give React's fizz worker turns to flush
529
+ // the now-complete shell and mark the still-pending boundaries as POSTPONED
530
+ // rather than errored. Deterministic (the byte set is already frozen), so a
531
+ // fixed count of turns suffices — no wall-clock.
532
+ for (let i = 0; i < POST_QUIESCE_TASK_HOPS; i++) {
533
+ await macrotask();
534
+ }
535
+ controller.abort(abortReason);
536
+
537
+ // A hard prerender rejection (fatal shell error) propagates. Expected
538
+ // degradation surfaces three ways and all return null: a trivial prelude
539
+ // (sanity gate below), the prerender REJECTING with our abort reason, or
540
+ // the prelude STREAM erroring with our abort reason mid-read — both abort
541
+ // shapes happen when our own abort lands before the shell completed (seen
542
+ // on dev cold paths, where module transform / first-render latency
543
+ // outlasts flight quiesce; a later request re-captures against warm
544
+ // modules and succeeds).
545
+ let prelude: Uint8Array;
546
+ let postponed: unknown;
547
+ try {
548
+ const result = await prerenderPromise;
549
+ prelude = await readStreamToUint8Array(result.prelude);
550
+ postponed = result.postponed;
551
+ } catch (error) {
552
+ // Identity match: swallow ONLY our own deliberate abort
553
+ // (error === abortReason). Not error.name — capture aborts before this
554
+ // await, so signal.aborted is always true, and a name check let a
555
+ // genuine AbortError-named throw masquerade as our abort and degrade
556
+ // into a retryable no-shell, hiding real failures from reportCacheError.
557
+ if (error === abortReason) {
558
+ return null;
559
+ }
560
+ throw error;
561
+ }
562
+
563
+ // Sanity gate: a prelude with no `<body` is the no-shell failure mode.
564
+ // Return null and store nothing; the request falls back to axis 1 and a
565
+ // later request re-captures. The dominant real-world cause is a loader
566
+ // route WITHOUT a route-level loading() boundary: renderSegments' loading-
567
+ // less branch awaits loader data at TREE-BUILD, so the masked loader pins
568
+ // the whole tree above <body> (root postpone). Root-postponing layouts and
569
+ // hung handles degrade the same way. shell-capture.ts logs a once-per-key
570
+ // warning so the eternal-MISS shape is diagnosable.
571
+ if (!new TextDecoder().decode(prelude).includes("<body")) {
572
+ return null;
573
+ }
539
574
 
540
- return {
541
- prelude,
542
- postponed: postponed == null ? null : JSON.stringify(postponed),
543
- };
575
+ return {
576
+ prelude,
577
+ postponed: postponed == null ? null : JSON.stringify(postponed),
578
+ };
579
+ } finally {
580
+ deadline.cancel();
581
+ }
544
582
  };
545
583
  }
546
584