@rangojs/router 0.17.0 → 0.18.0

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 (152) hide show
  1. package/README.md +3 -2
  2. package/dist/types/browser/cookie-name.d.ts +6 -0
  3. package/dist/types/browser/history-state.d.ts +16 -0
  4. package/dist/types/cache/cache-key-utils.d.ts +17 -2
  5. package/dist/types/cache/cache-policy.d.ts +17 -0
  6. package/dist/types/cache/cache-scope.d.ts +98 -14
  7. package/dist/types/cache/cache-tag.d.ts +92 -7
  8. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  9. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  10. package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
  11. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  12. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  13. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
  14. package/dist/types/cache/handle-snapshot.d.ts +4 -3
  15. package/dist/types/cache/index.d.ts +1 -0
  16. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  17. package/dist/types/cache/shell-memo.d.ts +178 -0
  18. package/dist/types/cache/shell-snapshot.d.ts +110 -21
  19. package/dist/types/cache/tag-invalidation.d.ts +30 -3
  20. package/dist/types/cache/types.d.ts +207 -43
  21. package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
  22. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  23. package/dist/types/router/instrument.d.ts +8 -0
  24. package/dist/types/router/middleware-types.d.ts +6 -1
  25. package/dist/types/router/segment-resolution/loader-cache.d.ts +40 -11
  26. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +21 -10
  27. package/dist/types/router/segment-resolution/mask-nested.d.ts +65 -24
  28. package/dist/types/rsc/full-payload.d.ts +14 -0
  29. package/dist/types/rsc/handler.d.ts +1 -1
  30. package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
  31. package/dist/types/rsc/shell-capture-constants.d.ts +53 -6
  32. package/dist/types/rsc/shell-capture.d.ts +146 -15
  33. package/dist/types/rsc/shell-serve.d.ts +140 -17
  34. package/dist/types/rsc/types.d.ts +7 -0
  35. package/dist/types/server/context.d.ts +109 -15
  36. package/dist/types/server/cookie-store.d.ts +21 -0
  37. package/dist/types/server/request-context.d.ts +159 -33
  38. package/dist/types/ssr/index.d.ts +9 -0
  39. package/dist/types/ssr/ssr-root.d.ts +9 -8
  40. package/dist/types/testing/flight.entry.d.ts +7 -0
  41. package/dist/types/testing/run-loader.d.ts +18 -3
  42. package/dist/types/testing/serve-shell-request.d.ts +132 -0
  43. package/dist/types/testing/shell-status.d.ts +23 -10
  44. package/dist/types/types/handler-context.d.ts +14 -1
  45. package/dist/types/urls/pattern-types.d.ts +22 -18
  46. package/dist/types/vite/router-discovery.d.ts +29 -2
  47. package/dist/vite/index.js +46 -9
  48. package/package.json +1 -1
  49. package/skills/breadcrumbs/SKILL.md +17 -7
  50. package/skills/cache-guide/SKILL.md +123 -51
  51. package/skills/caching/SKILL.md +253 -23
  52. package/skills/catalog.json +1 -1
  53. package/skills/client-urls/SKILL.md +11 -6
  54. package/skills/cloudflare/SKILL.md +15 -0
  55. package/skills/comparison/references/framework-comparison.md +44 -32
  56. package/skills/defer-hydration/SKILL.md +50 -30
  57. package/skills/deployment-caching/SKILL.md +5 -2
  58. package/skills/document-cache/SKILL.md +8 -2
  59. package/skills/handler-use/SKILL.md +2 -0
  60. package/skills/hooks/handle-and-actions.md +3 -1
  61. package/skills/hooks/state.md +3 -1
  62. package/skills/loader/SKILL.md +161 -39
  63. package/skills/middleware/SKILL.md +16 -9
  64. package/skills/migrate-nextjs/SKILL.md +67 -36
  65. package/skills/migrate-react-router/SKILL.md +2 -1
  66. package/skills/migrate-react-router/component-migration.md +3 -1
  67. package/skills/migrate-react-router/data-and-actions.md +3 -1
  68. package/skills/migrate-react-router/route-mapping.md +7 -4
  69. package/skills/observability/SKILL.md +64 -0
  70. package/skills/parallel/SKILL.md +13 -8
  71. package/skills/ppr/SKILL.md +655 -363
  72. package/skills/prerender/SKILL.md +27 -14
  73. package/skills/rango/SKILL.md +52 -33
  74. package/skills/route/SKILL.md +12 -7
  75. package/skills/router-setup/SKILL.md +4 -2
  76. package/skills/scripts/SKILL.md +8 -1
  77. package/skills/server-actions/SKILL.md +11 -0
  78. package/skills/shell-manifest/SKILL.md +21 -12
  79. package/skills/testing/SKILL.md +25 -21
  80. package/skills/testing/cache-prerender.md +15 -14
  81. package/skills/testing/loader.md +18 -1
  82. package/skills/testing/serve-shell-request.md +188 -0
  83. package/skills/theme/SKILL.md +52 -4
  84. package/skills/typesafety/env-and-bindings.md +10 -2
  85. package/skills/use-cache/SKILL.md +39 -16
  86. package/skills/vercel/SKILL.md +14 -2
  87. package/skills/view-transitions/SKILL.md +1 -1
  88. package/src/browser/cookie-name.ts +23 -0
  89. package/src/browser/history-state.ts +23 -0
  90. package/src/browser/rsc-router.tsx +5 -0
  91. package/src/cache/cache-key-utils.ts +22 -2
  92. package/src/cache/cache-policy.ts +22 -0
  93. package/src/cache/cache-runtime.ts +34 -7
  94. package/src/cache/cache-scope.ts +345 -33
  95. package/src/cache/cache-tag.ts +375 -11
  96. package/src/cache/cf/cf-base64.ts +36 -4
  97. package/src/cache/cf/cf-cache-constants.ts +12 -0
  98. package/src/cache/cf/cf-cache-store.ts +990 -348
  99. package/src/cache/cf/cf-cache-types.ts +32 -4
  100. package/src/cache/cf/cf-shell-frame.ts +258 -0
  101. package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
  102. package/src/cache/handle-snapshot.ts +4 -3
  103. package/src/cache/index.ts +2 -0
  104. package/src/cache/isolate-tag-memo.ts +308 -0
  105. package/src/cache/shell-memo.ts +341 -0
  106. package/src/cache/shell-snapshot.ts +217 -27
  107. package/src/cache/tag-invalidation.ts +104 -19
  108. package/src/cache/types.ts +211 -43
  109. package/src/cache/vercel/vercel-cache-store.ts +545 -61
  110. package/src/prerender/build-shell-capture.ts +65 -11
  111. package/src/route-definition/dsl-helpers.ts +4 -3
  112. package/src/router/handler-context.ts +38 -4
  113. package/src/router/instrument.ts +54 -2
  114. package/src/router/intercept-resolution.ts +19 -7
  115. package/src/router/loader-resolution.ts +63 -32
  116. package/src/router/match-api.ts +28 -9
  117. package/src/router/match-handlers.ts +5 -0
  118. package/src/router/match-middleware/cache-lookup.ts +17 -0
  119. package/src/router/match-middleware/cache-store.ts +31 -11
  120. package/src/router/middleware-types.ts +6 -1
  121. package/src/router/middleware.ts +23 -4
  122. package/src/router/prerender-match.ts +6 -0
  123. package/src/router/segment-resolution/fresh.ts +18 -9
  124. package/src/router/segment-resolution/helpers.ts +4 -2
  125. package/src/router/segment-resolution/loader-cache.ts +227 -56
  126. package/src/router/segment-resolution/loader-snapshot.ts +203 -51
  127. package/src/router/segment-resolution/mask-nested.ts +136 -42
  128. package/src/router/segment-resolution/revalidation.ts +27 -10
  129. package/src/rsc/full-payload.ts +25 -1
  130. package/src/rsc/handler.ts +14 -2
  131. package/src/rsc/progressive-enhancement.ts +3 -2
  132. package/src/rsc/response-cache-serve.ts +9 -6
  133. package/src/rsc/rsc-rendering.ts +601 -181
  134. package/src/rsc/shell-build-manifest.ts +27 -9
  135. package/src/rsc/shell-capture-constants.ts +60 -6
  136. package/src/rsc/shell-capture.ts +1001 -250
  137. package/src/rsc/shell-serve.ts +316 -34
  138. package/src/rsc/types.ts +7 -0
  139. package/src/server/context.ts +219 -28
  140. package/src/server/cookie-store.ts +87 -62
  141. package/src/server/request-context.ts +263 -38
  142. package/src/ssr/index.tsx +96 -37
  143. package/src/ssr/ssr-root.tsx +9 -8
  144. package/src/testing/flight.entry.ts +14 -0
  145. package/src/testing/run-loader.ts +63 -5
  146. package/src/testing/serve-shell-request.ts +378 -0
  147. package/src/testing/shell-status.ts +34 -12
  148. package/src/theme/ThemeProvider.tsx +7 -6
  149. package/src/types/handler-context.ts +14 -1
  150. package/src/urls/pattern-types.ts +22 -18
  151. package/src/vite/discovery/shell-prerender-phase.ts +9 -0
  152. package/src/vite/router-discovery.ts +14 -4
@@ -17,8 +17,9 @@
17
17
  */
18
18
 
19
19
  import React from "react";
20
- import { isLoaderDataResult } from "../types.js";
21
- import { bufferToBase64 } from "../cache/cf/cf-base64.js";
20
+ import { isLoaderDataResult, type MatchResult } from "../types.js";
21
+ import { bytesToBase64 } from "../cache/cf/cf-base64.js";
22
+ import { SHELL_BAKE_TAG_OWNER, getSegmentTags } from "../cache/cache-tag.js";
22
23
  import { reportCacheError } from "../cache/cache-error.js";
23
24
  import { runBackground } from "../cache/background-task.js";
24
25
  import {
@@ -41,10 +42,17 @@ import {
41
42
  wireRenderBarrier,
42
43
  UNTRACKED_BACKGROUND_TASK,
43
44
  type RequestContext,
45
+ type RouteRecordWindow,
44
46
  } from "../server/request-context.js";
47
+ import {
48
+ DEFAULT_FUNCTION_TTL,
49
+ resolveSwrWindow,
50
+ resolveTtl,
51
+ } from "../cache/cache-policy.js";
45
52
  import { createHandleStore, type HandleStore } from "../server/handle-store.js";
46
53
  import {
47
54
  maskNestedContainerThenables,
55
+ settleNestedThenables,
48
56
  type MaskReport,
49
57
  } from "../router/segment-resolution/mask-nested.js";
50
58
  import {
@@ -53,6 +61,7 @@ import {
53
61
  } from "../server/context.js";
54
62
  import { isThenable } from "../handles/is-thenable.js";
55
63
  import type {
64
+ CachedEntryData,
56
65
  ShellCacheEntry,
57
66
  SegmentCacheStore,
58
67
  ShellSnapshotRecord,
@@ -64,11 +73,16 @@ import {
64
73
  import {
65
74
  RecordingShellStore,
66
75
  SnapshotOnlySegmentStore,
76
+ countSnapshotFamilies,
77
+ estimateShellEntryBytes,
67
78
  getRecordingStore,
79
+ hasDocRecord,
80
+ pruneShellSnapshot,
81
+ type ShellSnapshotReaders,
68
82
  } from "../cache/shell-snapshot.js";
69
83
  import type { HandlerContext } from "./handler-context.js";
70
84
  import type { SSRModule } from "./types.js";
71
- import { buildFullPayload } from "./full-payload.js";
85
+ import { buildFullPayload, payloadInitialTheme } from "./full-payload.js";
72
86
  import { resolveDeferredHandleValues } from "../handles/deferred-resolution.js";
73
87
  import { renderRscFlightStage } from "./render-pipeline.js";
74
88
  import { stripInternalParams } from "../router/handler-context.js";
@@ -359,8 +373,9 @@ function clearCaptureBackoff(key: string): void {
359
373
  const warnedNullCaptures = new Set<string>();
360
374
 
361
375
  /**
362
- * Warn once per key that a capture produced no usable shell EVEN AFTER the
363
- * in-place retry (runShellCapture attempt 2). Naming both causes with the
376
+ * Warn once per key that a capture produced no usable shell: after the
377
+ * in-place retry (runShellCapture attempt 2), or after one attempt that ran
378
+ * out of its deadline (no retry: noShellCauses). Naming both causes with the
364
379
  * distinguishing signal — does the route ever flip to HIT — is the whole point:
365
380
  * the pre-retry version blamed "a loader route without loading()" unconditionally
366
381
  * and misled users whose route DID have loading() and was merely cold. Because the
@@ -371,25 +386,71 @@ const warnedNullCaptures = new Set<string>();
371
386
  * The pointer is the shared PPR_LANE_HINT: the /ppr skill ships in the npm
372
387
  * tarball, so the path resolves for consumers (a05c8251 convention).
373
388
  */
374
- function warnNullCaptureOnce(key: string): void {
389
+ function warnNullCaptureOnce(key: string, retried: boolean): void {
390
+ const pendingStacks = noShellPendingStacks.get(key);
391
+ noShellPendingStacks.delete(key);
392
+ const cause = noShellCauses.get(key);
393
+ noShellCauses.delete(key);
375
394
  if (warnedNullCaptures.has(key)) return;
376
395
  warnedNullCaptures.add(key);
377
396
  console.warn(
378
- `[rango] Shell capture for "${key}" produced no usable shell after an in-place ` +
379
- "retry; nothing was stored, so this request stays on MISS. Causes, told apart " +
380
- "by whether the route ever flips to HIT:\n" +
381
- " 1. Cold-start warmup (dev module transform, or a cold worker): the capture raced " +
382
- "an unfinished shell render. This SELF-HEALS — the route flips to HIT once a later " +
383
- "request warms the modules.\n" +
384
- " 2. Something suspends above <body> with no Suspense boundary and never settles " +
385
- "within the capture window: a live loader (no ssr: false) read without loading() or " +
386
- "an inline <Suspense>; a loader(Def, { ssr: false }) or top-level push (e.g. Meta) " +
387
- "slower than ppr.captureTimeout; or a pending promise with no Suspense above it. The " +
388
- "boundary belongs to the entry or component that owns the data.\n" +
397
+ `[rango] Shell capture for "${key}" produced no usable shell` +
398
+ (retried ? " after an in-place retry" : "") +
399
+ "; nothing was stored, so this request stays on MISS. " +
400
+ (cause
401
+ ? `Cause: ${cause}. Every capture waits for the handler layer to finish and ` +
402
+ 'bakes it; make the slow part cheaper (cache() or "use cache"), raise ' +
403
+ "ppr.captureTimeout, or move it into a live loader (no ssr: false) read under " +
404
+ "loading() or an inline <Suspense>.\n"
405
+ : "Causes, told apart by whether the route ever flips to HIT:\n" +
406
+ " 1. Cold-start warmup (dev module transform, or a cold worker): the capture raced " +
407
+ "an unfinished shell render. This SELF-HEALS — the route flips to HIT once a later " +
408
+ "request warms the modules.\n" +
409
+ " 2. Something suspends above <body> with no Suspense boundary and never settles " +
410
+ "within the capture window: a live loader (no ssr: false) read without loading() or " +
411
+ "an inline <Suspense>, or a loader(Def, { ssr: false }) or top-level push (e.g. Meta) " +
412
+ "slower than ppr.captureTimeout. The boundary belongs to the entry or component " +
413
+ "that owns the data.\n") +
414
+ (pendingStacks && pendingStacks.length > 0
415
+ ? "Components still pending when the capture froze the shell (one of them " +
416
+ "suspended above <body>):\n" +
417
+ pendingStacks.map(formatPendingStack).join("\n") +
418
+ "\n"
419
+ : "") +
389
420
  PPR_LANE_HINT,
390
421
  );
391
422
  }
392
423
 
424
+ /**
425
+ * Why the latest no-shell attempt per key ran out of ppr.captureTimeout (the
426
+ * match, the handler output, or the prerender after them). Set only for a
427
+ * deadline outcome, so runShellCapture also reads it to skip the in-place
428
+ * retry. Printed (and cleared) by warnNullCaptureOnce.
429
+ */
430
+ const noShellCauses = new Map<string, string>();
431
+
432
+ /**
433
+ * Dev only: component stacks of the tasks still pending at the latest
434
+ * no-shell attempt's abort, per key, printed (and cleared) by
435
+ * warnNullCaptureOnce. React reports them through prerender's onError
436
+ * (captureShellHTML's onAbortedTask).
437
+ */
438
+ const noShellPendingStacks = new Map<string, string[]>();
439
+
440
+ /** Stacks kept per attempt: the root pin is almost always among the first. */
441
+ const MAX_PENDING_STACKS = 3;
442
+
443
+ /** The first lines of one component stack, indented under the warning. */
444
+ function formatPendingStack(stack: string): string {
445
+ return stack
446
+ .split("\n")
447
+ .map((line) => line.trim())
448
+ .filter(Boolean)
449
+ .slice(0, 8)
450
+ .map((line) => ` ${line}`)
451
+ .join("\n");
452
+ }
453
+
393
454
  /** Keys already warned about a deterministic capture refusal (once per key). */
394
455
  const warnedRefusedCaptures = new Set<string>();
395
456
 
@@ -415,6 +476,71 @@ function warnCaptureRefusedOnce(
415
476
  );
416
477
  }
417
478
 
479
+ /**
480
+ * Refuse the capture when a capture guard tripped: a request-scoped read
481
+ * (cookies(), headers(), a { cache: false } variable) or ctx.dynamic() made
482
+ * anywhere the capture waited for — a handler, a promise it passes or pushes,
483
+ * an async server component, a bake-lane loader. The read throws where it
484
+ * happens, but the code can catch that throw, so the flag the guard sets on
485
+ * the capture context is what decides (server/context.ts
486
+ * assertNotInsideShellCapture). Returns true when it refused (and warned).
487
+ */
488
+ function refuseOnCaptureGuard(
489
+ key: string,
490
+ derivedCtx: RequestContext<any>,
491
+ ): boolean {
492
+ if (derivedCtx._dynamic) {
493
+ // A build-time capture reports dynamic() as its own outcome
494
+ // (build-shell-capture.ts): the URL keeps runtime capture.
495
+ if (derivedCtx.build) return true;
496
+ warnCaptureRefusedOnce(
497
+ key,
498
+ "ctx.dynamic() was called while capturing the shared shell (in a handler, or " +
499
+ "in a promise or async component the capture waited for). A dynamic render " +
500
+ "has no shell; the route keeps serving axis 1 for requests that opt out.",
501
+ );
502
+ return true;
503
+ }
504
+ const trip = derivedCtx._shellCaptureGuardTripped;
505
+ if (!trip) return false;
506
+ // Name the recorded source instead of hardcoding a lane: a trip can come
507
+ // from a bake-lane SEGMENT loader or from handler/render code, including a
508
+ // loader a handler awaits (issue #672). The guard recorded the read and its
509
+ // fix (server/context.ts tripShellCaptureGuard): cookies()/headers(), a
510
+ // { cache: false } variable, and the theme reads (#971).
511
+ const loaderId = derivedCtx._shellCaptureGuardTrippedLoaderId;
512
+ const origin = loaderId
513
+ ? `the loader "${loaderId}"`
514
+ : "handler/render code (no loader body was executing)";
515
+ warnCaptureRefusedOnce(
516
+ key,
517
+ `${origin} read ${trip.surface} during capture; request-scoped data must not ` +
518
+ `bake into the shared shell. ${trip.fix}`,
519
+ PPR_LANE_HINT,
520
+ );
521
+ return true;
522
+ }
523
+
524
+ /** The no-shell cause for handler output that missed the capture deadline. */
525
+ const HANDLER_OUTPUT_TIMEOUT_REASON: string =
526
+ "the handler output (a promise it passes or pushes, or an async server " +
527
+ "component) did not settle within ppr.captureTimeout";
528
+
529
+ /** The refusal for a capture that stored no doc record (a HIT needs it). */
530
+ const NO_DOC_RECORD_REASON: string =
531
+ "the capture produced no doc segment record, so a HIT could not replay the " +
532
+ "handler layer. Either the route's cache() refused the write (cache(false), or " +
533
+ "a condition() that returned false for the capture), or rendering the handler " +
534
+ "output failed (see the cache-write error reported for this key).";
535
+
536
+ /**
537
+ * Default limit (bytes) on a whole stored shell entry — prelude, postponed
538
+ * state, and snapshot — when the store declares none
539
+ * (SegmentCacheStore.maxShellEntryBytes). Cloudflare KV's 25 MiB value limit:
540
+ * the tightest common store tier a shell entry lands in.
541
+ */
542
+ export const DEFAULT_SHELL_ENTRY_MAX_BYTES: number = 25 * 1024 * 1024;
543
+
418
544
  /**
419
545
  * Default cap (serialized UTF-8 bytes) on the capture data snapshot riding
420
546
  * inside a shell entry, when the route's `ppr` option does not set
@@ -424,8 +550,9 @@ function warnCaptureRefusedOnce(
424
550
  * while still fitting any sane pinned-ring payload. Applied ONLY in
425
551
  * captureAndStoreShell (the single defaulting site — resolvePprConfig passes
426
552
  * the option through undefaulted), so every producer and direct caller gets
427
- * the same policy. Over the cap the snapshot is skipped (shell still stored;
428
- * pinned reads drift — see PartialPrerenderProps.maxSnapshotBytes).
553
+ * the same policy. The doc record is exempt; over the cap the other records
554
+ * are dropped (shell still stored and replayed; the pins' loaders read the
555
+ * live store — see PartialPrerenderProps.maxSnapshotBytes).
429
556
  */
430
557
  export const DEFAULT_PPR_MAX_SNAPSHOT_BYTES: number = 8 * 1024 * 1024;
431
558
 
@@ -436,12 +563,13 @@ const SNAPSHOT_BYTE_ENCODER = new TextEncoder();
436
563
  const warnedOverCapSnapshots = new Set<string>();
437
564
 
438
565
  /**
439
- * Warn once per key that the capture data snapshot exceeded the route's
440
- * `maxSnapshotBytes` cap and was skipped. The shell entry is still stored and
441
- * served — only the pinned-read replay is lost, so shell-baked cached content
442
- * can drift from the frozen prelude between capture and HIT and hydration
443
- * repairs it client-side (the pre-snapshot behavior). Once per key: the same
444
- * page recaptures on every TTL roll and would otherwise re-warn forever.
566
+ * Warn once per key that the capture's loader pins exceeded the route's
567
+ * `maxSnapshotBytes` cap and were dropped. The shell entry is still stored
568
+ * with its doc record and every HIT replays the handler layer; only the
569
+ * pinned loaders lose their captured values, so their cached data can drift
570
+ * from the frozen prelude and hydration repairs it client-side. Once per
571
+ * key: the same page recaptures on every TTL roll and would otherwise
572
+ * re-warn forever.
445
573
  */
446
574
  function warnSnapshotOverCapOnce(
447
575
  key: string,
@@ -451,14 +579,15 @@ function warnSnapshotOverCapOnce(
451
579
  if (warnedOverCapSnapshots.has(key)) return;
452
580
  warnedOverCapSnapshots.add(key);
453
581
  console.warn(
454
- `[rango] Shell capture for "${key}" recorded a ${snapshotBytes}-byte data ` +
455
- `snapshot, over the ${capBytes}-byte cap — the snapshot was skipped and ` +
456
- "the shell was stored without it. The page keeps serving, but cached " +
457
- "content baked into the shell is no longer pinned: if it drifts before " +
458
- "the shell's TTL, hydration repairs the mismatch client-side. Raise the " +
459
- "cap via the route's ppr option ({ maxSnapshotBytes }) if the entry " +
460
- "still fits your store's value limit (Cloudflare KV: 25 MiB per value), " +
461
- "or shrink the cache()'d data the shell bakes.",
582
+ `[rango] Shell capture for "${key}" recorded ${snapshotBytes} bytes of ` +
583
+ `loader pins, over the ${capBytes}-byte cap — the pins were dropped and ` +
584
+ "the shell was stored with its doc record only. HITs still replay the " +
585
+ "handler layer, but ssr: false loaders now read the live store: if " +
586
+ "their data drifts before the shell's TTL, hydration repairs the " +
587
+ "mismatch client-side. Raise the cap via the route's ppr option " +
588
+ "({ maxSnapshotBytes }) if the entry still fits your store's value limit " +
589
+ "(Cloudflare KV: 25 MiB per value), or shrink the cache()'d data the " +
590
+ "loaders read.",
462
591
  );
463
592
  }
464
593
 
@@ -479,8 +608,8 @@ const warnedBakeCosts = new Set<string>();
479
608
 
480
609
  /**
481
610
  * Dev-only, once per key: name the slowest bake source and the three remedies
482
- * in preference order. The doctrine ("nesting = liveness") makes the cost
483
- * legitimate, so this is a cost report with an exit, not a deprecation.
611
+ * in preference order. Handler output and bake-lane values are shell material
612
+ * by design, so this is a cost report with an exit, not a deprecation.
484
613
  */
485
614
  function warnBakeCostOnce(key: string, source: string, ms: number): void {
486
615
  if (warnedBakeCosts.has(key)) return;
@@ -489,10 +618,11 @@ function warnBakeCostOnce(key: string, source: string, ms: number): void {
489
618
  `[rango] Shell capture for "${key}" waited ${ms}ms for ${source} to ` +
490
619
  "settle before the shell could freeze. This cost recurs on every capture " +
491
620
  "of the route and occupies the per-isolate capture queue; the served " +
492
- "response never shows it. To make the value per-request, nest it " +
493
- "({ data: promise }) and read it with use() under Suspense; to keep it " +
494
- "baked but cheap, wrap the work in cache(); for a loader(Def, { ssr: false }), " +
495
- "drop ssr: false to move it to the live lane. " +
621
+ "response never shows it. To keep it baked but cheap, wrap the work in " +
622
+ 'cache() or "use cache"; to make it per-request, load it in a live ' +
623
+ "loader (no ssr: false) read under loading() or an inline <Suspense>; for " +
624
+ "a loader(Def, { ssr: false }), return the slow part as a nested promise " +
625
+ "or drop ssr: false. " +
496
626
  PPR_LANE_HINT,
497
627
  );
498
628
  }
@@ -509,10 +639,13 @@ export interface ShellCaptureDebugEvent {
509
639
  key: string;
510
640
  /**
511
641
  * What happened:
512
- * - stored / redirect / no-shell / refused: one capture ATTEMPT's outcome
513
- * (see CaptureAttemptOutcome for the semantics of each)
642
+ * - stored / redirect / no-shell / refused / expired: one capture
643
+ * ATTEMPT's outcome (see CaptureAttemptOutcome for the semantics of each)
514
644
  * - error: the capture task failed with a genuine error (also routed through
515
645
  * reportCacheError; the key is backed off)
646
+ * - skip-stored: a capture for the key stored after the scheduling request
647
+ * read the store and missed (ShellCaptureDescriptor.storedSeqAtRead), so
648
+ * the shell that request missed exists now
516
649
  * - skip-in-flight: scheduleShellCapture found a capture already running for
517
650
  * the key (stampede guard) and scheduled nothing
518
651
  * - skip-backoff: the key is inside its refused-capture backoff window and
@@ -535,8 +668,10 @@ export interface ShellCaptureDebugEvent {
535
668
  | "redirect"
536
669
  | "no-shell"
537
670
  | "refused"
671
+ | "expired"
538
672
  | "error"
539
673
  | "skip-in-flight"
674
+ | "skip-stored"
540
675
  | "skip-backoff"
541
676
  | "skip-capacity"
542
677
  | "skip-inert-store"
@@ -588,6 +723,14 @@ export interface ShellCaptureDebugEvent {
588
723
  * it attributable. Absent when nothing held the gate.
589
724
  */
590
725
  bakeWaitMs?: number;
726
+ /**
727
+ * How long the capture waited for its doc record: the handler layer's
728
+ * promises, async server components and handle pushes settling, then the
729
+ * record's encode (settleCaptureRecord). Part of the one capture deadline.
730
+ */
731
+ recordSettleMs?: number;
732
+ /** Bytes of the stored entry: prelude, postponed state, and snapshot. */
733
+ entryBytes?: number;
591
734
  }
592
735
 
593
736
  /**
@@ -646,6 +789,12 @@ export function describeShellCaptureEvent(
646
789
  if (event.bakeWaitMs !== undefined) {
647
790
  parts.push(`bake=${event.bakeWaitMs}ms`);
648
791
  }
792
+ if (event.recordSettleMs !== undefined) {
793
+ parts.push(`record=${event.recordSettleMs}ms`);
794
+ }
795
+ if (event.entryBytes !== undefined) {
796
+ parts.push(`entry=${event.entryBytes}b`);
797
+ }
649
798
  return parts.join(" ");
650
799
  }
651
800
 
@@ -681,6 +830,7 @@ const TIMING_RECORDED_OUTCOMES = new Set<ShellCaptureDebugEvent["outcome"]>([
681
830
  "redirect",
682
831
  "no-shell",
683
832
  "refused",
833
+ "expired",
684
834
  "error",
685
835
  ]);
686
836
 
@@ -974,6 +1124,33 @@ export interface ShellCaptureDescriptor {
974
1124
  debugSink?: (event: ShellCaptureDebugEvent) => void;
975
1125
  /** Store the snapshot for navigation replay, never the captured HTML prelude. */
976
1126
  navigationOnly?: true;
1127
+ /**
1128
+ * lastStoredCaptureSeq(key) when a document request read the store and
1129
+ * missed. The request schedules its capture only after rendering the MISS,
1130
+ * so a capture another request started can store in between; a later
1131
+ * sequence number then skips the redundant one (`skip-stored`), which would
1132
+ * re-render the page and drop the isolate's memo of the shell just stored.
1133
+ */
1134
+ storedSeqAtRead?: number;
1135
+ }
1136
+
1137
+ /** Per key, the sequence number of its latest stored capture (bounded). */
1138
+ const storedCaptureSeqs = new Map<string, number>();
1139
+ const STORED_CAPTURE_SEQS_MAX = 1_000;
1140
+ let storedCaptureSeq = 0;
1141
+
1142
+ /** The latest stored capture's sequence number for `key` in this isolate, or 0. */
1143
+ export function lastStoredCaptureSeq(key: string): number {
1144
+ return storedCaptureSeqs.get(key) ?? 0;
1145
+ }
1146
+
1147
+ function noteCaptureStored(key: string): void {
1148
+ storedCaptureSeqs.delete(key);
1149
+ if (storedCaptureSeqs.size >= STORED_CAPTURE_SEQS_MAX) {
1150
+ const oldest = storedCaptureSeqs.keys().next().value;
1151
+ if (oldest !== undefined) storedCaptureSeqs.delete(oldest);
1152
+ }
1153
+ storedCaptureSeqs.set(key, ++storedCaptureSeq);
977
1154
  }
978
1155
 
979
1156
  /**
@@ -1011,6 +1188,13 @@ export function scheduleShellCapture(
1011
1188
  // new token, and the stale task's token-guarded release can no longer
1012
1189
  // touch it.
1013
1190
  }
1191
+ if (
1192
+ descriptor.storedSeqAtRead !== undefined &&
1193
+ lastStoredCaptureSeq(key) > descriptor.storedSeqAtRead
1194
+ ) {
1195
+ publishCaptureDebugEvent(descriptor, { key, outcome: "skip-stored" });
1196
+ return;
1197
+ }
1014
1198
  // Refused/failed within the window → skip the doomed re-render (one probe per
1015
1199
  // key per window per isolate). Expired entries self-evict inside the check.
1016
1200
  if (isCaptureBackedOff(key)) {
@@ -1076,9 +1260,12 @@ export function scheduleShellCapture(
1076
1260
  // Update the negative cache off the terminal outcome. A stored shell clears
1077
1261
  // any prior backoff; a `no-shell` (after the in-place retry) backs the key
1078
1262
  // off so the next requests don't re-probe it. A `redirect` has no shell but
1079
- // is not a doomed render — leave the backoff untouched.
1080
- if (outcome === "stored") clearCaptureBackoff(key);
1081
- else if (outcome === "no-shell") {
1263
+ // is not a doomed render, and an `expired` retry lost a second record to
1264
+ // the same race — leave the backoff untouched.
1265
+ if (outcome === "stored") {
1266
+ clearCaptureBackoff(key);
1267
+ noteCaptureStored(key);
1268
+ } else if (outcome === "no-shell") {
1082
1269
  markCaptureBackoff(key);
1083
1270
  publishCaptureDebugEvent(descriptor, {
1084
1271
  key,
@@ -1191,6 +1378,91 @@ export function scheduleShellCapture(
1191
1378
  runBackground(reqCtx, serializedTask);
1192
1379
  }
1193
1380
 
1381
+ /**
1382
+ * The ttl/swr a shell is stored with: the route's ppr policy, capped by the
1383
+ * route cache() records the capture replayed or wrote
1384
+ * (RequestContext._routeRecordWindow). A HIT replays the capture's handler
1385
+ * output, while a client navigation reads the route's cache() tier first, so
1386
+ * a shell that outlived its record would show a document request other
1387
+ * handler output than a navigation of the same URL. Freshness and total
1388
+ * lifetime are capped separately, like the record's own SWR: the shell is
1389
+ * fresh while the record is (at most `ttl`), and servable while the record
1390
+ * is (at most `ttl + swr`), so a record already inside its swr window yields
1391
+ * a shell that is stale from the start and serves while it recaptures.
1392
+ *
1393
+ * Whole seconds, rounded up (the SegmentCacheStore.putShell contract), so the
1394
+ * shell may outlast the record by under a second. Null when the record's
1395
+ * total lifetime has run out: the shell would be dead on arrival.
1396
+ */
1397
+ export function capShellWindow(
1398
+ ttl: number,
1399
+ swr: number,
1400
+ record: Pick<RouteRecordWindow, "freshUntil" | "staleUntil">,
1401
+ now: number,
1402
+ ): { ttl: number; swr: number } | null {
1403
+ const total = Math.min(ttl + swr, (record.staleUntil - now) / 1000);
1404
+ if (!(total > 0)) return null;
1405
+ // At most `total`: freshUntil <= staleUntil and swr >= 0.
1406
+ const fresh = Math.max(0, Math.min(ttl, (record.freshUntil - now) / 1000));
1407
+ const wholeTtl = Math.ceil(fresh);
1408
+ return { ttl: wholeTtl, swr: Math.ceil(total) - wholeTtl };
1409
+ }
1410
+
1411
+ /** Routes already warned that their cache() entry expires before a capture. */
1412
+ const warnedExpiredRecordRoutes = new Set<string>();
1413
+
1414
+ /**
1415
+ * Warn once per route (dev and production) that no shell was stored because
1416
+ * the route cache() entry ran out before the capture could store one
1417
+ * (capShellWindow returned null) where a retry cannot help: the capture
1418
+ * wrote the entry itself (`written`), or the in-place retry's entry ran out
1419
+ * too, or no budget was left to retry. The key backs off like a refused
1420
+ * capture.
1421
+ */
1422
+ function warnRecordRanOutOnce(
1423
+ route: string,
1424
+ record: RouteRecordWindow,
1425
+ detail: string,
1426
+ ): void {
1427
+ if (warnedExpiredRecordRoutes.has(route)) return;
1428
+ warnedExpiredRecordRoutes.add(route);
1429
+ console.warn(
1430
+ `[rango] Route "${route}": its cache() entry (ttl ${record.ttl}, swr ` +
1431
+ `${record.swr}) ran out before the shell capture could store a shell ` +
1432
+ `(${detail}), so no shell is stored and the route renders without ` +
1433
+ "one. A shell never outlives the route cache() entry it replays: give " +
1434
+ "that cache() a ttl or swr longer than the capture takes, queue wait " +
1435
+ "included.",
1436
+ );
1437
+ }
1438
+
1439
+ /**
1440
+ * The record an `expired` attempt ran out on, per key, for the warning
1441
+ * runShellCapture prints when that outcome is terminal. Set and consumed
1442
+ * within one capture task.
1443
+ */
1444
+ const expiredRecords = new Map<
1445
+ string,
1446
+ { route: string; record: RouteRecordWindow; captureMs: number }
1447
+ >();
1448
+
1449
+ /**
1450
+ * A terminal `expired`: warn once per route with the record it ran out on,
1451
+ * and return `no-shell` so the caller backs the key off.
1452
+ */
1453
+ function expiredTerminal(key: string, reason: string): "no-shell" {
1454
+ const expired = expiredRecords.get(key);
1455
+ expiredRecords.delete(key);
1456
+ if (expired) {
1457
+ warnRecordRanOutOnce(
1458
+ expired.route,
1459
+ expired.record,
1460
+ `${reason}; the last attempt took ${expired.captureMs} ms`,
1461
+ );
1462
+ }
1463
+ return "no-shell";
1464
+ }
1465
+
1194
1466
  /**
1195
1467
  * The outcome of one capture attempt.
1196
1468
  * - `stored`: a usable shell was captured (and a putShell was attempted; a store
@@ -1198,10 +1470,21 @@ export function scheduleShellCapture(
1198
1470
  * the capture itself worked).
1199
1471
  * - `redirect`: the matched route redirects, so there is no shell to capture.
1200
1472
  * - `no-shell`: captureShellHTML returned null because the prelude was unusable
1201
- * or its private capture abort landed before the shell completed. This is the
1202
- * only RETRYABLE outcome.
1473
+ * or its private capture abort landed before the shell completed, or the
1474
+ * capture ran out of ppr.captureTimeout. Retried once in place, when no
1475
+ * deadline ran out (noShellCauses).
1476
+ * - `expired`: the route cache() record the capture read (not wrote) ran out
1477
+ * before the store (capShellWindow null): it was near its end (its age,
1478
+ * queue wait). Retried once in place, where a fresh match reads or renders
1479
+ * a newer record; a second `expired`, or no hard-cap budget left for the
1480
+ * retry, is terminal `no-shell` (backoff, warned once per route).
1203
1481
  */
1204
- type CaptureAttemptOutcome = "stored" | "redirect" | "no-shell" | "refused";
1482
+ type CaptureAttemptOutcome =
1483
+ | "stored"
1484
+ | "redirect"
1485
+ | "no-shell"
1486
+ | "refused"
1487
+ | "expired";
1205
1488
 
1206
1489
  function createNavigationCaptureRequest(request: Request, url: URL): Request {
1207
1490
  const headers = new Headers(request.headers);
@@ -1236,6 +1519,8 @@ type CaptureAttemptStats = Pick<
1236
1519
  | "untaggedBake"
1237
1520
  | "storeWrite"
1238
1521
  | "bakeWaitMs"
1522
+ | "recordSettleMs"
1523
+ | "entryBytes"
1239
1524
  >;
1240
1525
 
1241
1526
  /**
@@ -1249,8 +1534,9 @@ type CaptureAttemptStats = Pick<
1249
1534
  * when we froze the shell; the attempt itself warmed the module graph, so a second
1250
1535
  * attempt a short beat later usually completes the shell in the SAME background
1251
1536
  * task. That kills the old multi-request warmup where the caller had to re-issue
1252
- * several HTTP requests before a capture stuck. We retry ONLY on `no-shell`; a
1253
- * genuine render error is NOT retried — it propagates to scheduleShellCapture's
1537
+ * several HTTP requests before a capture stuck. We retry ONLY on `no-shell`
1538
+ * that did not run out of ppr.captureTimeout (noShellCauses); a genuine render
1539
+ * error is NOT retried — it propagates to scheduleShellCapture's
1254
1540
  * reportCacheError. See docs/design/ppr-shell-resume.md.
1255
1541
  *
1256
1542
  * `retryDelayMs` is a parameter (defaulting to the module const) so unit tests can
@@ -1304,24 +1590,58 @@ async function runShellCapture(
1304
1590
  return outcome;
1305
1591
  };
1306
1592
 
1593
+ const taskStartedAt = performance.now();
1307
1594
  const first = await timedAttempt(1);
1308
1595
  // "refused" is deterministic (identity guard / rejected bake-lane loader —
1309
1596
  // its own warning already fired): no retry, and the caller backs the key off
1310
1597
  // exactly like a structural no-shell.
1311
1598
  if (first === "refused") return "no-shell";
1312
- // "stored" (success) or "redirect" (no shell exists): nothing to retry.
1313
- if (first !== "no-shell") return first;
1314
-
1315
- // Attempt 1 produced no usable shell. Retry ONCE in place — the first attempt
1316
- // warmed the dev transform graph / cold worker, so attempt 2 typically completes
1317
- // the shell without another HTTP request. The concise line is gated on the
1318
- // middleware's debug flag (threaded via the descriptor) so it replaces the old
1319
- // full DOMException dump with one readable breadcrumb.
1320
- log(
1321
- `[ShellCache] capture attempt 1/2 for ${descriptor.key} aborted before shell completed (cold modules?) — retrying`,
1322
- );
1323
- await delay(retryDelayMs);
1324
- const second = await timedAttempt(2);
1599
+ let second: CaptureAttemptOutcome;
1600
+ if (first === "expired") {
1601
+ // The record attempt 1 read ran out mid-capture: a fresh match reads or
1602
+ // renders a newer one. The whole task races SHELL_CAPTURE_TASK_HARD_CAP_MS,
1603
+ // so a retry that could not finish inside it (barrier + captureTimeout)
1604
+ // is not started.
1605
+ const retryBudgetMs =
1606
+ SHELL_CAPTURE_TASK_HARD_CAP_MS - (performance.now() - taskStartedAt);
1607
+ const retryNeedsMs =
1608
+ SHELL_CAPTURE_WRITE_BARRIER_MS +
1609
+ (descriptor.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS);
1610
+ if (retryBudgetMs < retryNeedsMs) {
1611
+ return expiredTerminal(descriptor.key, "no time was left to retry");
1612
+ }
1613
+ log(
1614
+ `[ShellCache] capture attempt 1/2 for ${descriptor.key} outlived the route cache() record it read — retrying`,
1615
+ );
1616
+ second = await timedAttempt(2);
1617
+ if (second === "expired") {
1618
+ return expiredTerminal(descriptor.key, "twice in a row");
1619
+ }
1620
+ expiredRecords.delete(descriptor.key);
1621
+ } else {
1622
+ // "stored" (success) or "redirect" (no shell exists): nothing to retry.
1623
+ if (first !== "no-shell") return first;
1624
+ // Attempt 1 ran out of ppr.captureTimeout (a cause is recorded): its
1625
+ // handlers may still be running (a match is not cancellable), and a retry
1626
+ // would start the same work beside them. A cold-module abort, the retry's
1627
+ // reason, ends well inside the deadline.
1628
+ if (noShellCauses.has(descriptor.key)) {
1629
+ warnNullCaptureOnce(descriptor.key, false);
1630
+ return "no-shell";
1631
+ }
1632
+
1633
+ // Attempt 1 produced no usable shell. Retry ONCE in place — the first
1634
+ // attempt warmed the dev transform graph / cold worker, so attempt 2
1635
+ // typically completes the shell without another HTTP request. The concise
1636
+ // line is gated on the middleware's debug flag (threaded via the
1637
+ // descriptor) so it replaces the old full DOMException dump with one
1638
+ // readable breadcrumb.
1639
+ log(
1640
+ `[ShellCache] capture attempt 1/2 for ${descriptor.key} aborted before shell completed (cold modules?) — retrying`,
1641
+ );
1642
+ await delay(retryDelayMs);
1643
+ second = await timedAttempt(2);
1644
+ }
1325
1645
  if (second === "refused") return "no-shell";
1326
1646
  if (second !== "no-shell") return second;
1327
1647
 
@@ -1334,20 +1654,193 @@ async function runShellCapture(
1334
1654
  log(
1335
1655
  `[ShellCache] capture attempt 2/2 for ${descriptor.key} aborted — giving up until next request`,
1336
1656
  );
1337
- warnNullCaptureOnce(descriptor.key);
1657
+ warnNullCaptureOnce(descriptor.key, true);
1338
1658
  return "no-shell";
1339
1659
  }
1340
1660
 
1341
- /** Fold the capture's handle-liveness record into the entry flag (true | undefined). */
1342
- function handlerLayerIsLive(
1343
- liveness: RequestContext["_shellCaptureHandleLiveness"],
1344
- ): true | undefined {
1345
- if (!liveness) return undefined;
1346
- return liveness.holes ||
1347
- liveness.pendingPushes > 0 ||
1348
- liveness.handlerInvokedLoader
1349
- ? true
1350
- : undefined;
1661
+ /**
1662
+ * Who reads the snapshot this capture stores besides its doc record
1663
+ * (docs/design/shell-entry-layout.md §2): partial replay alone for a
1664
+ * navigation-only entry ("segments"), the loaders a document HIT re-runs
1665
+ * otherwise ("loaders"). Undefined when the capture recorded no doc record,
1666
+ * which only a prerender-served capture stores (its HIT tail takes the handler
1667
+ * layer from the prerender store and reads nothing else from the snapshot
1668
+ * beyond the loader records, so nothing is pruned).
1669
+ */
1670
+ function snapshotReaders(
1671
+ capture: ShellCaptureDescriptor,
1672
+ snapshot: readonly ShellSnapshotRecord[],
1673
+ docKey: string | undefined,
1674
+ ): ShellSnapshotReaders | undefined {
1675
+ if (!hasDocRecord(snapshot, docKey)) return undefined;
1676
+ return capture.navigationOnly ? "segments" : "loaders";
1677
+ }
1678
+
1679
+ /**
1680
+ * What the record-first step produced ({@link settleCaptureRecord}):
1681
+ * - `record`: the doc record settled; `match` carries its fragments in place
1682
+ * of the handler layer's elements, so the capture's Flight payload is the
1683
+ * bytes every HIT replays.
1684
+ * - `prerender`: the prerender store supplied the handler layer (a
1685
+ * Prerender route); its HIT tail replays the same build-time segments, so
1686
+ * the capture renders the match as is.
1687
+ * - `timeout`: handler output did not settle within the capture budget.
1688
+ * - `refused`: a deterministic refusal (a capture guard tripped, the route
1689
+ * opted out, or the handler output failed); the warning already fired.
1690
+ */
1691
+ export type CaptureRecordOutcome =
1692
+ | { kind: "record" | "prerender"; match: MatchResult }
1693
+ | { kind: "timeout"; reason: string }
1694
+ | { kind: "refused" };
1695
+
1696
+ /**
1697
+ * Record-first capture: the doc record is both the settle signal and the
1698
+ * parity source.
1699
+ *
1700
+ * Writing the record Flight-serializes every non-loader segment
1701
+ * (serializeSegments), which runs each handler's async server components and
1702
+ * waits for every promise the handler output carries — the capture's "is the
1703
+ * handler layer done" signal. Live-lane loader data is not in the record
1704
+ * (loader segments are excluded and their masked promises never settle), so
1705
+ * the wait never depends on a hole.
1706
+ *
1707
+ * The capture then renders its OWN Flight payload from the record's
1708
+ * fragments (fragmentSegments, the function a HIT tail uses) instead of from
1709
+ * the elements the match produced. Server components therefore render once
1710
+ * per capture, inside the record's encode: before this, the capture's Flight
1711
+ * render and the record's encode each ran them, so an uncached async
1712
+ * component showed one value in the prelude and another on every HIT.
1713
+ *
1714
+ * Order, all bounded by the one capture deadline (`ppr.captureTimeout`):
1715
+ * top-level handle promises, the handler pushes' nested promises, and the
1716
+ * bake-lane loader containers settle first (the record encodes the handles
1717
+ * a HIT restores, and its handle encode has its own 5s timeout that must
1718
+ * never be the one that fires); then the capture's onResponse callbacks fire
1719
+ * with a synthetic 200 (the implicit doc scope's cacheRoute, an explicit
1720
+ * scope's own write), and the deferred writes settle.
1721
+ */
1722
+ export async function settleCaptureRecord(
1723
+ match: MatchResult,
1724
+ derivation: CaptureContextDerivation,
1725
+ capture: Pick<ShellCaptureDescriptor, "key">,
1726
+ deadline: number,
1727
+ ): Promise<CaptureRecordOutcome> {
1728
+ const { derivedCtx, freshHandleStore } = derivation;
1729
+ const recording = getRecordingStore(derivedCtx._cacheStore);
1730
+
1731
+ // cacheRoute waits for handleStore.settled, which needs the seal.
1732
+ freshHandleStore.seal();
1733
+ const loaderRecords = derivedCtx._shellCaptureLoaderRecords;
1734
+ const settled = await withinDeadline(
1735
+ Promise.all([
1736
+ freshHandleStore.getData().then(resolveDeferredHandleValues),
1737
+ derivation.handlerPushesSettled(),
1738
+ loaderRecords && loaderRecords.size > 0
1739
+ ? Promise.allSettled([...loaderRecords.values()])
1740
+ : undefined,
1741
+ ]).catch(() => undefined),
1742
+ deadline,
1743
+ );
1744
+ // Past the deadline the record cannot settle in time: stop before its
1745
+ // write starts (no encode for a capture that already failed).
1746
+ if (!settled.done) {
1747
+ if (refuseOnCaptureGuard(capture.key, derivedCtx))
1748
+ return { kind: "refused" };
1749
+ return { kind: "timeout", reason: HANDLER_OUTPUT_TIMEOUT_REASON };
1750
+ }
1751
+
1752
+ const callbacks = derivedCtx._onResponseCallbacks?.splice(0) ?? [];
1753
+ if (callbacks.length > 0) {
1754
+ const synthetic = new Response(null, { status: 200 });
1755
+ for (const cb of callbacks) {
1756
+ try {
1757
+ cb(synthetic);
1758
+ } catch {
1759
+ // A capture-time cache write that throws is degradation, not failure.
1760
+ }
1761
+ }
1762
+ }
1763
+ const drained = recording
1764
+ ? await recording.settleWrites(Math.max(0, deadline - Date.now()))
1765
+ : true;
1766
+
1767
+ // Capture guards first: a tripped guard usually also fails the record,
1768
+ // and its own message names the cause.
1769
+ if (refuseOnCaptureGuard(capture.key, derivedCtx)) return { kind: "refused" };
1770
+
1771
+ const docKey = derivedCtx._shellImplicitCache?.docKey;
1772
+ const record =
1773
+ recording && docKey !== undefined
1774
+ ? (recording.getRecord("segment", docKey) as CachedEntryData | undefined)
1775
+ : undefined;
1776
+ if (
1777
+ !record ||
1778
+ !Array.isArray(record.segments) ||
1779
+ record.segments.length === 0
1780
+ ) {
1781
+ // Post-match serve-source truth (cache-lookup.ts tryPrerenderLookup).
1782
+ if (derivedCtx._pprReplayPostMatchReason === "prerender-store") {
1783
+ return { kind: "prerender", match };
1784
+ }
1785
+ if (!drained) {
1786
+ return { kind: "timeout", reason: HANDLER_OUTPUT_TIMEOUT_REASON };
1787
+ }
1788
+ warnCaptureRefusedOnce(capture.key, NO_DOC_RECORD_REASON);
1789
+ return { kind: "refused" };
1790
+ }
1791
+
1792
+ // A handle encode that timed out stores "" (handle-snapshot.ts
1793
+ // encodeHandleValue). With every handler push settled above, that only
1794
+ // happens for a promise the walk does not reach (inside a Map, Set, or class
1795
+ // instance), which never settles in time on a retry either.
1796
+ if (!record.handles && recordSegmentsHaveHandles(record, freshHandleStore)) {
1797
+ warnCaptureRefusedOnce(
1798
+ capture.key,
1799
+ "a handle value the handler pushed did not finish encoding into the doc " +
1800
+ "record. A promise inside a Map, Set, or class instance is not awaited " +
1801
+ "before the encode; push plain objects and arrays.",
1802
+ );
1803
+ return { kind: "refused" };
1804
+ }
1805
+
1806
+ const { fragmentSegments } = await import("../cache/segment-codec.js");
1807
+ const fragments = new Map(
1808
+ (await fragmentSegments(record.segments)).map((s) => [s.id, s]),
1809
+ );
1810
+ return {
1811
+ kind: "record",
1812
+ match: {
1813
+ ...match,
1814
+ segments: match.segments.map((segment) => {
1815
+ const fragment =
1816
+ segment.type === "loader" ? undefined : fragments.get(segment.id);
1817
+ return fragment
1818
+ ? {
1819
+ ...segment,
1820
+ component: fragment.component,
1821
+ layout: fragment.layout,
1822
+ loading: fragment.loading,
1823
+ }
1824
+ : segment;
1825
+ }),
1826
+ },
1827
+ };
1828
+ }
1829
+
1830
+ /** Whether the handle store holds handler pushes for the record's segments. */
1831
+ function recordSegmentsHaveHandles(
1832
+ record: CachedEntryData,
1833
+ handleStore: HandleStore,
1834
+ ): boolean {
1835
+ for (const segment of record.segments) {
1836
+ const id = segment?.metadata?.id;
1837
+ if (id === undefined) continue;
1838
+ const data = handleStore.getDataForSegment(id, true);
1839
+ for (const name in data) {
1840
+ if (data[name]!.length > 0) return true;
1841
+ }
1842
+ }
1843
+ return false;
1351
1844
  }
1352
1845
 
1353
1846
  /**
@@ -1367,6 +1860,7 @@ function handlerLayerIsLive(
1367
1860
  * set a shell entry should be invalidatable by (loader tags belong to holes).
1368
1861
  * - _transitionWhen: a fresh [] so the capture's transition gating is its own.
1369
1862
  * - _shellCaptureRun: true — the switch loaders/cookies/headers guards read.
1863
+ * - _dynamic / dynamic(): an own latch the capture reads.
1370
1864
  * - _metricsStore: undefined so the capture never appends to the foreground's
1371
1865
  * (already-finalized) metrics.
1372
1866
  * - _renderBarrier family: an own barrier wired to the fresh handle store
@@ -1379,6 +1873,8 @@ function handlerLayerIsLive(
1379
1873
  * capture guard is load-bearing). Middleware is NOT re-run: it already ran for the
1380
1874
  * triggering request, and the derived context inherits its post-middleware state
1381
1875
  * (guarding is serve-time; the shell is never served without the full chain).
1876
+ * After the match, {@link settleCaptureRecord} writes the doc record and the
1877
+ * capture renders from it.
1382
1878
  *
1383
1879
  * A FRESH context (and match/render) per attempt is what makes the retry sound:
1384
1880
  * the second attempt is a clean capture, not a resumption of the first.
@@ -1406,23 +1902,67 @@ async function attemptCapture(
1406
1902
  await settleTrackedBackgroundTasks(reqCtx, SHELL_CAPTURE_WRITE_BARRIER_MS);
1407
1903
  stats.barrierWaitMs = Math.round(performance.now() - barrierStart);
1408
1904
 
1409
- const { derivedCtx, freshHandleStore } = deriveShellCaptureContext(
1905
+ const derivation = deriveShellCaptureContext(
1410
1906
  reqCtx,
1411
1907
  descriptor,
1412
1908
  descriptor.navigationOnly ? { request, url } : undefined,
1413
1909
  );
1910
+ const { derivedCtx, freshHandleStore } = derivation;
1414
1911
  const captureStartedAt = Date.now();
1415
1912
 
1416
1913
  return runWithRequestContext(derivedCtx, async () => {
1417
- const match = await ctx.router.match(request, { env });
1914
+ // One deadline for the whole capture (see
1915
+ // PartialPrerenderProps.captureTimeout): the match (handlers, and the
1916
+ // loaders they await), then the record, then the prerender with what is
1917
+ // left.
1918
+ const deadline =
1919
+ Date.now() + (descriptor.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS);
1920
+ const matched = await withinDeadline(
1921
+ ctx.router.match(request, { env }),
1922
+ deadline,
1923
+ );
1924
+ if (!matched.done) {
1925
+ noShellCauses.set(
1926
+ descriptor.key,
1927
+ "the handlers (or a loader a handler awaits) did not return within " +
1928
+ "ppr.captureTimeout",
1929
+ );
1930
+ return "no-shell";
1931
+ }
1932
+ const match = matched.value;
1418
1933
  // A route that redirects has no shell to capture — bail (no store write, no
1419
1934
  // retry: a redirect is deterministic).
1420
1935
  if (match.redirect) return "redirect";
1421
1936
 
1422
1937
  setRequestContextParams(match.params, match.routeName);
1423
1938
 
1424
- const payload = buildFullPayload(
1939
+ const settleStart = performance.now();
1940
+ const settled = await settleCaptureRecord(
1425
1941
  match,
1942
+ derivation,
1943
+ descriptor,
1944
+ deadline,
1945
+ );
1946
+ stats.recordSettleMs = Math.round(performance.now() - settleStart);
1947
+ if (
1948
+ process.env.NODE_ENV !== "production" &&
1949
+ settled.kind === "record" &&
1950
+ stats.recordSettleMs >= SHELL_CAPTURE_BAKE_WARN_MS
1951
+ ) {
1952
+ warnBakeCostOnce(
1953
+ descriptor.key,
1954
+ "the handler output (promises it passes or pushes, async server components)",
1955
+ stats.recordSettleMs,
1956
+ );
1957
+ }
1958
+ if (settled.kind === "refused") return "refused";
1959
+ if (settled.kind === "timeout") {
1960
+ noShellCauses.set(descriptor.key, settled.reason);
1961
+ return "no-shell";
1962
+ }
1963
+
1964
+ const payload = buildFullPayload(
1965
+ settled.match,
1426
1966
  ctx,
1427
1967
  url,
1428
1968
  derivedCtx,
@@ -1446,30 +1986,77 @@ async function attemptCapture(
1446
1986
  // content (and tags propagated by async cache()/"use cache" reads) lands after
1447
1987
  // this synchronous construction point, so snapshotting here dropped it — the
1448
1988
  // shell-tag snapshot must sit behind the quiesce gate (issue #676).
1449
- return captureAndStoreShell(
1989
+ const outcome = await captureAndStoreShell(
1450
1990
  ssrModule,
1451
1991
  flightStage.stream,
1452
1992
  freshHandleStore,
1453
1993
  derivedCtx,
1454
- descriptor,
1994
+ {
1995
+ ...descriptor,
1996
+ captureTimeout: Math.max(1, deadline - Date.now()),
1997
+ },
1455
1998
  stats,
1456
1999
  captureStartedAt,
1457
2000
  );
2001
+ if (outcome === "no-shell" && Date.now() >= deadline) {
2002
+ noShellCauses.set(
2003
+ descriptor.key,
2004
+ "the shell render did not finish within what ppr.captureTimeout left " +
2005
+ `after the handler output settled (${stats.recordSettleMs}ms)`,
2006
+ );
2007
+ }
2008
+ return outcome;
1458
2009
  });
1459
2010
  }
1460
2011
 
2012
+ /**
2013
+ * Race `work` against an absolute deadline, keeping its value. The losing
2014
+ * work is not cancelled (a router match has no abort signal); callers that
2015
+ * lose do not start it again (runShellCapture skips its in-place retry).
2016
+ */
2017
+ export async function withinDeadline<T>(
2018
+ work: Promise<T>,
2019
+ deadline: number,
2020
+ ): Promise<{ done: true; value: T } | { done: false }> {
2021
+ // The losing branch keeps running; a late rejection must not surface.
2022
+ work.catch(() => {});
2023
+ let timer: ReturnType<typeof setTimeout> | undefined;
2024
+ const expired = new Promise<{ done: false }>((resolve) => {
2025
+ timer = setTimeout(
2026
+ () => resolve({ done: false }),
2027
+ Math.max(0, deadline - Date.now()),
2028
+ );
2029
+ (timer as { unref?: () => void }).unref?.();
2030
+ });
2031
+ try {
2032
+ return await Promise.race([
2033
+ work.then((value) => ({ done: true as const, value })),
2034
+ expired,
2035
+ ]);
2036
+ } finally {
2037
+ if (timer) clearTimeout(timer);
2038
+ }
2039
+ }
2040
+
1461
2041
  /**
1462
2042
  * The derived capture context and its fresh (mask-funneled) handle store,
1463
2043
  * shared by BOTH shell producers: the runtime background capture
1464
2044
  * (attemptCapture, producer A) and the build-time prerender shell capture
1465
2045
  * (prerender/build-shell-capture.ts, producer B — issue #699). One
1466
- * implementation so the capture semantics — the nested-thenable mask funnel,
1467
- * handler-liveness bookkeeping, snapshot recording, the implicit doc-cache
1468
- * scope — cannot drift between producers.
2046
+ * implementation so the capture semantics — the handle push funnel (handler
2047
+ * pushes settled, loader pushes masked), the capture guards, snapshot
2048
+ * recording, the implicit doc-cache scope — cannot drift between producers.
1469
2049
  */
1470
2050
  export interface CaptureContextDerivation {
1471
2051
  derivedCtx: RequestContext;
1472
2052
  freshHandleStore: HandleStore;
2053
+ /**
2054
+ * Resolves once every handler handle push has settled at any depth of
2055
+ * plain objects and arrays (top-level promise, then the promises nested
2056
+ * in what it resolved to). The record-first step awaits it before the doc
2057
+ * record encodes its handles, so that encode never races a pending value.
2058
+ */
2059
+ handlerPushesSettled: () => Promise<void>;
1473
2060
  }
1474
2061
 
1475
2062
  /**
@@ -1487,52 +2074,53 @@ export function deriveShellCaptureContext(
1487
2074
  ): CaptureContextDerivation {
1488
2075
  const freshHandleStore = createHandleStore();
1489
2076
  freshHandleStore.onError = reqCtx._handleStore.onError;
1490
- // Shape = liveness for handles, exactly as for bake-lane loader containers
1491
- // (mask-nested.ts): nested thenables in a pushed handle container are
1492
- // per-request by declaration, so the CAPTURE's copy masks them — the
1493
- // consuming boundary postpones as a hole regardless of settle timing,
1494
- // instead of a fast-settling nested value baking into the shared shell. A
1495
- // TOP-LEVEL promise push keeps its documented bake contract (awaited
1496
- // pre-SSR, gate held open for it), but the container it RESOLVES to gets
1497
- // the same nested masking. Wrapping THIS store's push is the single funnel:
1498
- // the store exists only for this capture attempt, so every push wrapper
1499
- // (setupLoaderAccess, createUseFunction, prerender) inherits the policy and
1500
- // the foreground store is untouched.
1501
- // Shell fast path bookkeeping on the same funnel:
1502
- // - handleLiveness: a nested thenable in a push made OUTSIDE a DSL loader
1503
- // scope (attribution read synchronously at push time — handler bodies,
1504
- // handler-invoked ctx.use(loader) callbacks, defers) declares
1505
- // handler-layer per-request data. Its mask is a hole only a handler
1506
- // re-run can fill, so the entry must not serve handler-free
1507
- // (ShellCacheEntry.handlerLiveHoles). Still-pending top-level handler
1508
- // pushes at the putShell barrier count too — their liveness is unknowable.
1509
- // - loader-push tag: DSL-loader pushes re-run fresh on every HIT, so their
1510
- // captured values must NOT enter a segment record's handle snapshot
1511
- // (replay would duplicate the fresh push, and their masked nested
1512
- // promises would stall the Flight handle encode to its timeout). The
1513
- // store tags them by array position (HandleStore.push loaderPush), which
1514
- // covers primitives, and only the captureHandles cache-write call site
1515
- // excludes tagged pushes — every other getDataForSegment consumer (the
1516
- // render-barrier snapshot, prerender) sees every push. Unflagged loaders
1517
- // never execute at capture; the tagged pushes come from loaders an
1518
- // ssr:false loader awaits via ctx.use and from loader-cache replays
1519
- // (loader-cache.ts replayLoaderHandles), both re-run on a HIT.
1520
- // EXCEPTION — bake-lane { ssr: false } loaders: they execute at capture
1521
- // (fresh.ts awaits them), their pushes are in the captured HTML (<head>
1522
- // title/meta, useHandle echoes), and the stored snapshot carries the same
1523
- // values so a HIT's handle data is guaranteed to match its own prelude.
1524
- // They still re-run on every HIT (resolveLoaderData, loader-cache.ts), so
1525
- // each kept push carries its loader as `owner` (HandleStore.push): the
1526
- // record stores it (CachedEntryData.handleOwners), the HIT restores it
1527
- // through pushReplayed, and the re-run's live pushes replace the
1528
- // restored copies in place. Only settled, thenable-free pushes
1529
- // qualify — a deferred (thenable) push or one with masked nested promises
1530
- // keeps the exclusion so the handle encode cannot stall on a
1531
- // never-resolving mask.
1532
- const handleLiveness = {
1533
- holes: false,
1534
- pendingPushes: 0,
1535
- handlerInvokedLoader: false,
2077
+ // The capture handle store's push is the single funnel every push wrapper
2078
+ // (setupLoaderAccess, createUseFunction, prerender) goes through; the store
2079
+ // exists only for this capture attempt, so the foreground store is
2080
+ // untouched. Two lanes:
2081
+ // - HANDLER pushes (outside a DSL loader scope: handler bodies, handler-
2082
+ // invoked ctx.use(loader) bodies, defers) are handler output, baked like
2083
+ // everything else the handler layer produces. They pass through
2084
+ // unmasked; settleHandlerPush tracks each one so the record-first step
2085
+ // (settleCaptureRecord) can wait for its promises, top-level and nested,
2086
+ // before the doc record encodes the handles a HIT replays. A route
2087
+ // cache() record's restore (handle-snapshot.ts restoreHandles) lands
2088
+ // here too: a value it restores through pushReplayed keeps that loader
2089
+ // as owner, so a HIT that runs the loader replaces it, not appends.
2090
+ // - DSL-LOADER pushes. Only bake-lane { ssr: false } loaders and the
2091
+ // loaders they await via ctx.use execute at capture (live loaders are
2092
+ // masked), their pushes are in the captured HTML (<head> title/meta,
2093
+ // useHandle echoes), and a HIT does not run a promise-free bake-lane
2094
+ // loader (loader-cache.ts), so the stored record carries them: each
2095
+ // settled, thenable-free push from a loader body keeps that loader as
2096
+ // `owner` (HandleStore.push), the record stores it
2097
+ // (CachedEntryData.handleOwners), and the HIT restores it through
2098
+ // pushReplayed; a loader that does run on the HIT (a promise-carrying
2099
+ // bake-lane loader, a live loader awaiting it) replaces its restored
2100
+ // copies in place. Nested thenables stay masked (the promise shape is
2101
+ // the liveness declaration, mask-nested.ts). A deferred (thenable) push,
2102
+ // one with masked nested promises, or one outside a loader body (a
2103
+ // loader-cache replay names its loader: pushReplayed below) stays
2104
+ // tagged (HandleStore.push loaderPush) so the
2105
+ // handle encode cannot stall on a never-resolving mask; only the
2106
+ // captureHandles cache-write call site excludes tagged pushes, and the
2107
+ // loader records then carry `runs: 1` so a HIT runs those bodies for
2108
+ // them.
2109
+ const handlerPushSettles: Promise<void>[] = [];
2110
+ // A replay (a loader-cache HIT, loader-cache.ts replayLoaderHandles, or a
2111
+ // route cache() record's owned values, restoreHandles) re-pushes a loader's
2112
+ // recorded values through pushReplayed, which calls push() outside any
2113
+ // loader body: name that loader for the funnel so the record keeps them
2114
+ // under it too.
2115
+ let replayOwner: string | undefined;
2116
+ const rawPushReplayed = freshHandleStore.pushReplayed.bind(freshHandleStore);
2117
+ freshHandleStore.pushReplayed = (handleName, segmentId, value, loaderId) => {
2118
+ replayOwner = loaderId;
2119
+ try {
2120
+ rawPushReplayed(handleName, segmentId, value, loaderId);
2121
+ } finally {
2122
+ replayOwner = undefined;
2123
+ }
1536
2124
  };
1537
2125
  const rawCapturePush = freshHandleStore.push.bind(freshHandleStore);
1538
2126
  freshHandleStore.push = (
@@ -1540,49 +2128,55 @@ export function deriveShellCaptureContext(
1540
2128
  segmentId: string,
1541
2129
  value: unknown,
1542
2130
  ) => {
1543
- const pushedInLoaderScope = isInsideLoaderScope();
1544
- // Single walk: the mask reports whether it masked any nested thenable
1545
- // (the liveness declaration) while building the capture copy.
2131
+ if (!isInsideLoaderScope()) {
2132
+ handlerPushSettles.push(settleNestedThenables(value));
2133
+ rawCapturePush(handleName, segmentId, value, false, replayOwner);
2134
+ return;
2135
+ }
1546
2136
  let maskedNestedThenable = false;
1547
- const maskWithLiveness = (v: unknown): unknown => {
2137
+ const mask = (v: unknown): unknown => {
1548
2138
  const report: MaskReport = { thenable: false };
1549
2139
  const masked = maskNestedContainerThenables(v, undefined, report);
1550
- if (report.thenable) {
1551
- maskedNestedThenable = true;
1552
- if (!pushedInLoaderScope) handleLiveness.holes = true;
1553
- }
2140
+ if (report.thenable) maskedNestedThenable = true;
1554
2141
  return masked;
1555
2142
  };
1556
2143
  let masked: unknown;
1557
- let loaderPush = pushedInLoaderScope;
2144
+ let loaderPush = true;
1558
2145
  let owner: string | undefined;
1559
2146
  if (isThenable(value)) {
1560
- if (!pushedInLoaderScope) {
1561
- handleLiveness.pendingPushes++;
1562
- const settle = () => handleLiveness.pendingPushes--;
1563
- value.then(settle, settle);
1564
- }
1565
2147
  // Deferred (thenable) loader pushes always keep the tag — the
1566
- // bake-lane carve-out below is for settled values only.
1567
- masked = value.then(maskWithLiveness);
2148
+ // carve-out below is for settled values only.
2149
+ masked = value.then(mask);
1568
2150
  } else {
1569
- masked = maskWithLiveness(value);
1570
- if (loaderPush && !maskedNestedThenable) {
1571
- // Bake-lane { ssr: false } loader pushes are shell material (see the
1572
- // funnel comment above): untagged, the stored snapshot keeps them
1573
- // when settled and thenable-free; everything else stays tagged.
1574
- const bodyLoaderId = getCurrentLoaderBodyId();
1575
- loaderPush =
1576
- bodyLoaderId === undefined ||
1577
- derivedCtx._awaitBeforeFlushLoaderIds?.has(bodyLoaderId) !== true;
2151
+ masked = mask(value);
2152
+ if (!maskedNestedThenable) {
2153
+ // A settled, thenable-free push from a loader body is shell
2154
+ // material (see the funnel comment above): only bake-lane loaders
2155
+ // and the loaders they await run at capture, and a HIT does not run
2156
+ // a promise-free bake-lane loader (loader-cache.ts), so the record
2157
+ // keeps the push under the loader that made it.
2158
+ const bodyLoaderId = getCurrentLoaderBodyId() ?? replayOwner;
2159
+ loaderPush = bodyLoaderId === undefined;
1578
2160
  if (!loaderPush) owner = bodyLoaderId;
1579
2161
  }
1580
2162
  }
2163
+ // A push the record cannot keep reaches a HIT only from a live run of
2164
+ // its loader, so the loader records ask for one (runs: 1).
2165
+ if (loaderPush) derivedCtx._shellCaptureUnrecordedLoaderPush = true;
1581
2166
  rawCapturePush(handleName, segmentId, masked, loaderPush, owner);
1582
2167
  };
1583
2168
 
2169
+ // The capture reuses the record keys its request resolved (CacheScope
2170
+ // resolveKeyFrom), read through the prototype: the map must exist on the
2171
+ // request before the derivation, or the capture would start its own and
2172
+ // run key() under the capture guard.
2173
+ reqCtx._resolvedCacheKeys ??= new Map();
1584
2174
  const derivedCtx: RequestContext = Object.assign(Object.create(reqCtx), {
1585
2175
  _handleStore: freshHandleStore,
2176
+ // The capture notes the route records it reads or writes itself; the
2177
+ // foreground's (a partial request's `partial:` record, say) are not the
2178
+ // ones this shell is captured from.
2179
+ _routeRecordWindow: undefined,
1586
2180
  });
1587
2181
  if (identity) {
1588
2182
  derivedCtx.request = identity.request;
@@ -1614,6 +2208,16 @@ export function deriveShellCaptureContext(
1614
2208
  // foreground's never reach it.
1615
2209
  derivedCtx._renderErrors = [];
1616
2210
  derivedCtx._shellCaptureRun = true;
2211
+ // Own dynamic() latch. The inherited method writes the FOREGROUND context
2212
+ // (its closure), which already served, and nothing read it for a runtime
2213
+ // capture: handler code that opts out after an await (a handler promise the
2214
+ // capture now waits for) must refuse THIS capture instead
2215
+ // (settleCaptureRecord and captureAndStoreShell read _dynamic). The ppr
2216
+ // header latch is left alone: a capture's response is never sent.
2217
+ derivedCtx._dynamic = false;
2218
+ derivedCtx.dynamic = (): void => {
2219
+ derivedCtx._dynamic = true;
2220
+ };
1617
2221
  // Own-property reset: an inherited flag would make serializeSegments store
1618
2222
  // a double-encoded fragment. No current arming site reaches a capture
1619
2223
  // render (kept as defense-in-depth for a future one).
@@ -1649,7 +2253,6 @@ export function deriveShellCaptureContext(
1649
2253
  // forwarding to the parent so the write persists and the worker stays alive),
1650
2254
  // then captureAndStoreShell awaits them before draining. Reads that HIT are
1651
2255
  // recorded synchronously during the render and need none of this.
1652
- derivedCtx._shellCaptureHandleLiveness = handleLiveness;
1653
2256
  if (reqCtx._cacheStore) {
1654
2257
  const recordingStore = new RecordingShellStore(reqCtx._cacheStore);
1655
2258
  derivedCtx._cacheStore = recordingStore;
@@ -1658,12 +2261,13 @@ export function deriveShellCaptureContext(
1658
2261
  recordingStore.trackWrite(p);
1659
2262
  reqCtx.waitUntil(() => p);
1660
2263
  };
1661
- // Shell fast path (capture side): the implicit doc-cache scope makes the
2264
+ // The doc record (capture side): the implicit doc-cache scope makes the
1662
2265
  // capture's match write ALL matched non-loader segments as one doc-keyed
1663
2266
  // segment record — into the snapshot only (SnapshotOnlySegmentStore), so
1664
2267
  // the record dies with the shell entry and the next capture's lookup
1665
- // still misses (handlers re-run on recapture). Routes deriving their own
1666
- // cache scope are untouched (resolveShellImplicitCacheScope).
2268
+ // still misses (handlers re-run on recapture). A route deriving its own
2269
+ // cache() scope keeps it, and recordShellCaptureDocRecord (cache-store.ts)
2270
+ // writes the same record for it.
1667
2271
  derivedCtx._shellImplicitCache = {
1668
2272
  ttl: descriptor.ttl,
1669
2273
  swr: descriptor.swr,
@@ -1672,7 +2276,12 @@ export function deriveShellCaptureContext(
1672
2276
  };
1673
2277
  }
1674
2278
 
1675
- return { derivedCtx, freshHandleStore };
2279
+ return {
2280
+ derivedCtx,
2281
+ freshHandleStore,
2282
+ handlerPushesSettled: () =>
2283
+ Promise.all(handlerPushSettles).then(() => undefined),
2284
+ };
1676
2285
  }
1677
2286
 
1678
2287
  /**
@@ -1718,17 +2327,19 @@ async function captureAndStoreShell(
1718
2327
  // docs/design/ppr-shell-resume.md ("Loaders and handles").
1719
2328
  handleStore.seal();
1720
2329
 
1721
- // Handles contract, shell half ("nesting = liveness"): TOP-LEVEL pushed handle
1722
- // promises are BAKED into the shell — resolvedHandleStream awaits them before
1723
- // the payload's handles row emits. A pushed promise with real latency would lose
1724
- // the byte-quiet race (the pending handles row emits no bytes, the gate freezes,
1725
- // the row is dropped, SsrRoot suspends at the root), so the gate is HELD open
1726
- // until the same await completes: handlesBaked mirrors resolvedHandleStream's
1727
- // resolution (getData waits the tracked-handler barrier; resolveDeferredHandleValues
1728
- // awaits the top-level thenables). NESTED promises inside pushed containers are
1729
- // shallow-skipped by isThenable and never hold the gate — they stay holes.
1730
- // Bounded by maxWaitMs like every quiesce input (a defer hanging on a masked
1731
- // loader still ends in the sanity-gate refusal).
2330
+ // TOP-LEVEL pushed handle promises are BAKED into the shell —
2331
+ // resolvedHandleStream awaits them before the payload's handles row emits. A
2332
+ // pushed promise with real latency would lose the byte-quiet race (the
2333
+ // pending handles row emits no bytes, the gate freezes, the row is dropped,
2334
+ // SsrRoot suspends at the root), so the gate is HELD open until the same
2335
+ // await completes: handlesBaked mirrors resolvedHandleStream's resolution
2336
+ // (getData waits the tracked-handler barrier; resolveDeferredHandleValues
2337
+ // awaits the top-level thenables). On the runtime path settleCaptureRecord
2338
+ // already waited for these (and for handler pushes' nested promises), so the
2339
+ // hold is normally released at once. A DSL loader push's nested promises are
2340
+ // masked and never hold the gate — they stay holes. Bounded by maxWaitMs like
2341
+ // every quiesce input (a defer hanging on a masked loader still ends in the
2342
+ // sanity-gate refusal).
1732
2343
  const handlesBaked = handleStore.getData().then(resolveDeferredHandleValues);
1733
2344
  // Bake-lane loader containers hold the gate the same way (loader-container-
1734
2345
  // bake): a boundary-less container with real latency (a 100ms layout loader)
@@ -1774,35 +2385,32 @@ async function captureAndStoreShell(
1774
2385
  () => {},
1775
2386
  );
1776
2387
 
1777
- // Deterministic identity-guard refusal, checked at BOTH exits below: the
1778
- // guard error either rejects the prerender itself (boundary-less segment —
1779
- // lands in the catch) or is swallowed into per-loader error UI (the render
1780
- // completes — caught after the try). One helper so the message and the
1781
- // "refused" mapping cannot drift between the two sites.
1782
- const refuseOnGuardTrip = (): "refused" | undefined => {
1783
- const fnName = reqCtx._shellCaptureGuardTripped;
1784
- if (!fnName) return undefined;
1785
- // Name the recorded source instead of hardcoding a lane. Under the
1786
- // consumption-lane rule, handler-INVOKED loader bodies are exempt from
1787
- // the guard (their value is a baked shared copy, mirroring cache()), so a
1788
- // trip can only come from a bake-lane SEGMENT loader or from non-loader
1789
- // handler/render code — the old blanket "bake-lane loader" attribution
1790
- // sent a live-lane debugging session down the wrong lane (issue #672).
1791
- const loaderId = reqCtx._shellCaptureGuardTrippedLoaderId;
1792
- const origin = loaderId
1793
- ? `segment loader "${loaderId}"`
1794
- : "handler/render code (no loader body was executing)";
1795
- warnCaptureRefusedOnce(
1796
- capture.key,
1797
- `${origin} called ${fnName}() during capture; identity must not bake into the shared shell. ` +
1798
- "Segment loader: drop ssr: false and read it under loading() or an inline <Suspense> " +
1799
- "(a nested promise does not help: its body still runs at capture). Handler/render " +
1800
- "code: read it in a live loader and consume it with useLoader. Exception: " +
1801
- "await ctx.use(loader) in a HANDLER bakes as a capture-time copy.",
1802
- PPR_LANE_HINT,
1803
- );
1804
- return "refused";
1805
- };
2388
+ // Deterministic capture-guard refusal (refuseOnCaptureGuard), checked at
2389
+ // BOTH exits below: the guard error either rejects the prerender itself
2390
+ // (boundary-less segment — lands in the catch) or is swallowed into
2391
+ // per-loader error UI (the render completes — caught after the try).
2392
+ const refuseOnGuardTrip = (): "refused" | undefined =>
2393
+ refuseOnCaptureGuard(capture.key, reqCtx) ? "refused" : undefined;
2394
+
2395
+ // Dev diagnostics for a no-shell attempt (warnNullCaptureOnce): the stacks of
2396
+ // the tasks React reports as still pending at the capture's abort. React's
2397
+ // componentStack is computed on read, so it is read only while there is room.
2398
+ let pendingStacks: string[] | undefined;
2399
+ let onAbortedTask:
2400
+ | ((errorInfo: { componentStack?: string } | undefined) => void)
2401
+ | undefined;
2402
+ if (process.env.NODE_ENV !== "production") {
2403
+ noShellPendingStacks.delete(capture.key);
2404
+ const stacks: string[] = [];
2405
+ pendingStacks = stacks;
2406
+ onAbortedTask = (errorInfo) => {
2407
+ if (stacks.length >= MAX_PENDING_STACKS) return;
2408
+ const componentStack = errorInfo?.componentStack;
2409
+ if (componentStack && !stacks.includes(componentStack)) {
2410
+ stacks.push(componentStack);
2411
+ }
2412
+ };
2413
+ }
1806
2414
 
1807
2415
  try {
1808
2416
  // captureShellHTML CONSUMES the (gated) stream — it is not also SSR'd.
@@ -1821,6 +2429,7 @@ async function captureAndStoreShell(
1821
2429
  onError: (error) => {
1822
2430
  reqCtx._renderErrors?.push(error);
1823
2431
  },
2432
+ onAbortedTask,
1824
2433
  }),
1825
2434
  );
1826
2435
  } catch (error) {
@@ -1848,6 +2457,7 @@ async function captureAndStoreShell(
1848
2457
  if (refused) return refused;
1849
2458
 
1850
2459
  if (result === null) {
2460
+ if (pendingStacks) noShellPendingStacks.set(capture.key, pendingStacks);
1851
2461
  return "no-shell";
1852
2462
  }
1853
2463
  if (stats) stats.preludeBytes = result.prelude.length;
@@ -1934,6 +2544,8 @@ async function captureAndStoreShell(
1934
2544
  let serializeContainer:
1935
2545
  | typeof import("../cache/segment-codec.js").serializeResult
1936
2546
  | undefined;
2547
+ // Capture-wide, read once: every record of this snapshot agrees.
2548
+ const runs: 0 | 1 = reqCtx._shellCaptureUnrecordedLoaderPush ? 1 : 0;
1937
2549
  for (const [segmentKey, containerPromise] of loaderRecords) {
1938
2550
  const elided = await elideLoaderContainer(containerPromise);
1939
2551
  if (elided.state === "rejected") {
@@ -1986,7 +2598,11 @@ async function captureAndStoreShell(
1986
2598
  // without rescanning whether the pin can resolve immediately
1987
2599
  // (holes: 0) or must wait for the fresh run's live promises
1988
2600
  // (holes: 1). See ShellSnapshotLoaderValue.
1989
- value: { value: serialized, holes: elided.hasHole ? 1 : 0 },
2601
+ value: {
2602
+ value: serialized,
2603
+ holes: elided.hasHole ? 1 : 0,
2604
+ runs,
2605
+ },
1990
2606
  });
1991
2607
  }
1992
2608
  } catch {
@@ -2006,42 +2622,127 @@ async function captureAndStoreShell(
2006
2622
  const renderErrors = reqCtx._renderErrors;
2007
2623
  if (renderErrors && renderErrors.length > 0) throw renderErrors[0];
2008
2624
 
2625
+ // Record only what a HIT reads (issue #941): the doc record, plus what
2626
+ // the bake-lane loaders a HIT runs read. Before the size guards, so they measure
2627
+ // what is stored.
2628
+ const docKey = reqCtx._shellImplicitCache?.docKey;
2629
+ let prunedRecords: string | undefined;
2630
+ if (recording && snapshot) {
2631
+ const readers = snapshotReaders(capture, snapshot, docKey);
2632
+ if (readers && docKey !== undefined) {
2633
+ const { kept, pruned } = pruneShellSnapshot(
2634
+ snapshot,
2635
+ readers,
2636
+ recording.loaderKeys,
2637
+ docKey,
2638
+ );
2639
+ if (pruned.length > 0) {
2640
+ prunedRecords = countSnapshotFamilies(pruned);
2641
+ snapshot = kept.length > 0 ? kept : undefined;
2642
+ }
2643
+ }
2644
+ }
2645
+
2009
2646
  // Snapshot size guard (issue #651): the snapshot duplicates every pinned
2010
- // cache value inside the shell entry, so a page over a large cache()
2011
- // segment can push the stored envelope toward store value limits (KV caps
2012
- // a value at 25 MiB) with no signal — the kv.put rejects deep inside
2013
- // waitUntil. Measure the serialized snapshot (UTF-8 bytes of the JSON that
2014
- // rides in the envelope) AFTER the loader family is appended, and over the
2015
- // cap store the shell WITHOUT it: pinned reads then fall back to the live
2016
- // store on a HIT (documented drift — hydration repairs a mismatch
2017
- // client-side, the pre-snapshot behavior), which beats losing the whole
2018
- // entry to a store-side write rejection. Reported once per key.
2647
+ // cache value inside the shell entry. `maxSnapshotBytes` bounds the PINS
2648
+ // (loader-family records and the item/response records loaders read);
2649
+ // over it they are dropped and the entry keeps its doc record, so every
2650
+ // HIT still replays the handler layer (the pins' loaders then read the
2651
+ // live store: documented drift, repaired client-side). The doc record is
2652
+ // exempt: without it a HIT could not serve at all. The whole entry is
2653
+ // bounded separately below, by the store's value limit.
2019
2654
  if (snapshot && snapshot.length > 0) {
2020
- const snapshotBytes = SNAPSHOT_BYTE_ENCODER.encode(
2021
- JSON.stringify(snapshot),
2022
- ).length;
2023
- if (stats) stats.snapshotBytes = snapshotBytes;
2655
+ const docRecord =
2656
+ docKey !== undefined
2657
+ ? snapshot.find((r) => r.family === "segment" && r.key === docKey)
2658
+ : undefined;
2659
+ const pins = docRecord
2660
+ ? snapshot.filter((r) => r !== docRecord)
2661
+ : snapshot;
2662
+ const pinBytes =
2663
+ pins.length > 0
2664
+ ? SNAPSHOT_BYTE_ENCODER.encode(JSON.stringify(pins)).length
2665
+ : 0;
2666
+ if (stats) stats.snapshotBytes = pinBytes;
2024
2667
  const cap = capture.maxSnapshotBytes ?? DEFAULT_PPR_MAX_SNAPSHOT_BYTES;
2025
- if (snapshotBytes > cap) {
2026
- warnSnapshotOverCapOnce(capture.key, snapshotBytes, cap);
2027
- snapshot = undefined;
2668
+ if (pinBytes > cap) {
2669
+ warnSnapshotOverCapOnce(capture.key, pinBytes, cap);
2670
+ snapshot = docRecord ? [docRecord] : undefined;
2028
2671
  if (stats) stats.snapshotSkipped = true;
2029
2672
  }
2030
2673
  }
2031
2674
 
2032
- // Shell tags snapshot at the WRITE BARRIER, not at stream construction: by
2033
- // here the capture has quiesced and the deferred cache writes were awaited, so
2034
- // tags recorded AFTER an await in async shell content (and by async
2035
- // cache()/"use cache" reads propagating through recordRequestTags) are
2036
- // included — issue #676. Loaders are masked, so loader cache tags — which
2037
- // belong to the holes, not the shell — never execute during capture and cannot
2038
- // contribute. Union with the route's static ppr.tags (capture.tags); the
2039
- // collected set is authoritative, the option only adds what the render cannot
2040
- // know.
2041
- const collected = [...reqCtx._requestTags];
2675
+ // A capture that ran handlers must store its doc record: an entry without
2676
+ // it could only be served by running handlers after the commit. Only a
2677
+ // prerender-served capture (the prerender store supplies the handler
2678
+ // layer on every HIT) or a navigation-only entry may lack it.
2679
+ const storedDocKey = hasDocRecord(snapshot, docKey) ? docKey : undefined;
2680
+ if (
2681
+ storedDocKey === undefined &&
2682
+ !capture.navigationOnly &&
2683
+ reqCtx._pprReplayPostMatchReason !== "prerender-store"
2684
+ ) {
2685
+ warnCaptureRefusedOnce(capture.key, NO_DOC_RECORD_REASON);
2686
+ return "refused";
2687
+ }
2688
+
2689
+ // Shell tags: what the shell renders from, and nothing else. The handler
2690
+ // layer is the doc record, whose tags are what its content recorded (the
2691
+ // handlers, the server components its serialization rendered, the loaders
2692
+ // they consumed, the handle values; cache-scope.ts collectRecordTags), or
2693
+ // what a replayed route cache() record carried (recordSegmentTags). So a
2694
+ // capture that rendered fresh and one that replayed the route's record
2695
+ // store the same tags. Bake-lane loaders add theirs (SHELL_BAKE_TAG_OWNER):
2696
+ // their data is in the shell, and no handler reads it. A live-lane loader
2697
+ // never runs at capture, and one whose cache() config tags are recorded
2698
+ // anyway is a hole's, so request-level tags are not the source. Read at
2699
+ // the write barrier: the record settled and the deferred writes were
2700
+ // awaited, so a tag recorded after an await (#676) is in. A capture
2701
+ // without a doc record (the prerender store served its match) keeps the
2702
+ // request-level set. Union with the route's static ppr.tags.
2703
+ const docRecordValue = storedDocKey
2704
+ ? (snapshot?.find((r) => r.family === "segment" && r.key === storedDocKey)
2705
+ ?.value as CachedEntryData | undefined)
2706
+ : undefined;
2707
+ const collected = docRecordValue
2708
+ ? [
2709
+ ...(docRecordValue.tags ?? []),
2710
+ ...getSegmentTags(reqCtx, SHELL_BAKE_TAG_OWNER),
2711
+ ]
2712
+ : [...reqCtx._requestTags];
2042
2713
  const union = new Set<string>([...(capture.tags ?? []), ...collected]);
2043
2714
  const shellTags = union.size > 0 ? [...union] : undefined;
2044
2715
 
2716
+ // Whole-entry guard: the prelude, postponed state, snapshot, and tags
2717
+ // travel in one stored value. Over the store's limit (Cloudflare KV:
2718
+ // 25 MiB) the write would fail inside waitUntil and every MISS would
2719
+ // recapture, so refuse here (the key backs off) instead. Measured as the
2720
+ // stores write it (estimateShellEntryBytes): the postponed state rides a
2721
+ // JSON head as an escaped string.
2722
+ const entryBytes = estimateShellEntryBytes({
2723
+ // A navigation-only entry stores no document half.
2724
+ preludeBytes: capture.navigationOnly ? 0 : result.prelude.length,
2725
+ postponed: capture.navigationOnly ? undefined : result.postponed,
2726
+ snapshot,
2727
+ tags: shellTags,
2728
+ docKey: storedDocKey,
2729
+ prunedRecords: snapshot ? prunedRecords : undefined,
2730
+ });
2731
+ if (stats) stats.entryBytes = entryBytes;
2732
+ const entryLimit =
2733
+ (capture.store ?? reqCtx._cacheStore)?.maxShellEntryBytes ??
2734
+ DEFAULT_SHELL_ENTRY_MAX_BYTES;
2735
+ if (entryBytes > entryLimit) {
2736
+ warnCaptureRefusedOnce(
2737
+ capture.key,
2738
+ `the shell entry is ${entryBytes} bytes (prelude, postponed state, ` +
2739
+ `snapshot, and head), over the store's ${entryLimit}-byte value limit. ` +
2740
+ "Shrink the page the shell bakes, or move large regions under a live " +
2741
+ "loader's boundary.",
2742
+ );
2743
+ return "refused";
2744
+ }
2745
+
2045
2746
  // Missing tags are valid: the shell follows TTL/SWR-only invalidation. Expose
2046
2747
  // that choice only to operators who enabled structured capture diagnostics.
2047
2748
  if (
@@ -2065,45 +2766,73 @@ async function captureAndStoreShell(
2065
2766
  ...(capture.navigationOnly
2066
2767
  ? {}
2067
2768
  : {
2068
- // slice() copies just this view's bytes into a fresh
2069
- // ArrayBuffer, so a prelude that is a subarray of a larger
2070
- // backing buffer encodes only its own region — bufferToBase64
2071
- // reads the whole ArrayBuffer it is handed.
2072
- prelude: bufferToBase64(
2073
- result.prelude.slice().buffer as ArrayBuffer,
2074
- ),
2769
+ // bytesToBase64 encodes the view's own region, so a prelude
2770
+ // that is a subarray of a larger buffer encodes correctly.
2771
+ prelude: bytesToBase64(result.prelude),
2075
2772
  postponed: result.postponed,
2076
2773
  }),
2077
2774
  reactVersion: React.version,
2078
2775
  buildVersion: capture.buildVersion,
2079
2776
  // The theme this capture's payload was built with (buildFullPayload
2080
- // reads reqCtx.theme off the derived context). The serve tail replays
2081
- // it so the resume tree matches the frozen prelude — see
2082
- // ShellCacheEntry.initialTheme.
2083
- initialTheme: reqCtx.theme,
2777
+ // reads payloadInitialTheme off the derived context: the no-cookie
2778
+ // default, #971). The serve tail replays it so the resume tree
2779
+ // matches the frozen prelude — see ShellCacheEntry.initialTheme.
2780
+ initialTheme: payloadInitialTheme(reqCtx),
2084
2781
  snapshot,
2782
+ prunedRecords: snapshot ? prunedRecords : undefined,
2085
2783
  // The canonical doc segment record's key, published by the doc
2086
- // scope's cacheRoute during this capture's match (undefined when no
2087
- // doc record was recorded — the entry then never claims navigation
2088
- // replayability). Only meaningful alongside a snapshot.
2089
- docKey: snapshot ? reqCtx._shellImplicitCache?.docKey : undefined,
2784
+ // scope's cacheRoute during this capture's match. Every HIT tail
2785
+ // looks the record up by this key (serveShellHit fixedDocKey);
2786
+ // undefined only for a prerender-served entry.
2787
+ docKey: storedDocKey,
2090
2788
  navigationOnly: capture.navigationOnly,
2091
- // Handler-layer liveness folded at the barrier: nested thenables in
2092
- // handler-scoped pushes, handler pushes still pending (liveness
2093
- // unknowable), or a handler-invoked loader execution — any of them
2094
- // refuses the FAST PATH, not the capture. See
2095
- // _shellCaptureHandleLiveness.
2096
- handlerLiveHoles: handlerLayerIsLive(
2097
- reqCtx._shellCaptureHandleLiveness,
2098
- ),
2099
- transitionWhen: reqCtx._transitionWhen?.length ? true : undefined,
2100
2789
  createdAt: captureStartedAt,
2101
2790
  };
2791
+ // Capped to the route record the capture used: the policy the
2792
+ // store would apply (resolveTtl/resolveSwrWindow, as putShell does),
2793
+ // made explicit.
2794
+ const record = reqCtx._routeRecordWindow;
2795
+ let window: { ttl?: number; swr?: number } = {
2796
+ ttl: capture.ttl,
2797
+ swr: capture.swr,
2798
+ };
2799
+ if (record) {
2800
+ const capped = capShellWindow(
2801
+ resolveTtl(capture.ttl, store.defaults, DEFAULT_FUNCTION_TTL),
2802
+ resolveSwrWindow(capture.swr, store.defaults),
2803
+ record,
2804
+ Date.now(),
2805
+ );
2806
+ if (!capped) {
2807
+ // Dead on arrival, not stored. A record this attempt wrote ran
2808
+ // out between its write and the store, which a retry repeats:
2809
+ // warn, refuse, back off. A record it read was near its end (its
2810
+ // age, queue wait): `expired` retries on a fresh match.
2811
+ const route = reqCtx._routeName ?? capture.key;
2812
+ if (record.written) {
2813
+ const sinceWriteMs =
2814
+ Date.now() - (record.freshUntil - record.ttl * 1000);
2815
+ warnRecordRanOutOnce(
2816
+ route,
2817
+ record,
2818
+ `${sinceWriteMs} ms after this capture wrote it`,
2819
+ );
2820
+ return "refused";
2821
+ }
2822
+ expiredRecords.set(capture.key, {
2823
+ route,
2824
+ record,
2825
+ captureMs: Date.now() - captureStartedAt,
2826
+ });
2827
+ return "expired";
2828
+ }
2829
+ window = capped;
2830
+ }
2102
2831
  const storeWrite = await store.putShell(
2103
2832
  capture.key,
2104
2833
  entry,
2105
- capture.ttl,
2106
- capture.swr,
2834
+ window.ttl,
2835
+ window.swr,
2107
2836
  shellTags,
2108
2837
  );
2109
2838
  if (stats && storeWrite) stats.storeWrite = storeWrite;
@@ -2176,3 +2905,25 @@ export {
2176
2905
  REFUSED_CAPTURE_MAX_MS,
2177
2906
  REFUSED_CAPTURE_DEV_MAX_MS,
2178
2907
  };
2908
+
2909
+ /**
2910
+ * @internal Reset this module's per-isolate state: the capture stampede
2911
+ * guard, the refused-capture backoff, the recorded no-shell causes (which
2912
+ * also skip a timed-out capture's in-place retry), the once-per-key warnings
2913
+ * and the buffered debug events. Tests only (testing/serve-shell-request.ts
2914
+ * resetShellTestState), between requests, never while a capture runs.
2915
+ */
2916
+ export function resetShellCaptureStateForTests(): void {
2917
+ inFlightCaptures.clear();
2918
+ refusedCaptures.clear();
2919
+ warnedNullCaptures.clear();
2920
+ noShellCauses.clear();
2921
+ noShellPendingStacks.clear();
2922
+ warnedRefusedCaptures.clear();
2923
+ warnedOverCapSnapshots.clear();
2924
+ warnedBakeCosts.clear();
2925
+ warnedExpiredRecordRoutes.clear();
2926
+ expiredRecords.clear();
2927
+ storedCaptureSeqs.clear();
2928
+ lastCaptureEventsForTiming.clear();
2929
+ }