@rangojs/router 0.0.0-experimental.150 → 0.0.0-experimental.152

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.
@@ -18,9 +18,14 @@ import { gateTransitions } from "./transition-gate.js";
18
18
  import { resolvedHandleStream } from "../handles/deferred-resolution.js";
19
19
  import type { RscPayload, ReactFormState } from "./types.js";
20
20
  import {
21
+ RSC_RENDER_HTML_RESPONSE_PHASES,
21
22
  createResponseWithMergedHeaders,
22
23
  finalizeResponse,
23
24
  buildRouteMiddlewareEntries,
25
+ createRscRenderStages,
26
+ finishRscRenderStages,
27
+ observeRscHtmlStage,
28
+ readRscFlightStage,
24
29
  } from "./helpers.js";
25
30
  import type { HandlerContext } from "./handler-context.js";
26
31
  import {
@@ -327,11 +332,31 @@ export async function handleProgressiveEnhancement<TEnv>(
327
332
  },
328
333
  };
329
334
 
330
- const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
331
- onError: (error: unknown) => {
332
- ctx.callOnError(error, "rendering", { request, url, env });
335
+ const stageTracking = {
336
+ mode: "progressive-enhancement" as const,
337
+ routeKey: getRequestContext()._routeName,
338
+ actionId: directActionId ?? undefined,
339
+ phases: RSC_RENDER_HTML_RESPONSE_PHASES,
340
+ };
341
+ const renderStages = createRscRenderStages({
342
+ ctx,
343
+ request,
344
+ env,
345
+ url,
346
+ payload,
347
+ init: {
348
+ // boundarylessErrorStatus is set only when the action threw and no error
349
+ // boundary matched; it makes the re-render carry 500 like the JS path.
350
+ // The redirect branch above returns before this, so a redirect re-render
351
+ // keeps its 308 and is never overridden.
352
+ ...(boundarylessErrorStatus !== undefined
353
+ ? { status: boundarylessErrorStatus }
354
+ : {}),
355
+ headers: { "content-type": "text/html;charset=utf-8" },
333
356
  },
357
+ tracking: stageTracking,
334
358
  });
359
+ const flightStage = await readRscFlightStage(renderStages);
335
360
  // metricsStore=undefined is safe: the handler already stashed the early
336
361
  // SSR setup promise on request variables, so getSSRSetup returns it
337
362
  // without falling back to a fresh startSSRSetup.
@@ -344,21 +369,18 @@ export async function handleProgressiveEnhancement<TEnv>(
344
369
  );
345
370
  // reactFormState carries the useActionState payload via the SSR-option path
346
371
  // (renderToReadableStream({ formState })); it does NOT travel on RscPayload.
347
- const htmlStream = await ssrModule.renderHTML(rscStream, {
348
- formState: reactFormState,
349
- nonce,
350
- streamMode,
351
- });
372
+ const htmlStream = await observeRscHtmlStage(
373
+ { url, tracking: stageTracking },
374
+ () =>
375
+ ssrModule.renderHTML(flightStage.stream, {
376
+ formState: reactFormState,
377
+ nonce,
378
+ streamMode,
379
+ }),
380
+ );
352
381
 
353
- return createResponseWithMergedHeaders(htmlStream, {
354
- // boundarylessErrorStatus is set only when the action threw and no error
355
- // boundary matched; it makes the re-render carry 500 like the JS path.
356
- // The redirect branch above returns before this, so a redirect re-render
357
- // keeps its 308 and is never overridden.
358
- ...(boundarylessErrorStatus !== undefined
359
- ? { status: boundarylessErrorStatus }
360
- : {}),
361
- headers: { "content-type": "text/html;charset=utf-8" },
382
+ return finishRscRenderStages(renderStages, {
383
+ body: htmlStream,
362
384
  });
363
385
  };
364
386
 
@@ -475,11 +497,25 @@ async function renderPeErrorBoundary<TEnv>(
475
497
  },
476
498
  };
477
499
 
478
- const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
479
- onError: (error: unknown) => {
480
- ctx.callOnError(error, "rendering", { request, url, env });
500
+ const stageTracking = {
501
+ mode: "progressive-enhancement-error" as const,
502
+ routeKey: getRequestContext()._routeName,
503
+ actionId: actionId ?? undefined,
504
+ phases: RSC_RENDER_HTML_RESPONSE_PHASES,
505
+ };
506
+ const renderStages = createRscRenderStages({
507
+ ctx,
508
+ request,
509
+ env,
510
+ url,
511
+ payload,
512
+ init: {
513
+ status: 500,
514
+ headers: { "content-type": "text/html;charset=utf-8" },
481
515
  },
516
+ tracking: stageTracking,
482
517
  });
518
+ const flightStage = await readRscFlightStage(renderStages);
483
519
  // metricsStore=undefined is safe: the handler already stashed the early
484
520
  // SSR setup promise on request variables, so getSSRSetup returns it
485
521
  // without falling back to a fresh startSSRSetup.
@@ -490,13 +526,16 @@ async function renderPeErrorBoundary<TEnv>(
490
526
  url,
491
527
  undefined,
492
528
  );
493
- const htmlStream = await ssrModule.renderHTML(rscStream, {
494
- nonce,
495
- streamMode,
496
- });
529
+ const htmlStream = await observeRscHtmlStage(
530
+ { url, tracking: stageTracking },
531
+ () =>
532
+ ssrModule.renderHTML(flightStage.stream, {
533
+ nonce,
534
+ streamMode,
535
+ }),
536
+ );
497
537
 
498
- return createResponseWithMergedHeaders(htmlStream, {
499
- status: 500,
500
- headers: { "content-type": "text/html;charset=utf-8" },
538
+ return finishRscRenderStages(renderStages, {
539
+ body: htmlStream,
501
540
  });
502
541
  }
@@ -22,9 +22,18 @@ import type { RscPayload } from "./types.js";
22
22
  import type { SSRModule } from "./types.js";
23
23
  import type { RequestContext } from "../server/request-context.js";
24
24
  import {
25
+ RSC_FLIGHT_ONLY_PHASES,
26
+ RSC_RENDER_FLIGHT_RESPONSE_PHASES,
27
+ RSC_RENDER_HTML_RESPONSE_PHASES,
25
28
  createResponseWithMergedHeaders,
26
29
  createSimpleRedirectResponse,
27
30
  attachLocationStateIfPresent,
31
+ createRscRenderStages,
32
+ finishRscRenderStages,
33
+ observeRscHtmlStage,
34
+ readRscFlightStage,
35
+ renderRscFlightStage,
36
+ runRscRenderStages,
28
37
  } from "./helpers.js";
29
38
  import type { HandlerContext } from "./handler-context.js";
30
39
  import { gateTransitions } from "./transition-gate.js";
@@ -124,7 +133,8 @@ async function handleRscRenderingInner<TEnv>(
124
133
  !isPartial &&
125
134
  request.method === "GET" &&
126
135
  !url.searchParams.has("__prerender_collect") &&
127
- !isRscRequest(request, url, false)
136
+ !isRscRequest(request, url, false) &&
137
+ !reqCtx._dynamic
128
138
  ) {
129
139
  const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry);
130
140
  if (pprConfig) {
@@ -428,54 +438,60 @@ async function handleRscRenderingInner<TEnv>(
428
438
 
429
439
  const metricsStore = reqCtx._metricsStore;
430
440
 
431
- // Serialize to RSC stream
432
- const rscSerializeStart = performance.now();
433
- const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
434
- onError: (error: unknown) => {
435
- ctx.callOnError(error, "rendering", { request, url, env });
441
+ const rscHeaders: Record<string, string> = {
442
+ "content-type": "text/x-component;charset=utf-8",
443
+ vary: "accept, X-Rango-State, X-RSC-Router-Client-Path",
444
+ // Router identity, so the client can verify pre-decode (before importing
445
+ // chunks) that this content payload belongs to its app and refuse a
446
+ // foreign one (cache/proxy/bug). Control-only reload/redirect responses
447
+ // are deliberately NOT stamped. See browser/response-adapter.ts.
448
+ "X-RSC-Router-Id": ctx.router.id,
449
+ };
450
+ // Tell the client's prefetch cache to scope this response to its source
451
+ // URL (instead of the default source-agnostic wildcard). Intercept
452
+ // responses depend on the source page matching an intercept rule, so
453
+ // they must not be reused for navigations from other sources.
454
+ if (hasInterceptSlots) {
455
+ rscHeaders["x-rsc-prefetch-scope"] = "source";
456
+ }
457
+ // Enable browser HTTP caching for prefetch responses only.
458
+ // Requires X-Rango-Prefetch header (sent by Link prefetch fetch),
459
+ // non-intercept context (intercept responses depend on source page),
460
+ // and a configured cache-control value (false disables caching).
461
+ const isPrefetch = request.headers.has("X-Rango-Prefetch");
462
+ if (isPrefetch && isPartial && !hasInterceptSlots) {
463
+ const cc = ctx.router.prefetchCacheControl;
464
+ if (cc) {
465
+ rscHeaders["cache-control"] = cc;
466
+ }
467
+ }
468
+
469
+ const isFlightResponse = isRscRequest(request, url, isPartial);
470
+ const stageTracking = {
471
+ mode: isPartial ? ("partial" as const) : ("full" as const),
472
+ routeKey: reqCtx._routeName,
473
+ phases: isFlightResponse
474
+ ? RSC_RENDER_FLIGHT_RESPONSE_PHASES
475
+ : RSC_RENDER_HTML_RESPONSE_PHASES,
476
+ };
477
+ const renderStages = createRscRenderStages({
478
+ ctx,
479
+ request,
480
+ env,
481
+ url,
482
+ payload,
483
+ init: {
484
+ headers: rscHeaders,
436
485
  },
486
+ tracking: stageTracking,
437
487
  });
438
- const rscSerializeDur = performance.now() - rscSerializeStart;
439
- // This measures synchronous stream creation, not end-to-end stream consumption.
440
- appendMetric(
441
- metricsStore,
442
- "rsc-serialize",
443
- rscSerializeStart,
444
- rscSerializeDur,
445
- );
446
488
 
447
- if (isRscRequest(request, url, isPartial)) {
489
+ const flightStage = await readRscFlightStage(renderStages);
490
+ const rscStream = flightStage.stream;
491
+
492
+ if (isFlightResponse) {
448
493
  // render:total is recorded by the observePhase wrapper around this function.
449
- const rscHeaders: Record<string, string> = {
450
- "content-type": "text/x-component;charset=utf-8",
451
- vary: "accept, X-Rango-State, X-RSC-Router-Client-Path",
452
- // Router identity, so the client can verify pre-decode (before importing
453
- // chunks) that this content payload belongs to its app and refuse a
454
- // foreign one (cache/proxy/bug). Control-only reload/redirect responses
455
- // are deliberately NOT stamped. See browser/response-adapter.ts.
456
- "X-RSC-Router-Id": ctx.router.id,
457
- };
458
- // Tell the client's prefetch cache to scope this response to its source
459
- // URL (instead of the default source-agnostic wildcard). Intercept
460
- // responses depend on the source page matching an intercept rule, so
461
- // they must not be reused for navigations from other sources.
462
- if (hasInterceptSlots) {
463
- rscHeaders["x-rsc-prefetch-scope"] = "source";
464
- }
465
- // Enable browser HTTP caching for prefetch responses only.
466
- // Requires X-Rango-Prefetch header (sent by Link prefetch fetch),
467
- // non-intercept context (intercept responses depend on source page),
468
- // and a configured cache-control value (false disables caching).
469
- const isPrefetch = request.headers.has("X-Rango-Prefetch");
470
- if (isPrefetch && isPartial && !hasInterceptSlots) {
471
- const cc = ctx.router.prefetchCacheControl;
472
- if (cc) {
473
- rscHeaders["cache-control"] = cc;
474
- }
475
- }
476
- return createResponseWithMergedHeaders(rscStream, {
477
- headers: rscHeaders,
478
- });
494
+ return runRscRenderStages(renderStages);
479
495
  }
480
496
 
481
497
  // Delegate to SSR for HTML response (reuse early setup if available)
@@ -489,14 +505,21 @@ async function handleRscRenderingInner<TEnv>(
489
505
 
490
506
  // ssr-render-html metric + rango.ssr span from one boundary. render:total is
491
507
  // recorded by the observePhase wrapper around this function.
492
- const htmlStream = await observePhase(PHASES.ssr, () =>
493
- ssrModule.renderHTML(rscStream, {
494
- nonce,
495
- streamMode,
496
- }),
508
+ const htmlStream = await observeRscHtmlStage(
509
+ { url, tracking: stageTracking },
510
+ () =>
511
+ observePhase(PHASES.ssr, () =>
512
+ ssrModule.renderHTML(rscStream, {
513
+ nonce,
514
+ streamMode,
515
+ }),
516
+ ),
497
517
  );
498
- const response = createResponseWithMergedHeaders(htmlStream, {
499
- headers: { "content-type": "text/html;charset=utf-8" },
518
+ const response = await finishRscRenderStages(renderStages, {
519
+ body: htmlStream,
520
+ init: {
521
+ headers: { "content-type": "text/html;charset=utf-8" },
522
+ },
500
523
  });
501
524
 
502
525
  // --- Axis 2: PPR shell CAPTURE on MISS (background task; see design doc) ---
@@ -507,7 +530,7 @@ async function handleRscRenderingInner<TEnv>(
507
530
  // page via router.match() under a derived context (fresh handle store,
508
531
  // _shellCaptureRun: true) — middleware never re-runs; it already ran for this
509
532
  // request and guarding is serve-time.
510
- if (pprMiss) {
533
+ if (pprMiss && !reqCtx._dynamic) {
511
534
  if (
512
535
  response.status === 200 &&
513
536
  (response.headers.get("content-type") ?? "").includes("text/html")
@@ -611,11 +634,22 @@ function serveShellHit(
611
634
  }
612
635
  // Full Flight render per request: hydration needs the whole payload (there
613
636
  // is no Flight-side resume — a React limitation, not ours).
614
- let rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
615
- onError: (error: unknown) => {
616
- ctx.callOnError(error, "rendering", { request, url, env });
637
+ const flightStage = renderRscFlightStage(
638
+ {
639
+ ctx,
640
+ request,
641
+ env,
642
+ url,
643
+ payload,
644
+ tracking: {
645
+ mode: "full",
646
+ routeKey: activeCtx._routeName,
647
+ phases: RSC_FLIGHT_ONLY_PHASES,
648
+ },
617
649
  },
618
- });
650
+ performance.now(),
651
+ );
652
+ let rscStream = flightStage.stream;
619
653
  // Timing tap: when does the Flight render produce its FIRST byte? Compared
620
654
  // with the eager-inject/first-tail logs this proves whether hydration-start
621
655
  // latency is genuine server work (loaders) or stream plumbing holding
@@ -19,7 +19,6 @@ import {
19
19
  getRequestContext,
20
20
  setRequestContextParams,
21
21
  } from "../server/request-context.js";
22
- import { appendMetric } from "../router/metrics.js";
23
22
  import { observePhase, PHASES } from "../router/instrument.js";
24
23
  import { gateTransitions } from "./transition-gate.js";
25
24
  import type { RscPayload } from "./types.js";
@@ -29,6 +28,8 @@ import {
29
28
  createSimpleRedirectResponse,
30
29
  interceptRedirectForPartial,
31
30
  attachLocationStateIfPresent,
31
+ createRscRenderStages,
32
+ runRscRenderStages,
32
33
  } from "./helpers.js";
33
34
  import { warnNonRedirectActionResponse } from "./runtime-warnings.js";
34
35
  import type { HandlerContext } from "./handler-context.js";
@@ -362,8 +363,6 @@ async function revalidateAfterActionInner<TEnv>(
362
363
  errorBoundary,
363
364
  } = continuation;
364
365
  const reqCtx = getRequestContext();
365
- const metricsStore = reqCtx._metricsStore;
366
-
367
366
  // Expose the action that triggered this revalidation to the transition({ when })
368
367
  // gate (covers both the error-boundary and success gate calls below). Mirrors
369
368
  // the action fields a revalidate() predicate sees.
@@ -412,29 +411,30 @@ async function revalidateAfterActionInner<TEnv>(
412
411
  // is a success-only semantic. Error boundary responses update the error UI
413
412
  // but should not mutate browser history state.
414
413
 
415
- const errorStart = performance.now();
416
- const errorStream = ctx.renderToReadableStream<RscPayload>(errorPayload, {
417
- temporaryReferences,
418
- onError: (error: unknown) => {
419
- ctx.callOnError(error, "rendering", { request, url, env });
420
- },
421
- });
422
- appendMetric(
423
- metricsStore,
424
- "rsc-serialize",
425
- errorStart,
426
- performance.now() - errorStart,
414
+ return runRscRenderStages(
415
+ createRscRenderStages({
416
+ ctx,
417
+ request,
418
+ env,
419
+ url,
420
+ payload: errorPayload,
421
+ temporaryReferences,
422
+ init: {
423
+ status: actionStatus,
424
+ headers: {
425
+ "content-type": "text/x-component;charset=utf-8",
426
+ // Router identity for the client's pre-decode integrity check (the
427
+ // action apply path has no post-decode guard). See response-adapter.
428
+ "X-RSC-Router-Id": ctx.router.id,
429
+ },
430
+ },
431
+ tracking: {
432
+ mode: "action-revalidation",
433
+ routeKey: reqCtx._routeName,
434
+ actionId: actionContext?.actionId,
435
+ },
436
+ }),
427
437
  );
428
-
429
- return createResponseWithMergedHeaders(errorStream, {
430
- status: actionStatus,
431
- headers: {
432
- "content-type": "text/x-component;charset=utf-8",
433
- // Router identity for the client's pre-decode integrity check (the
434
- // action apply path has no post-decode guard). See response-adapter.
435
- "X-RSC-Router-Id": ctx.router.id,
436
- },
437
- });
438
438
  }
439
439
 
440
440
  const matchResult = await ctx.router.matchPartial(
@@ -498,25 +498,28 @@ async function revalidateAfterActionInner<TEnv>(
498
498
 
499
499
  attachLocationStateIfPresent(payload);
500
500
 
501
- const renderStart = performance.now();
502
- const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
503
- temporaryReferences,
504
- onError: (error: unknown) => {
505
- ctx.callOnError(error, "rendering", { request, url, env });
506
- },
507
- });
508
- const rscSerializeDur = performance.now() - renderStart;
509
- // This measures synchronous stream creation, not end-to-end stream consumption.
510
- // render:total is recorded by the observePhase wrapper in revalidateAfterAction.
511
- appendMetric(metricsStore, "rsc-serialize", renderStart, rscSerializeDur);
512
-
513
- return createResponseWithMergedHeaders(rscStream, {
514
- status: actionStatus,
515
- headers: {
516
- "content-type": "text/x-component;charset=utf-8",
517
- // Router identity for the client's pre-decode integrity check (the action
518
- // apply path has no post-decode guard). See response-adapter.
519
- "X-RSC-Router-Id": ctx.router.id,
520
- },
521
- });
501
+ return runRscRenderStages(
502
+ createRscRenderStages({
503
+ ctx,
504
+ request,
505
+ env,
506
+ url,
507
+ payload,
508
+ temporaryReferences,
509
+ init: {
510
+ status: actionStatus,
511
+ headers: {
512
+ "content-type": "text/x-component;charset=utf-8",
513
+ // Router identity for the client's pre-decode integrity check (the
514
+ // action apply path has no post-decode guard). See response-adapter.
515
+ "X-RSC-Router-Id": ctx.router.id,
516
+ },
517
+ },
518
+ tracking: {
519
+ mode: "action-revalidation",
520
+ routeKey: reqCtx._routeName,
521
+ actionId: actionContext?.actionId,
522
+ },
523
+ }),
524
+ );
522
525
  }
@@ -146,6 +146,32 @@ export interface DevShellLookup {
146
146
  /** Retry delay (~400ms) plus quiesce/fizz/store headroom past the budgets. */
147
147
  const DEV_SHELL_RETRY_MARGIN_MS = 5_000;
148
148
 
149
+ /**
150
+ * Dev boot-race readiness (issue #719). The `/__rsc_shell` endpoint stands up
151
+ * the capture realm lazily on its first hit — the temp server (Cloudflare
152
+ * preset), the registry import, and, on either preset, a Vite dependency
153
+ * re-optimization that the first import of the shell-capture graph can trigger.
154
+ * All three are TRANSIENT: retrying the same request seconds later succeeds.
155
+ *
156
+ * Before this, the read-through fired ONCE and mapped any non-2xx to a hard
157
+ * MISS, so a Prerender+ppr route's FIRST request lost the race and served
158
+ * axis-1 (x-rango-shell: MISS) while a later poll HIT — the build-time
159
+ * first-request-HIT contract held only after a warm-up. A fast machine loses
160
+ * the race deterministically (the document request beats the boot infra);
161
+ * slow CI arrives after it settled, hence "0-flake on CI".
162
+ *
163
+ * Fix: the endpoint tags the transient not-ready branches with
164
+ * `x-rango-shell-dev: NOT-READY` (HTTP 503), and the read-through re-polls
165
+ * ONLY that signal — a bounded await of readiness, not a blind retry. A
166
+ * genuine negative (route not prerenderable, capture refused: 404) stays an
167
+ * immediate MISS, so a non-baked route never stalls the foreground. The window
168
+ * is capped by DEV_SHELL_READINESS_DEADLINE_MS; the boot infra normally
169
+ * settles well inside the first poll.
170
+ */
171
+ const DEV_SHELL_NOT_READY = "NOT-READY";
172
+ const DEV_SHELL_READINESS_DEADLINE_MS = 10_000;
173
+ const DEV_SHELL_READINESS_POLL_MS = 150;
174
+
149
175
  /**
150
176
  * Timed out like the dev prerender store fetch (see #697): inside a workerd
151
177
  * waitUntil an unsettled fetch pends forever instead of rejecting; on timeout
@@ -192,14 +218,30 @@ async function fetchDevShellEntry(
192
218
  if (dev.captureTimeout !== undefined) {
193
219
  params.set("captureTimeout", String(dev.captureTimeout));
194
220
  }
195
- try {
196
- const res = await fetch(`${devUrl}/__rsc_shell?${params}`, {
197
- signal: AbortSignal.timeout(devShellFetchTimeoutMs(dev.captureTimeout)),
198
- });
199
- if (!res.ok) return undefined;
200
- return (await res.json()) as BuildShellEntry;
201
- } catch {
202
- return undefined;
221
+ const shellUrl = `${devUrl}/__rsc_shell?${params}`;
222
+ // Await the endpoint's boot readiness (issue #719): re-poll ONLY the
223
+ // NOT-READY signal, capped by DEV_SHELL_READINESS_DEADLINE_MS. Every other
224
+ // outcome — a served entry, a genuine negative (404), or a network fault —
225
+ // resolves on the first attempt, so a non-baked route never stalls here.
226
+ const readinessDeadline = Date.now() + DEV_SHELL_READINESS_DEADLINE_MS;
227
+ for (;;) {
228
+ try {
229
+ const res = await fetch(shellUrl, {
230
+ signal: AbortSignal.timeout(devShellFetchTimeoutMs(dev.captureTimeout)),
231
+ });
232
+ if (
233
+ res.status === 503 &&
234
+ res.headers.get("x-rango-shell-dev") === DEV_SHELL_NOT_READY &&
235
+ Date.now() < readinessDeadline
236
+ ) {
237
+ await new Promise((r) => setTimeout(r, DEV_SHELL_READINESS_POLL_MS));
238
+ continue;
239
+ }
240
+ if (!res.ok) return undefined;
241
+ return (await res.json()) as BuildShellEntry;
242
+ } catch {
243
+ return undefined;
244
+ }
203
245
  }
204
246
  }
205
247
 
@@ -53,9 +53,10 @@ import {
53
53
  getRecordingStore,
54
54
  } from "../cache/shell-snapshot.js";
55
55
  import type { HandlerContext } from "./handler-context.js";
56
- import type { RscPayload, SSRModule } from "./types.js";
56
+ import type { SSRModule } from "./types.js";
57
57
  import { buildFullPayload } from "./full-payload.js";
58
58
  import { resolveDeferredHandleValues } from "../handles/deferred-resolution.js";
59
+ import { RSC_FLIGHT_ONLY_PHASES, renderRscFlightStage } from "./helpers.js";
59
60
 
60
61
  /**
61
62
  * Task-quantized quiesce: the number of consecutive macrotask hops with zero new
@@ -1131,11 +1132,21 @@ async function attemptCapture(
1131
1132
  derivedCtx,
1132
1133
  freshHandleStore,
1133
1134
  );
1134
- const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
1135
- onError: (error: unknown) => {
1136
- ctx.callOnError(error, "rendering", { request, url, env });
1135
+ const flightStage = renderRscFlightStage(
1136
+ {
1137
+ ctx,
1138
+ request,
1139
+ env,
1140
+ url,
1141
+ payload,
1142
+ tracking: {
1143
+ mode: "full",
1144
+ routeKey: derivedCtx._routeName,
1145
+ phases: RSC_FLIGHT_ONLY_PHASES,
1146
+ },
1137
1147
  },
1138
- });
1148
+ performance.now(),
1149
+ );
1139
1150
 
1140
1151
  // Pass the descriptor with its STATIC ppr.tags unchanged. The shell's own
1141
1152
  // render-recorded tags are snapshotted at the putShell WRITE BARRIER inside
@@ -1145,7 +1156,7 @@ async function attemptCapture(
1145
1156
  // shell-tag snapshot must sit behind the quiesce gate (issue #676).
1146
1157
  return captureAndStoreShell(
1147
1158
  ssrModule,
1148
- rscStream,
1159
+ flightStage.stream,
1149
1160
  freshHandleStore,
1150
1161
  derivedCtx,
1151
1162
  descriptor,