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

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,
@@ -817,9 +817,14 @@ const loaderBodyScopeALS: AsyncLocalStorage<{
817
817
  */
818
818
  export function isInsideCacheScope(): boolean {
819
819
  if (RangoContext.getStore()?.insideCacheScope !== true) return false;
820
- // Loaders are always fresh even inside a cache() boundary, the loader
821
- // function re-executes on every request (DSL loaders AND handler-invoked
822
- // loader bodies alike), so request-scoped reads inside any loader are safe.
820
+ // Request-scoped READS are exempt in any loader body DSL loaders re-run on
821
+ // every request (including cache() HITs via resolveLoadersOnly), and a
822
+ // handler-invoked loader body, though skipped with its handler on a HIT,
823
+ // yields a BAKED shared copy in the cached artifact — an accepted
824
+ // consumption-lane tradeoff (#672/#674). This is deliberately BROADER than
825
+ // the WRITE guard (assertCachedHeaderWriteAllowed narrows the cache()
826
+ // exemption to DSL scope, #725): a read bakes-and-accepts, a Set-Cookie/header
827
+ // write drops-and-throws because it has no baked-copy semantics on a HIT.
823
828
  if (isInsideAnyLoaderScope()) return false;
824
829
  return true;
825
830
  }
@@ -901,8 +906,11 @@ export function latchPprHeaderScopeForEntries(
901
906
  /**
902
907
  * RULE (issue #713): in any cached scenario ONLY MIDDLEWARE writes response
903
908
  * headers — handler and loader writes throw while a scope is latched; the one
904
- * exemption is loaders under plain cache(). Full layer rules and rationale:
905
- * docs/design/ppr-shell-resume.md "The header doctrine".
909
+ * exemption is DSL (registered) loaders under plain cache(). A handler-invoked
910
+ * loader body (ctx.use from a handler, never registered with loader()) is
911
+ * skipped with its handler on a HIT and throws like a handler write (#725).
912
+ * Full layer rules and rationale: docs/design/ppr-shell-resume.md "The header
913
+ * doctrine".
906
914
  */
907
915
  export function assertCachedHeaderWriteAllowed(
908
916
  surface: string,
@@ -910,21 +918,37 @@ export function assertCachedHeaderWriteAllowed(
910
918
  ): void {
911
919
  const scope = RangoContext.getStore()?.cachedHeaderScope;
912
920
  if (!scope) return;
913
- const insideLoader = isInsideAnyLoaderScope();
914
- if (scope.kind === "cache" && insideLoader) return;
921
+ // Exempt DSL loaders (loaderScopeALS) ONLY. A registered loader re-runs on
922
+ // every cache HIT (fresh.ts runInsideLoaderScope -> cache-lookup.ts
923
+ // resolveLoadersOnly), so its header/cookie writes merge into every response
924
+ // with no MISS/HIT divergence. A handler-invoked loader body has
925
+ // loaderBodyScopeALS active but loaderScopeALS unset (loader-resolution.ts
926
+ // derives isDslLoader from isInsideLoaderScope()); on a HIT the handler is
927
+ // skipped so that loader never re-runs and its write would land only on the
928
+ // MISS — throw it. isInsideLoaderScope() (not isInsideAnyLoaderScope) is the
929
+ // discriminator; the DSL scope ALS survives nested ctx.use bodies, so a
930
+ // handler-invoked loader nested under a DSL loader stays exempt (its DSL
931
+ // parent re-invokes it on every HIT). This is the exempt fast path, so the
932
+ // broad predicate for the error label is deferred to the throw path below.
933
+ if (scope.kind === "cache" && isInsideLoaderScope()) return;
915
934
  // Everything below runs only on the throw path — `surfaceProp` exists so
916
935
  // callers pass constants and the success path allocates nothing (the full
917
- // surface, e.g. "ctx.headers.set()", is assembled here).
936
+ // surface, e.g. "ctx.headers.set()", is assembled here). isInsideAnyLoaderScope
937
+ // (broad) labels a now-throwing handler-invoked loader body "loader".
918
938
  const fullSurface =
919
939
  surfaceProp === undefined ? surface : `${surface}.${String(surfaceProp)}()`;
920
- const layer = insideLoader ? "loader" : "handler";
940
+ const layer = isInsideAnyLoaderScope() ? "loader" : "handler";
921
941
  const route = scope.routeKey ? ` (route "${scope.routeKey}")` : "";
922
942
  const where =
923
943
  scope.kind === "ppr"
924
944
  ? `on a ppr route${route} — the document shell is cached and replayed`
925
945
  : `inside a cache() boundary${route}`;
946
+ // ppr loader writes fail by physics (headers flush before loaders settle);
947
+ // every other throw — a handler, or a handler-invoked loader under cache() —
948
+ // fails because the handler is skipped on a HIT, so key the reason on the
949
+ // scope kind, not the layer.
926
950
  const why =
927
- layer === "loader"
951
+ scope.kind === "ppr" && layer === "loader"
928
952
  ? "The response headers flush with the shell before loaders settle, so this write is dropped on cache hits."
929
953
  : "On a cache hit the handler is skipped, so this write would silently vanish.";
930
954
  throw new Error(