@rangojs/router 0.4.3 → 0.5.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.
@@ -30,6 +30,8 @@ import type { KVNamespace, CFCacheReadDebugEvent, CFCacheDebug, CFCacheStoreOpti
30
30
  export type { KVNamespace, CFCacheReadDebugEvent, CFCacheDebug, CFCacheStoreOptions, };
31
31
  export declare class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
32
32
  readonly supportsPassiveShellReads: true;
33
+ /** True when constructed without KV: the shell family no-ops (see ctor). */
34
+ readonly shellFamilyInert?: boolean;
33
35
  readonly defaults?: CacheDefaults;
34
36
  readonly keyGenerator?: (ctx: RequestContext<TEnv>, defaultKey: string) => string | Promise<string>;
35
37
  private readonly namespace?;
@@ -164,6 +164,15 @@ export interface SegmentCacheStore<TEnv = unknown> {
164
164
  * `stored` when acknowledged, or void for stores without acknowledgements.
165
165
  */
166
166
  putShell?(key: string, entry: ShellCacheEntry, ttlSeconds?: number, swrSeconds?: number, tags?: string[]): Promise<"stored" | "invalidated" | void>;
167
+ /**
168
+ * Declares the shell family present-but-inert: getShell/putShell exist but
169
+ * no-op (CFCacheStore without a KV namespace). scheduleShellCapture skips
170
+ * captures whose only write target is inert — the background render would be
171
+ * dead work that still occupies the per-isolate serialized capture queue
172
+ * (a promise-heavy route bakes for seconds per MISS with nothing stored).
173
+ * Absent/false means the family, when present, actually stores.
174
+ */
175
+ shellFamilyInert?: boolean;
167
176
  /**
168
177
  * Get a cached function result by key.
169
178
  * Returns the serialized value, optional handle data, and staleness flag.
@@ -221,8 +230,8 @@ export interface CacheItemResult {
221
230
  /**
222
231
  * A cached PPR (Partial Pre-rendering) shell entry.
223
232
  *
224
- * One entry carries BOTH artifacts a resume needs — the rendered HTML prelude
225
- * and React's postponed state — because the pair is version- and
233
+ * A DOCUMENT entry carries BOTH artifacts a resume needs — the rendered HTML
234
+ * prelude and React's postponed state — because the pair is version- and
226
235
  * generation-coupled and must never be mixed across a React upgrade or a build
227
236
  * change. The reactVersion and buildVersion fields are the read-time gates that
228
237
  * enforce both halves: isValidShellHit (rsc/shell-serve.ts) treats an entry
@@ -231,15 +240,28 @@ export interface CacheItemResult {
231
240
  * positions against one exact tree; resuming it against a different React or a
232
241
  * different app build tree-mismatches inside resume(), AFTER the 200 + prelude
233
242
  * are committed — an unrecoverable broken serve).
243
+ *
244
+ * A `navigationOnly` entry stores NEITHER half: nothing ever serves its HTML
245
+ * (document serving skips navigationOnly entries at the read gate, and partial
246
+ * replay consumes only `snapshot`/`docKey`), so the prelude would ride every
247
+ * store write and read as dead weight at KV-value scale. The capture still runs
248
+ * the full fizz prerender — it is the completeness arbiter and sanity gate —
249
+ * but its output is dropped before putShell. hasIntactShellPayload is the
250
+ * document-half gate; navigationOnly entries never satisfy it.
234
251
  */
235
252
  export interface ShellCacheEntry {
236
- /** Rendered HTML prelude bytes, base64-encoded (stores are JSON-serializing). */
237
- prelude: string;
253
+ /**
254
+ * Rendered HTML prelude bytes, base64-encoded (stores are JSON-serializing).
255
+ * Absent on `navigationOnly` entries (no document half is stored — see the
256
+ * interface doc); present on every document-servable entry.
257
+ */
258
+ prelude?: string;
238
259
  /**
239
260
  * JSON.stringify of React's postponed state, or null when the shell settled
240
- * with no holes (the DATA variant — served without a fizz resume).
261
+ * with no holes (the DATA variant — served without a fizz resume). Absent
262
+ * exactly when `prelude` is (navigationOnly entries).
241
263
  */
242
- postponed: string | null;
264
+ postponed?: string | null;
243
265
  /** React.version captured at prerender time; the read-time invalidation gate. */
244
266
  reactVersion: string;
245
267
  /**
@@ -322,6 +344,17 @@ export interface ShellCacheEntry {
322
344
  /** Capture-generation start time; tag invalidations at or after it win. */
323
345
  createdAt: number;
324
346
  }
347
+ /**
348
+ * A shell entry whose document half is present — what the document HIT path
349
+ * (serveShellHit / lookupBuildShell) consumes. hasIntactShellPayload
350
+ * (rsc/shell-serve.ts) is the runtime gate AND the type narrowing to this
351
+ * shape; navigationOnly entries never pass it (their document half is not
352
+ * stored).
353
+ */
354
+ export type DocumentShellCacheEntry = ShellCacheEntry & {
355
+ prelude: string;
356
+ postponed: string | null;
357
+ };
325
358
  /**
326
359
  * The families a shell snapshot pins. The item/segment/response families are
327
360
  * cache-store reads/writes (recorded by RecordingShellStore); the loader family
@@ -1,5 +1,20 @@
1
1
  /** Bound queued/running capture closures retained by one isolate. */
2
2
  export declare const MAX_ADMITTED_CAPTURES: number;
3
+ /** Point-in-time queue occupancy, for capture-scheduling telemetry. */
4
+ export interface CaptureQueueDepths {
5
+ /** A capture is currently executing (never preempted). */
6
+ running: boolean;
7
+ /** Waiting document-priority captures (run before every navigation capture). */
8
+ document: number;
9
+ /** Waiting navigation-priority captures. */
10
+ navigation: number;
11
+ }
12
+ /**
13
+ * Snapshot the queue's waiting/running state. Wait-timed-out entries still
14
+ * sitting in the arrays (pruned lazily by takeNextCapture) are excluded — they
15
+ * will never run, so counting them would overstate the backlog.
16
+ */
17
+ export declare function captureQueueDepths(): CaptureQueueDepths;
3
18
  /** The caller may drop this best-effort capture and retry on a later request. */
4
19
  export declare class CaptureQueueFullError extends Error {
5
20
  constructor();
@@ -24,10 +39,11 @@ export declare class CaptureQueueWaitTimeoutError extends Error {
24
39
  constructor(waitedMs: number);
25
40
  }
26
41
  /**
27
- * Run `task` after every previously enqueued capture has settled. Returns a
28
- * promise for THIS task's completion (rejections propagate to the caller —
29
- * the queue itself is insulated).
42
+ * Run `task` after the active capture and every higher/equal-priority capture
43
+ * ahead of it have settled. Returns a promise for THIS task's completion
44
+ * (rejections propagate to the caller — the queue itself is insulated).
30
45
  */
31
46
  export declare function enqueueSerializedCapture(task: () => Promise<void>, opts?: {
32
47
  maxQueueWaitMs?: number;
48
+ priority?: "document" | "navigation";
33
49
  }): Promise<void>;
@@ -24,7 +24,7 @@
24
24
  * as the reference instant. No tombstones — the manifest is immutable; the
25
25
  * markers say whether it is still current.
26
26
  */
27
- import type { SegmentCacheStore, ShellCacheEntry } from "../cache/types.js";
27
+ import type { DocumentShellCacheEntry, SegmentCacheStore, ShellCacheEntry } from "../cache/types.js";
28
28
  import type { SearchParamsFilter } from "../cache/search-params-filter.js";
29
29
  /** One baked manifest record (the __ps asset module's default export). */
30
30
  export interface BuildShellEntry {
@@ -49,7 +49,7 @@ declare global {
49
49
  /** Reset the memoized manifest (unit tests swap the global loader). */
50
50
  export declare function resetBuildShellManifestForTests(): void;
51
51
  export interface BuildShellHit {
52
- entry: ShellCacheEntry;
52
+ entry: DocumentShellCacheEntry;
53
53
  /** Past createdAt + ttl: serve, but schedule the runtime recapture. */
54
54
  stale: boolean;
55
55
  }
@@ -125,6 +125,10 @@ export interface ShellCaptureDebugEvent {
125
125
  * - skip-backoff: the key is inside its refused-capture backoff window and
126
126
  * the capture was not attempted
127
127
  * - skip-capacity: the isolate capture queue is full; a later request may retry
128
+ * - skip-inert-store: the resolved store's shell family is missing or
129
+ * declared inert (SegmentCacheStore.shellFamilyInert — a CFCacheStore
130
+ * without a KV namespace); nothing could store the result, so the
131
+ * background render was not scheduled at all
128
132
  * - skip-queue-timeout: the capture waited past CAPTURE_QUEUE_WAIT_BUDGET_MS
129
133
  * behind other captures and was dropped unrun (no backoff — the route is
130
134
  * not doomed, the isolate was busy; a later request re-probes). Carries
@@ -132,7 +136,7 @@ export interface ShellCaptureDebugEvent {
132
136
  * - backoff: the key entered (or escalated) backoff after a terminal
133
137
  * no-shell — carries the new backoff state
134
138
  */
135
- outcome: "stored" | "redirect" | "no-shell" | "refused" | "error" | "skip-in-flight" | "skip-backoff" | "skip-capacity" | "skip-queue-timeout" | "backoff";
139
+ outcome: "stored" | "redirect" | "no-shell" | "refused" | "error" | "skip-in-flight" | "skip-backoff" | "skip-capacity" | "skip-inert-store" | "skip-queue-timeout" | "backoff";
136
140
  /** Attempt number (1 = first, 2 = in-place retry). Absent on skips. */
137
141
  attempt?: number;
138
142
  /** Wall-clock ms of the whole attempt (barrier + render + drain + put). */
@@ -163,6 +167,22 @@ export interface ShellCaptureDebugEvent {
163
167
  backoffRemainingMs?: number;
164
168
  /** Ms the capture waited in the serialized queue (skip-queue-timeout). */
165
169
  queueWaitMs?: number;
170
+ /** Queue priority class at enqueue: document outranks queued navigation. */
171
+ queuePriority?: "document" | "navigation";
172
+ /**
173
+ * Captures ahead at enqueue: the active one plus every waiting capture of
174
+ * same-or-higher priority. 0 = this capture starts immediately.
175
+ */
176
+ queueAhead?: number;
177
+ /**
178
+ * Ms the capture gate was HELD waiting for the slowest bake source — a
179
+ * top-level pushed handle promise or a bake-lane loader container — to
180
+ * settle, measured from capture start. Bake time is by-design shell latency
181
+ * (the hole doctrine: top-level promises bake), but it recurs on EVERY
182
+ * capture of the route and occupies the serialized queue; this field makes
183
+ * it attributable. Absent when nothing held the gate.
184
+ */
185
+ bakeWaitMs?: number;
166
186
  }
167
187
  /**
168
188
  * Debug sink for the capture pipeline, mirroring {@link CFCacheDebug}: `true`
@@ -322,7 +342,7 @@ type CaptureAttemptOutcome = "stored" | "redirect" | "no-shell" | "refused";
322
342
  * bag, not a return value: captureAndStoreShell's outcome type stays a string
323
343
  * union its existing callers (producer B, tests) consume unchanged.
324
344
  */
325
- type CaptureAttemptStats = Pick<ShellCaptureDebugEvent, "barrierWaitMs" | "writeSettleMs" | "preludeBytes" | "snapshotBytes" | "snapshotSkipped" | "untaggedBake" | "storeWrite">;
345
+ type CaptureAttemptStats = Pick<ShellCaptureDebugEvent, "barrierWaitMs" | "writeSettleMs" | "preludeBytes" | "snapshotBytes" | "snapshotSkipped" | "untaggedBake" | "storeWrite" | "bakeWaitMs">;
326
346
  /**
327
347
  * Run the shell capture with a single in-place retry, then store the result.
328
348
  *
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { type EntryData } from "../server/context.js";
19
19
  import type { SearchParamsFilter } from "../cache/search-params-filter.js";
20
- import type { ShellCacheEntry, SegmentCacheStore } from "../cache/types.js";
20
+ import type { DocumentShellCacheEntry, ShellCacheEntry, SegmentCacheStore } from "../cache/types.js";
21
21
  /** Debug/status header the browser (and e2e assertions) can read: HIT | MISS. */
22
22
  export declare const SHELL_STATUS_HEADER = "x-rango-shell";
23
23
  /** Partial-navigation status header set only after captured PPR segments are consumed. */
@@ -106,18 +106,24 @@ export declare function buildShellKey(url: URL, filter?: SearchParamsFilter): st
106
106
  */
107
107
  export declare function isValidShellHit(entry: ShellCacheEntry, buildVersion: string): boolean;
108
108
  /**
109
- * Payload integrity gate, run BEFORE the HIT response commits: a stored entry
110
- * whose prelude is not decodable base64 or whose postponed blob is not
111
- * parseable JSON would otherwise throw AFTER the 200 + full static prelude
112
- * flushed (`serveShellHit` decodes at stream construction, `resumeShellHTML`
113
- * parses in the tail) — the client gets a visually complete page that never
114
- * hydrates, re-served on every request until the entry ages out (no eviction
115
- * path exists; failure schedules no recapture by itself). Checking here turns
116
- * a corrupt entry (store-layer fault) into a plain MISS the recapture
117
- * overwrites. Cost: one duplicate decode/parse per HIT, sub-ms against a
118
- * prelude flush that dominates the path.
109
+ * DOCUMENT-half integrity gate and type narrowing, run BEFORE the HIT response
110
+ * commits: a stored entry whose prelude is not decodable base64 or whose
111
+ * postponed blob is not parseable JSON would otherwise throw AFTER the 200 +
112
+ * full static prelude flushed (`serveShellHit` decodes at stream construction,
113
+ * `resumeShellHTML` parses in the tail) — the client gets a visually complete
114
+ * page that never hydrates, re-served on every request until the entry ages
115
+ * out (no eviction path exists; failure schedules no recapture by itself).
116
+ * Checking here turns a corrupt entry (store-layer fault) into a plain MISS
117
+ * the recapture overwrites. Cost: one duplicate decode/parse per HIT, sub-ms
118
+ * against a prelude flush that dominates the path.
119
+ *
120
+ * navigationOnly entries store no document half (prelude/postponed absent) and
121
+ * therefore never pass. The partial-replay path skips this gate for them
122
+ * (replayableShellSnapshot) — snapshot fragment corruption is caught by the
123
+ * consumer-side Flight decoders instead (SegmentFragmentDecodeError → healing
124
+ * capture).
119
125
  */
120
- export declare function hasIntactShellPayload(entry: ShellCacheEntry): boolean;
126
+ export declare function hasIntactShellPayload(entry: ShellCacheEntry): entry is DocumentShellCacheEntry;
121
127
  /** Decode a base64 prelude back into bytes for stream composition. */
122
128
  export declare function base64ToBytes(b64: string): Uint8Array;
123
129
  /** True when the store implements the shell entry family. */
@@ -2530,7 +2530,7 @@ import { resolve } from "node:path";
2530
2530
  // package.json
2531
2531
  var package_default = {
2532
2532
  name: "@rangojs/router",
2533
- version: "0.4.3",
2533
+ version: "0.5.0",
2534
2534
  description: "Django-inspired RSC router with composable URL patterns",
2535
2535
  keywords: [
2536
2536
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -358,14 +358,27 @@ curl -s -D - -o /dev/null https://app.example.com/products/1 | grep -i x-rango-s
358
358
  falls through to KV on a miss, and promotes the KV hit back into that colo.
359
359
  WITHOUT a KV namespace its shell family remains inert: every ppr route stays
360
360
  `MISS` forever. The store warns once per isolate — bind KV
361
- (`new CFCacheStore({ ctx, kv: env.CACHE_KV })`) or use another store.
361
+ (`new CFCacheStore({ ctx, kv: env.CACHE_KV })`) or use another store. An
362
+ inert store also stops captures at the gate: the store declares
363
+ `shellFamilyInert` and the scheduler skips the background render entirely
364
+ (`skip-inert-store` on the debug event) instead of burning a full capture per
365
+ MISS whose write could only no-op.
362
366
  - Structured capture diagnostics: `createRouter({ debugShellCapture: true })`
363
367
  logs one line per capture attempt/skip (outcome, durations, prelude and
364
368
  snapshot bytes, backoff state); pass a function to receive each
365
369
  `ShellCaptureDebugEvent` instead. `skip-capacity` means the isolate already
366
370
  has 32 queued/running captures; the dropped best-effort capture can retry on a
367
- later request. In dev, with `debugPerformance` on, the
368
- last capture outcome for a key also rides the next document GET's
371
+ later request. `skip-queue-timeout` means this capture waited 15s behind the
372
+ active or same-priority work and was dropped before rendering; document-shell
373
+ captures outrank queued navigation-only snapshots, but never interrupt the
374
+ active capture — the skip event and the `rango.background` span carry
375
+ `queuePriority`/`queueAhead` (class and backlog at enqueue) so a parked
376
+ capture diagnoses itself. A stored attempt reports `bakeWaitMs`: how long the
377
+ slowest bake source (a top-level pushed handle promise or a bake-lane loader
378
+ container) held the capture gate — in dev, past 2s, the source is also named
379
+ once per key in a console warning with the remedies (nest the promise /
380
+ `cache()` the work / add `loading()`). In dev, with `debugPerformance` on,
381
+ the last capture outcome for a key also rides the next document GET's
369
382
  `Server-Timing` as `ppr-capture;desc="…"`.
370
383
  - For deployed Cloudflare tier diagnostics, build with
371
384
  `INTERNAL_RANGO_DEBUG=1` and run `wrangler tail`. `[CFCacheStore][shell]`
@@ -621,6 +634,34 @@ capture with identity reads permitted, and the value bakes as a shared
621
634
  capture-time copy — mirroring `cache()` semantics (the consumption-lane
622
635
  rule; semantic-matrix row PPR3).
623
636
 
637
+ ### Designing routes for cheap captures (the cost model)
638
+
639
+ The hole doctrine has a cost corollary that bites silently: **everything that
640
+ bakes is awaited at capture, and that wait recurs on EVERY capture of the
641
+ route**. A top-level pushed handle promise that takes 5s and a Meta promise
642
+ chained 2s further make every capture occupy the per-isolate serialized queue
643
+ for ~7s — while the served response still reads 13ms TTFB, because captures are
644
+ detached. You will not see this cost in any request timing; you see it as slow
645
+ MISS→HIT flips, `skip-queue-timeout` under prefetch pressure, and (in dev, past
646
+ 2s) the bake-cost warning naming the source.
647
+
648
+ Three levers, in preference order:
649
+
650
+ 1. **Nest it** (`{ data: promise }` instead of the promise): the value becomes
651
+ a hole — per-request, streamed under the consumer's `<Suspense>`, zero
652
+ capture cost. Choose this whenever the value may be dynamic anyway.
653
+ 2. **`cache()` the work inside it**: the value stays baked (shell material,
654
+ tag-invalidatable), but the capture replays the cached value instead of
655
+ re-executing the expensive body (mixed-chain). Choose this for shared,
656
+ expensive-to-compute shell content.
657
+ 3. **`loading()` on the entry** (loaders only): moves the loader to the live
658
+ lane — masked at capture, fresh per serve — at the cost of a fallback in
659
+ the shell.
660
+
661
+ Head material (Meta) generally cannot be a hole — the head is shell — so its
662
+ promises bake by design; make them cheap with lever 2. `bakeWaitMs` on the
663
+ capture debug event tells you what each capture actually paid.
664
+
624
665
  ## Execution matrix
625
666
 
626
667
  | Phase | MISS (foreground) | Background capture | HIT (foreground) |
@@ -267,10 +267,13 @@ interface KVItemEnvelope {
267
267
  * @internal
268
268
  */
269
269
  interface CFShellEnvelope {
270
- /** base64-encoded prelude bytes */
271
- p: string;
272
- /** postponed state JSON, or null (DATA variant no holes) */
273
- po: string | null;
270
+ /**
271
+ * base64-encoded prelude bytes. Absent iff `no` (navigationOnly entries
272
+ * store no document halfShellCacheEntry.prelude).
273
+ */
274
+ p?: string;
275
+ /** postponed state JSON, or null (DATA variant — no holes). Absent iff `no`. */
276
+ po?: string | null;
274
277
  /** React.version captured at prerender time */
275
278
  rv: string;
276
279
  /** Build version captured at prerender time (ShellCacheEntry.buildVersion) */
@@ -347,8 +350,14 @@ function isShellEnvelope(value: unknown): value is CFShellEnvelope {
347
350
  if (value == null || typeof value !== "object") return false;
348
351
  const envelope = value as Partial<CFShellEnvelope>;
349
352
  return (
350
- typeof envelope.p === "string" &&
351
- (envelope.po === null || typeof envelope.po === "string") &&
353
+ // Document half: required unless the envelope is navigationOnly (`no`),
354
+ // which stores neither field. Tolerate a legacy navigationOnly envelope
355
+ // that still carries them.
356
+ (typeof envelope.p === "string" ||
357
+ (envelope.p === undefined && envelope.no === true)) &&
358
+ (envelope.po === null ||
359
+ typeof envelope.po === "string" ||
360
+ (envelope.po === undefined && envelope.no === true)) &&
352
361
  typeof envelope.rv === "string" &&
353
362
  (envelope.bv === undefined || typeof envelope.bv === "string") &&
354
363
  typeof envelope.c === "number" &&
@@ -400,6 +409,8 @@ interface KVResponseEnvelope {
400
409
 
401
410
  export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
402
411
  readonly supportsPassiveShellReads: true = true;
412
+ /** True when constructed without KV: the shell family no-ops (see ctor). */
413
+ readonly shellFamilyInert?: boolean;
403
414
  readonly defaults?: CacheDefaults;
404
415
  readonly keyGenerator?: (
405
416
  ctx: RequestContext<TEnv>,
@@ -471,6 +482,11 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
471
482
  this.keyGenerator = options.keyGenerator;
472
483
  this.waitUntil = (fn) => options.ctx.waitUntil(fn());
473
484
  this.kv = options.kv;
485
+ // The shell family requires KV (getShell/putShell no-op without it — see
486
+ // warnShellFamilyInertOnce). Declaring it lets scheduleShellCapture skip
487
+ // captures whose write could only no-op instead of burning a background
488
+ // render per MISS that still occupies the serialized capture queue.
489
+ this.shellFamilyInert = options.kv ? undefined : true;
474
490
  this.onRevalidateTag = options.onRevalidateTag;
475
491
  // tagPurge accepts a ready purge function or a credentials object; the
476
492
  // object form is normalized through the built-in zone purge client, which
@@ -2083,8 +2099,11 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2083
2099
  }
2084
2100
 
2085
2101
  const envelope: CFShellEnvelope = {
2086
- p: entry.prelude,
2087
- po: entry.postponed,
2102
+ // Presence-keyed: a navigationOnly entry has no document half, and an
2103
+ // omitted key (vs an explicit undefined) also keeps it out of the
2104
+ // serialized JSON.
2105
+ ...(entry.prelude !== undefined ? { p: entry.prelude } : {}),
2106
+ ...(entry.postponed !== undefined ? { po: entry.postponed } : {}),
2088
2107
  rv: entry.reactVersion,
2089
2108
  bv: entry.buildVersion,
2090
2109
  c: entry.createdAt,
@@ -198,6 +198,16 @@ export interface SegmentCacheStore<TEnv = unknown> {
198
198
  tags?: string[],
199
199
  ): Promise<"stored" | "invalidated" | void>;
200
200
 
201
+ /**
202
+ * Declares the shell family present-but-inert: getShell/putShell exist but
203
+ * no-op (CFCacheStore without a KV namespace). scheduleShellCapture skips
204
+ * captures whose only write target is inert — the background render would be
205
+ * dead work that still occupies the per-isolate serialized capture queue
206
+ * (a promise-heavy route bakes for seconds per MISS with nothing stored).
207
+ * Absent/false means the family, when present, actually stores.
208
+ */
209
+ shellFamilyInert?: boolean;
210
+
201
211
  /**
202
212
  * Get a cached function result by key.
203
213
  * Returns the serialized value, optional handle data, and staleness flag.
@@ -264,8 +274,8 @@ export interface CacheItemResult {
264
274
  /**
265
275
  * A cached PPR (Partial Pre-rendering) shell entry.
266
276
  *
267
- * One entry carries BOTH artifacts a resume needs — the rendered HTML prelude
268
- * and React's postponed state — because the pair is version- and
277
+ * A DOCUMENT entry carries BOTH artifacts a resume needs — the rendered HTML
278
+ * prelude and React's postponed state — because the pair is version- and
269
279
  * generation-coupled and must never be mixed across a React upgrade or a build
270
280
  * change. The reactVersion and buildVersion fields are the read-time gates that
271
281
  * enforce both halves: isValidShellHit (rsc/shell-serve.ts) treats an entry
@@ -274,15 +284,28 @@ export interface CacheItemResult {
274
284
  * positions against one exact tree; resuming it against a different React or a
275
285
  * different app build tree-mismatches inside resume(), AFTER the 200 + prelude
276
286
  * are committed — an unrecoverable broken serve).
287
+ *
288
+ * A `navigationOnly` entry stores NEITHER half: nothing ever serves its HTML
289
+ * (document serving skips navigationOnly entries at the read gate, and partial
290
+ * replay consumes only `snapshot`/`docKey`), so the prelude would ride every
291
+ * store write and read as dead weight at KV-value scale. The capture still runs
292
+ * the full fizz prerender — it is the completeness arbiter and sanity gate —
293
+ * but its output is dropped before putShell. hasIntactShellPayload is the
294
+ * document-half gate; navigationOnly entries never satisfy it.
277
295
  */
278
296
  export interface ShellCacheEntry {
279
- /** Rendered HTML prelude bytes, base64-encoded (stores are JSON-serializing). */
280
- prelude: string;
297
+ /**
298
+ * Rendered HTML prelude bytes, base64-encoded (stores are JSON-serializing).
299
+ * Absent on `navigationOnly` entries (no document half is stored — see the
300
+ * interface doc); present on every document-servable entry.
301
+ */
302
+ prelude?: string;
281
303
  /**
282
304
  * JSON.stringify of React's postponed state, or null when the shell settled
283
- * with no holes (the DATA variant — served without a fizz resume).
305
+ * with no holes (the DATA variant — served without a fizz resume). Absent
306
+ * exactly when `prelude` is (navigationOnly entries).
284
307
  */
285
- postponed: string | null;
308
+ postponed?: string | null;
286
309
  /** React.version captured at prerender time; the read-time invalidation gate. */
287
310
  reactVersion: string;
288
311
  /**
@@ -366,6 +389,18 @@ export interface ShellCacheEntry {
366
389
  createdAt: number;
367
390
  }
368
391
 
392
+ /**
393
+ * A shell entry whose document half is present — what the document HIT path
394
+ * (serveShellHit / lookupBuildShell) consumes. hasIntactShellPayload
395
+ * (rsc/shell-serve.ts) is the runtime gate AND the type narrowing to this
396
+ * shape; navigationOnly entries never pass it (their document half is not
397
+ * stored).
398
+ */
399
+ export type DocumentShellCacheEntry = ShellCacheEntry & {
400
+ prelude: string;
401
+ postponed: string | null;
402
+ };
403
+
369
404
  /**
370
405
  * The families a shell snapshot pins. The item/segment/response families are
371
406
  * cache-store reads/writes (recorded by RecordingShellStore); the loader family
@@ -184,10 +184,13 @@ interface VercelResponseEnvelope {
184
184
 
185
185
  /** Stored envelope for a PPR shell entry (getShell/putShell). */
186
186
  interface VercelShellEnvelope {
187
- /** base64-encoded prelude bytes. */
188
- p: string;
189
- /** postponed state JSON, or null (DATA variant). */
190
- po: string | null;
187
+ /**
188
+ * base64-encoded prelude bytes. Absent iff `no` (navigationOnly entries
189
+ * store no document half ShellCacheEntry.prelude).
190
+ */
191
+ p?: string;
192
+ /** postponed state JSON, or null (DATA variant). Absent iff `no`. */
193
+ po?: string | null;
191
194
  /** React.version at capture. */
192
195
  rv: string;
193
196
  /** Build version at capture (ShellCacheEntry.buildVersion). */
@@ -868,8 +871,11 @@ export class VercelCacheStore<
868
871
  ? Math.min(totalTtl, TAG_MARKER_TTL_SECONDS)
869
872
  : totalTtl;
870
873
  const env: VercelShellEnvelope = {
871
- p: entry.prelude,
872
- po: entry.postponed,
874
+ // Presence-keyed: a navigationOnly entry has no document half, and an
875
+ // omitted key (vs an explicit undefined) also keeps it out of the
876
+ // serialized JSON.
877
+ ...(entry.prelude !== undefined ? { p: entry.prelude } : {}),
878
+ ...(entry.postponed !== undefined ? { po: entry.postponed } : {}),
873
879
  rv: entry.reactVersion,
874
880
  bv: entry.buildVersion,
875
881
  c: entry.createdAt,
@@ -1210,13 +1216,21 @@ export class VercelCacheStore<
1210
1216
  private asShellEnvelope(raw: unknown): VercelShellEnvelope | null {
1211
1217
  if (!isRecord(raw)) return null;
1212
1218
  const { p, po, rv, bv, c, s, e, t, i, sn, dk, lh, tw, no } = raw;
1213
- if (typeof p !== "string" || typeof rv !== "string") return null;
1214
- if (po !== null && typeof po !== "string") return null;
1219
+ if (typeof rv !== "string") return null;
1220
+ // Document half: required unless navigationOnly (`no`), which stores
1221
+ // neither field. Tolerate a legacy navigationOnly envelope that still
1222
+ // carries them.
1223
+ if (p === undefined ? no !== true : typeof p !== "string") return null;
1224
+ if (
1225
+ po === undefined ? no !== true : po !== null && typeof po !== "string"
1226
+ ) {
1227
+ return null;
1228
+ }
1215
1229
  if (typeof c !== "number") return null;
1216
1230
  if (typeof s !== "number" || typeof e !== "number") return null;
1217
1231
  return {
1218
- p,
1219
- po: po as string | null,
1232
+ ...(typeof p === "string" ? { p } : {}),
1233
+ ...(po !== undefined ? { po: po as string | null } : {}),
1220
1234
  rv,
1221
1235
  bv: typeof bv === "string" ? bv : undefined,
1222
1236
  c,
@@ -19,15 +19,55 @@
19
19
  * is bounded; a capture killed mid-queue by that bound simply recaptures on a
20
20
  * later request (the existing best-effort contract).
21
21
  *
22
- * The chain link resolves in `finally` and the prior link is awaited with a
23
- * swallow, so one rejected capture can never wedge every later one.
22
+ * Document shells outrank queued navigation-only snapshots. Production Link
23
+ * prefetching can enqueue several expensive navigation captures at once; a
24
+ * strict FIFO made the requesting page's document capture wait
25
+ * behind speculative work until its queue budget expired. The active capture
26
+ * is never interrupted, and each priority remains FIFO.
24
27
  */
25
- let captureQueue: Promise<void> = Promise.resolve();
26
28
  let admittedCaptures = 0;
29
+ let captureRunning = false;
30
+
31
+ interface QueuedCapture {
32
+ waitStart: number;
33
+ state: "waiting" | "running" | "settled";
34
+ signalStart: () => void;
35
+ }
36
+
37
+ const documentCaptures: QueuedCapture[] = [];
38
+ const navigationCaptures: QueuedCapture[] = [];
27
39
 
28
40
  /** Bound queued/running capture closures retained by one isolate. */
29
41
  export const MAX_ADMITTED_CAPTURES: number = 32;
30
42
 
43
+ /** Point-in-time queue occupancy, for capture-scheduling telemetry. */
44
+ export interface CaptureQueueDepths {
45
+ /** A capture is currently executing (never preempted). */
46
+ running: boolean;
47
+ /** Waiting document-priority captures (run before every navigation capture). */
48
+ document: number;
49
+ /** Waiting navigation-priority captures. */
50
+ navigation: number;
51
+ }
52
+
53
+ /**
54
+ * Snapshot the queue's waiting/running state. Wait-timed-out entries still
55
+ * sitting in the arrays (pruned lazily by takeNextCapture) are excluded — they
56
+ * will never run, so counting them would overstate the backlog.
57
+ */
58
+ export function captureQueueDepths(): CaptureQueueDepths {
59
+ const waiting = (queue: QueuedCapture[]): number => {
60
+ let count = 0;
61
+ for (const queued of queue) if (queued.state === "waiting") count++;
62
+ return count;
63
+ };
64
+ return {
65
+ running: captureRunning,
66
+ document: waiting(documentCaptures),
67
+ navigation: waiting(navigationCaptures),
68
+ };
69
+ }
70
+
31
71
  /** The caller may drop this best-effort capture and retry on a later request. */
32
72
  export class CaptureQueueFullError extends Error {
33
73
  constructor() {
@@ -65,7 +105,7 @@ export class CaptureQueueWaitTimeoutError extends Error {
65
105
  }
66
106
 
67
107
  /**
68
- * Upper bound on how long one queue link may hold the queue. A capture task
108
+ * Upper bound on how long one running capture may hold the queue. A capture task
69
109
  * normally settles well inside this (attempt + in-place retry + writes), but
70
110
  * a task wedged on never-settling I/O — a workerd waitUntil fetch that pends
71
111
  * instead of rejecting (seen on GH runners with the dev prerender store
@@ -76,70 +116,103 @@ export class CaptureQueueWaitTimeoutError extends Error {
76
116
  const QUEUE_LINK_CAP_MS = 60_000;
77
117
 
78
118
  /**
79
- * Run `task` after every previously enqueued capture has settled. Returns a
80
- * promise for THIS task's completion (rejections propagate to the caller —
81
- * the queue itself is insulated).
119
+ * Run `task` after the active capture and every higher/equal-priority capture
120
+ * ahead of it have settled. Returns a promise for THIS task's completion
121
+ * (rejections propagate to the caller — the queue itself is insulated).
82
122
  */
83
123
  export function enqueueSerializedCapture(
84
124
  task: () => Promise<void>,
85
- opts?: { maxQueueWaitMs?: number },
125
+ opts?: {
126
+ maxQueueWaitMs?: number;
127
+ priority?: "document" | "navigation";
128
+ },
86
129
  ): Promise<void> {
87
130
  if (admittedCaptures >= MAX_ADMITTED_CAPTURES) {
88
131
  return Promise.reject(new CaptureQueueFullError());
89
132
  }
90
133
  admittedCaptures++;
91
- const prior = captureQueue;
92
- let releaseQueue!: () => void;
93
- captureQueue = new Promise<void>((resolve) => {
94
- releaseQueue = resolve;
134
+
135
+ let signalStart!: () => void;
136
+ const startGate = new Promise<void>((resolve) => {
137
+ signalStart = resolve;
95
138
  });
96
- return (async () => {
97
- const waitStart = performance.now();
139
+ const queued: QueuedCapture = {
140
+ waitStart: performance.now(),
141
+ state: "waiting",
142
+ signalStart,
143
+ };
144
+
145
+ const completion = (async () => {
98
146
  const budget = opts?.maxQueueWaitMs ?? CAPTURE_QUEUE_WAIT_BUDGET_MS;
99
- // The wait itself is budget-raced: the drop fires AT the budget, not once
100
- // the predecessor eventually settles — a waiter parked behind a wedged
101
- // link must not stay pending until that link's 60s cap just to learn it
102
- // was already over budget.
103
147
  let waitTimer: ReturnType<typeof setTimeout> | undefined;
104
148
  const waitTimedOut = await Promise.race([
105
- prior.catch(() => {}).then(() => false),
149
+ startGate.then(() => false),
106
150
  new Promise<boolean>((resolve) => {
107
- waitTimer = setTimeout(() => resolve(true), budget);
151
+ waitTimer = setTimeout(() => {
152
+ if (queued.state !== "waiting") return;
153
+ queued.state = "settled";
154
+ admittedCaptures--;
155
+ resolve(true);
156
+ }, budget);
108
157
  (waitTimer as { unref?: () => void }).unref?.();
109
158
  }),
110
159
  ]);
111
160
  if (waitTimer) clearTimeout(waitTimer);
112
161
  if (waitTimedOut) {
113
- // Never started: hand the admission slot back here (the taskPromise
114
- // finally below does it for tasks that ran). Serialization stays
115
- // intact: THIS link releases only once the predecessor settles —
116
- // releasing now would let a successor run concurrently with it.
117
- admittedCaptures--;
118
- prior.catch(() => {}).then(releaseQueue, releaseQueue);
119
- throw new CaptureQueueWaitTimeoutError(performance.now() - waitStart);
162
+ throw new CaptureQueueWaitTimeoutError(
163
+ performance.now() - queued.waitStart,
164
+ );
120
165
  }
166
+
167
+ let capTimer: ReturnType<typeof setTimeout> | undefined;
168
+ const taskPromise = Promise.resolve()
169
+ .then(task)
170
+ .finally(() => {
171
+ // A capped capture releases serialization, but its detached task still
172
+ // counts against admission until it actually settles.
173
+ admittedCaptures--;
174
+ });
175
+ const cap = new Promise<void>((resolve) => {
176
+ capTimer = setTimeout(resolve, QUEUE_LINK_CAP_MS);
177
+ (capTimer as { unref?: () => void }).unref?.();
178
+ });
179
+
121
180
  try {
122
- let capTimer: ReturnType<typeof setTimeout> | undefined;
123
- const taskPromise = Promise.resolve()
124
- .then(task)
125
- .finally(() => {
126
- // A timed-out link releases serialization, but its detached task still
127
- // counts against admission until it actually settles.
128
- admittedCaptures--;
129
- });
130
- try {
131
- await Promise.race([
132
- taskPromise,
133
- new Promise<void>((resolve) => {
134
- capTimer = setTimeout(resolve, QUEUE_LINK_CAP_MS);
135
- (capTimer as { unref?: () => void }).unref?.();
136
- }),
137
- ]);
138
- } finally {
139
- if (capTimer) clearTimeout(capTimer);
140
- }
181
+ await Promise.race([taskPromise, cap]);
141
182
  } finally {
142
- releaseQueue();
183
+ if (capTimer) clearTimeout(capTimer);
184
+ queued.state = "settled";
185
+ captureRunning = false;
186
+ // Handoff must happen before this caller's waitUntil promise settles.
187
+ // Resolving first lets workerd terminate the context with the queue lock
188
+ // still held, permanently parking later captures in the isolate.
189
+ startNextCapture();
143
190
  }
144
191
  })();
192
+
193
+ const queue =
194
+ opts?.priority === "document" ? documentCaptures : navigationCaptures;
195
+ queue.push(queued);
196
+ startNextCapture();
197
+ return completion;
198
+ }
199
+
200
+ function takeNextCapture(): QueuedCapture | undefined {
201
+ for (const queue of [documentCaptures, navigationCaptures]) {
202
+ let queued: QueuedCapture | undefined;
203
+ while ((queued = queue.shift())) {
204
+ if (queued.state === "waiting") return queued;
205
+ }
206
+ }
207
+ return undefined;
208
+ }
209
+
210
+ function startNextCapture(): void {
211
+ if (captureRunning) return;
212
+ const queued = takeNextCapture();
213
+ if (!queued) return;
214
+
215
+ captureRunning = true;
216
+ queued.state = "running";
217
+ queued.signalStart();
145
218
  }
@@ -79,6 +79,7 @@ import { reportCacheError } from "../cache/cache-error.js";
79
79
  import type { SearchParamsFilter } from "../cache/search-params-filter.js";
80
80
  import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
81
81
  import type {
82
+ DocumentShellCacheEntry,
82
83
  SegmentCacheStore,
83
84
  ShellCacheEntry,
84
85
  ShellSnapshotRecord,
@@ -212,7 +213,14 @@ function replayableShellSnapshot(
212
213
  if (!isValidShellHit(entry, buildVersion)) {
213
214
  return { reason: "invalid-version" };
214
215
  }
215
- if (!hasIntactShellPayload(entry)) return { reason: "corrupt-entry" };
216
+ // navigationOnly entries store no document half (prelude/postponed absent),
217
+ // so the payload gate applies to document-snapshot entries alone. Replay
218
+ // consumes only the snapshot; fragment corruption inside it is caught by the
219
+ // consumer-side Flight decoders (SegmentFragmentDecodeError →
220
+ // segmentReplayCorrupt → healing capture at the document key).
221
+ if (!entry.navigationOnly && !hasIntactShellPayload(entry)) {
222
+ return { reason: "corrupt-entry" };
223
+ }
216
224
  if (entry.handlerLiveHoles) return { reason: "handler-live-holes" };
217
225
  if (entry.transitionWhen) return { reason: "transition-when" };
218
226
  // Eligibility requires the CANONICAL doc segment record (`docKey`), not just
@@ -386,7 +394,7 @@ async function handleRscRenderingInner<TEnv>(
386
394
  // build-manifest hit further down): schedule the background
387
395
  // recapture when asked, then commit the composed response.
388
396
  const serveHit = (
389
- entry: ShellCacheEntry,
397
+ entry: DocumentShellCacheEntry,
390
398
  revalidate: boolean | undefined,
391
399
  ): Response => {
392
400
  if (revalidate) {
@@ -1159,7 +1167,7 @@ function serveShellHit(
1159
1167
  reqCtx: RequestContext<any>,
1160
1168
  handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
1161
1169
  ssrModule: SSRModule,
1162
- entry: ShellCacheEntry,
1170
+ entry: DocumentShellCacheEntry,
1163
1171
  descriptor: ShellCaptureDescriptor,
1164
1172
  ): Response {
1165
1173
  const preludeBytes = base64ToBytes(entry.prelude);
@@ -25,7 +25,11 @@
25
25
  * markers say whether it is still current.
26
26
  */
27
27
 
28
- import type { SegmentCacheStore, ShellCacheEntry } from "../cache/types.js";
28
+ import type {
29
+ DocumentShellCacheEntry,
30
+ SegmentCacheStore,
31
+ ShellCacheEntry,
32
+ } from "../cache/types.js";
29
33
  import { sortedSearchString } from "../cache/cache-key-utils.js";
30
34
  import type { SearchParamsFilter } from "../cache/search-params-filter.js";
31
35
  import {
@@ -88,20 +92,29 @@ function loadManifest(): Promise<ShellManifestModule | null> {
88
92
  * Spec-only keying is sound because buildVersion is process-constant on the
89
93
  * manifest path (folded into the shipped worker; dev never loads a manifest).
90
94
  */
91
- const validatedSpecs = new Map<string, BuildShellEntry | null>();
95
+ const validatedSpecs = new Map<string, ValidatedBuildShellEntry | null>();
96
+
97
+ /**
98
+ * A manifest record whose document half passed hasIntactShellPayload — the
99
+ * casts below sit directly on that runtime gate (build entries are always
100
+ * document-shaped; the predicate narrows entry, not the record around it).
101
+ */
102
+ type ValidatedBuildShellEntry = BuildShellEntry & {
103
+ entry: DocumentShellCacheEntry;
104
+ };
92
105
 
93
106
  async function validatedManifestRecord(
94
107
  mod: ShellManifestModule,
95
108
  spec: string,
96
109
  buildVersion: string,
97
- ): Promise<BuildShellEntry | undefined> {
110
+ ): Promise<ValidatedBuildShellEntry | undefined> {
98
111
  let verdict = validatedSpecs.get(spec);
99
112
  if (verdict === undefined) {
100
113
  const record = (await mod.loadShellAsset(spec)).default;
101
114
  verdict =
102
115
  isValidShellHit(record.entry, buildVersion) &&
103
116
  hasIntactShellPayload(record.entry)
104
- ? record
117
+ ? (record as ValidatedBuildShellEntry)
105
118
  : null;
106
119
  validatedSpecs.set(spec, verdict);
107
120
  }
@@ -118,7 +131,7 @@ export function resetBuildShellManifestForTests(): void {
118
131
  const warnedTagCheckUnsupported = new Set<string>();
119
132
 
120
133
  export interface BuildShellHit {
121
- entry: ShellCacheEntry;
134
+ entry: DocumentShellCacheEntry;
122
135
  /** Past createdAt + ttl: serve, but schedule the runtime recapture. */
123
136
  stale: boolean;
124
137
  }
@@ -273,7 +286,7 @@ export async function lookupBuildShell(
273
286
  const hasManifest = globalThis.__loadShellManifestModule !== undefined;
274
287
  if (!hasManifest && !dev) return null;
275
288
  if (sortedSearchString(url.searchParams, filter) !== "") return null;
276
- let record: BuildShellEntry | undefined;
289
+ let record: ValidatedBuildShellEntry | undefined;
277
290
  if (hasManifest) {
278
291
  const mod = await loadManifest();
279
292
  if (!mod) return null;
@@ -286,7 +299,7 @@ export async function lookupBuildShell(
286
299
  fetched !== undefined &&
287
300
  isValidShellHit(fetched.entry, buildVersion) &&
288
301
  hasIntactShellPayload(fetched.entry)
289
- ? fetched
302
+ ? (fetched as ValidatedBuildShellEntry)
290
303
  : undefined;
291
304
  }
292
305
  if (!record) return null;
@@ -23,6 +23,7 @@ import { runBackground } from "../cache/background-task.js";
23
23
  import {
24
24
  CaptureQueueFullError,
25
25
  CaptureQueueWaitTimeoutError,
26
+ captureQueueDepths,
26
27
  enqueueSerializedCapture,
27
28
  } from "./capture-queue.js";
28
29
  import { SHELL_CAPTURE_MAX_WAIT_MS } from "./shell-capture-constants.js";
@@ -397,6 +398,43 @@ function warnSnapshotOverCapOnce(
397
398
  );
398
399
  }
399
400
 
401
+ /**
402
+ * Dev warning threshold (ms) for the slowest bake-source settlement wait. Bake
403
+ * time is by-design shell latency (top-level pushed handle promises and
404
+ * bake-lane loader containers settle before the freeze), but it recurs on
405
+ * EVERY capture of the route and occupies the per-isolate serialized capture
406
+ * queue — silently: the served response's own timing never shows it (a route
407
+ * can read 13ms TTFB while each of its captures holds the queue for seconds).
408
+ * Past this threshold the source gets named once per key with the remedy
409
+ * ladder.
410
+ */
411
+ const SHELL_CAPTURE_BAKE_WARN_MS = 2_000;
412
+
413
+ /** Keys already warned about an expensive bake source (once per key). */
414
+ const warnedBakeCosts = new Set<string>();
415
+
416
+ /**
417
+ * Dev-only, once per key: name the slowest bake source and the three remedies
418
+ * in preference order. The doctrine ("nesting = liveness") makes the cost
419
+ * legitimate, so this is a cost report with an exit, not a deprecation.
420
+ */
421
+ function warnBakeCostOnce(key: string, source: string, ms: number): void {
422
+ if (warnedBakeCosts.has(key)) return;
423
+ warnedBakeCosts.add(key);
424
+ console.warn(
425
+ `[rango] Shell capture for "${key}" waited ${ms}ms for ${source} to ` +
426
+ "settle before the shell could freeze. This cost recurs on every capture " +
427
+ "of the route and occupies the per-isolate capture queue; the served " +
428
+ "response never shows it. Top-level promises BAKE by design (the " +
429
+ "container settles; nested promises stay live). To make the value " +
430
+ "per-request instead, nest it ({ data: promise }) and read it with use() " +
431
+ "under Suspense; to keep it baked but cheap, wrap the work in cache() " +
432
+ "(the capture replays the cached value); for a segment loader, add a " +
433
+ "loading() boundary to its entry (live lane, masked at capture). See the " +
434
+ "/ppr skill (node_modules/@rangojs/router/skills/ppr/SKILL.md).",
435
+ );
436
+ }
437
+
400
438
  /**
401
439
  * One structured event from the background capture pipeline, mirroring the
402
440
  * CFCacheReadDebugEvent pattern (cache/cf/cf-cache-types.ts): typed fields an
@@ -418,6 +456,10 @@ export interface ShellCaptureDebugEvent {
418
456
  * - skip-backoff: the key is inside its refused-capture backoff window and
419
457
  * the capture was not attempted
420
458
  * - skip-capacity: the isolate capture queue is full; a later request may retry
459
+ * - skip-inert-store: the resolved store's shell family is missing or
460
+ * declared inert (SegmentCacheStore.shellFamilyInert — a CFCacheStore
461
+ * without a KV namespace); nothing could store the result, so the
462
+ * background render was not scheduled at all
421
463
  * - skip-queue-timeout: the capture waited past CAPTURE_QUEUE_WAIT_BUDGET_MS
422
464
  * behind other captures and was dropped unrun (no backoff — the route is
423
465
  * not doomed, the isolate was busy; a later request re-probes). Carries
@@ -434,6 +476,7 @@ export interface ShellCaptureDebugEvent {
434
476
  | "skip-in-flight"
435
477
  | "skip-backoff"
436
478
  | "skip-capacity"
479
+ | "skip-inert-store"
437
480
  | "skip-queue-timeout"
438
481
  | "backoff";
439
482
  /** Attempt number (1 = first, 2 = in-place retry). Absent on skips. */
@@ -466,6 +509,22 @@ export interface ShellCaptureDebugEvent {
466
509
  backoffRemainingMs?: number;
467
510
  /** Ms the capture waited in the serialized queue (skip-queue-timeout). */
468
511
  queueWaitMs?: number;
512
+ /** Queue priority class at enqueue: document outranks queued navigation. */
513
+ queuePriority?: "document" | "navigation";
514
+ /**
515
+ * Captures ahead at enqueue: the active one plus every waiting capture of
516
+ * same-or-higher priority. 0 = this capture starts immediately.
517
+ */
518
+ queueAhead?: number;
519
+ /**
520
+ * Ms the capture gate was HELD waiting for the slowest bake source — a
521
+ * top-level pushed handle promise or a bake-lane loader container — to
522
+ * settle, measured from capture start. Bake time is by-design shell latency
523
+ * (the hole doctrine: top-level promises bake), but it recurs on EVERY
524
+ * capture of the route and occupies the serialized queue; this field makes
525
+ * it attributable. Absent when nothing held the gate.
526
+ */
527
+ bakeWaitMs?: number;
469
528
  }
470
529
 
471
530
  /**
@@ -515,6 +574,15 @@ export function describeShellCaptureEvent(
515
574
  if (event.queueWaitMs !== undefined) {
516
575
  parts.push(`queue-wait=${event.queueWaitMs}ms`);
517
576
  }
577
+ if (event.queuePriority !== undefined) {
578
+ parts.push(`queue-priority=${event.queuePriority}`);
579
+ }
580
+ if (event.queueAhead !== undefined) {
581
+ parts.push(`queue-ahead=${event.queueAhead}`);
582
+ }
583
+ if (event.bakeWaitMs !== undefined) {
584
+ parts.push(`bake=${event.bakeWaitMs}ms`);
585
+ }
518
586
  return parts.join(" ");
519
587
  }
520
588
 
@@ -864,6 +932,16 @@ export function scheduleShellCapture(
864
932
  });
865
933
  return;
866
934
  }
935
+ // A capture whose write can only no-op is dead work that still occupies the
936
+ // serialized queue (a promise-heavy route bakes for seconds per MISS),
937
+ // starving captures that CAN store. Skip scheduling entirely when the
938
+ // resolved store (same defaulting as captureAndStoreShell) has no shell
939
+ // family or declared it inert (CFCacheStore without a KV namespace).
940
+ const scheduledStore = descriptor.store ?? reqCtx._cacheStore;
941
+ if (!scheduledStore?.putShell || scheduledStore.shellFamilyInert) {
942
+ publishCaptureDebugEvent(descriptor, { key, outcome: "skip-inert-store" });
943
+ return;
944
+ }
867
945
  inFlightCaptures.add(key);
868
946
  const captureTask = async (span: TraceSpan) => {
869
947
  try {
@@ -941,15 +1019,34 @@ export function scheduleShellCapture(
941
1019
  const serializedTask = async () => {
942
1020
  await observePhase(PHASES.background("shell-capture"), async (span) => {
943
1021
  span.setAttribute("rango.shell_key", key);
1022
+ // A production page can enqueue several navigation-only captures through
1023
+ // viewport prefetching. Let a later document MISS overtake that queued
1024
+ // speculative work so it can become a shell HIT before the 15s queue
1025
+ // budget expires. Never preempts the active capture. Priority and the
1026
+ // backlog ahead at enqueue ride the span (and the skip event below), so
1027
+ // a skip-queue-timeout diagnoses itself instead of needing a trace dive.
1028
+ const queuePriority = descriptor.navigationOnly
1029
+ ? ("navigation" as const)
1030
+ : ("document" as const);
1031
+ const depths = captureQueueDepths();
1032
+ const queueAhead =
1033
+ (depths.running ? 1 : 0) +
1034
+ depths.document +
1035
+ (queuePriority === "navigation" ? depths.navigation : 0);
1036
+ span.setAttribute("rango.background.queue_priority", queuePriority);
1037
+ span.setAttribute("rango.background.queue_ahead", queueAhead);
944
1038
  const queueStart = performance.now();
945
1039
  try {
946
- await enqueueSerializedCapture(() => {
947
- span.setAttribute(
948
- "rango.background.queue_wait_ms",
949
- Math.round(performance.now() - queueStart),
950
- );
951
- return captureTask(span);
952
- });
1040
+ await enqueueSerializedCapture(
1041
+ () => {
1042
+ span.setAttribute(
1043
+ "rango.background.queue_wait_ms",
1044
+ Math.round(performance.now() - queueStart),
1045
+ );
1046
+ return captureTask(span);
1047
+ },
1048
+ { priority: queuePriority },
1049
+ );
953
1050
  } catch (error) {
954
1051
  if (error instanceof CaptureQueueFullError) {
955
1052
  inFlightCaptures.delete(key);
@@ -974,6 +1071,8 @@ export function scheduleShellCapture(
974
1071
  key,
975
1072
  outcome: "skip-queue-timeout",
976
1073
  queueWaitMs: Math.round(error.waitedMs),
1074
+ queuePriority,
1075
+ queueAhead,
977
1076
  });
978
1077
  return;
979
1078
  }
@@ -1035,6 +1134,7 @@ type CaptureAttemptStats = Pick<
1035
1134
  | "snapshotSkipped"
1036
1135
  | "untaggedBake"
1037
1136
  | "storeWrite"
1137
+ | "bakeWaitMs"
1038
1138
  >;
1039
1139
 
1040
1140
  /**
@@ -1511,6 +1611,29 @@ async function captureAndStoreShell(
1511
1611
  loaderRecordsForHold && loaderRecordsForHold.size > 0
1512
1612
  ? Promise.allSettled([handlesBaked, ...loaderRecordsForHold.values()])
1513
1613
  : handlesBaked;
1614
+ // Bake-cost attribution: record how long each bake source held the gate,
1615
+ // measured from capture start. Side-channel observers only — holdUntil and
1616
+ // its consumers are untouched, and a rejected source is recorded the same
1617
+ // (allSettled swallows it; the drain below owns the refusal). Folded into
1618
+ // stats.bakeWaitMs (+ a once-per-key dev warning) after a successful
1619
+ // capture; on a refusal the other warnings own the story.
1620
+ const bakeStart = performance.now();
1621
+ const bakeWaits: { source: string; ms: number }[] = [];
1622
+ const observeBake = (source: string, promise: Promise<unknown>): void => {
1623
+ const record = (): void => {
1624
+ bakeWaits.push({
1625
+ source,
1626
+ ms: Math.round(performance.now() - bakeStart),
1627
+ });
1628
+ };
1629
+ promise.then(record, record);
1630
+ };
1631
+ observeBake("top-level pushed handle promises", handlesBaked);
1632
+ if (loaderRecordsForHold) {
1633
+ for (const [loaderId, promise] of loaderRecordsForHold) {
1634
+ observeBake(`bake-lane segment loader "${loaderId}"`, promise);
1635
+ }
1636
+ }
1514
1637
  const gate = gateFlightForCapture(rscStream, undefined, holdUntil);
1515
1638
  // Quiesce = handles settled AND the Flight shell rows went task-quiet. Either
1516
1639
  // half stalling is bounded by captureShellHTML's maxWaitMs.
@@ -1590,6 +1713,23 @@ async function captureAndStoreShell(
1590
1713
  }
1591
1714
  if (stats) stats.preludeBytes = result.prelude.length;
1592
1715
 
1716
+ // Every bake source has settled by here (quiesce gates on holdUntil).
1717
+ // Surface the slowest one: sub-ms settles are noise, threshold-crossers
1718
+ // get the dev warning with the remedy ladder.
1719
+ let slowestBake: { source: string; ms: number } | undefined;
1720
+ for (const wait of bakeWaits) {
1721
+ if (!slowestBake || wait.ms > slowestBake.ms) slowestBake = wait;
1722
+ }
1723
+ if (slowestBake && slowestBake.ms > 0) {
1724
+ if (stats) stats.bakeWaitMs = slowestBake.ms;
1725
+ if (
1726
+ process.env.NODE_ENV !== "production" &&
1727
+ slowestBake.ms >= SHELL_CAPTURE_BAKE_WARN_MS
1728
+ ) {
1729
+ warnBakeCostOnce(capture.key, slowestBake.source, slowestBake.ms);
1730
+ }
1731
+ }
1732
+
1593
1733
  // Store per the flag's key/ttl/swr/tags, into the flag's store: the middleware
1594
1734
  // threads the SAME store it resolved for its getShell read (options.store ??
1595
1735
  // _cacheStore), so a store-attached middleware writes captures where it reads
@@ -1746,11 +1886,23 @@ async function captureAndStoreShell(
1746
1886
  if (store?.putShell) {
1747
1887
  try {
1748
1888
  const entry: ShellCacheEntry = {
1749
- // slice() copies just this view's bytes into a fresh ArrayBuffer, so a
1750
- // prelude that is a subarray of a larger backing buffer encodes only its
1751
- // own region bufferToBase64 reads the whole ArrayBuffer it is handed.
1752
- prelude: bufferToBase64(result.prelude.slice().buffer as ArrayBuffer),
1753
- postponed: result.postponed,
1889
+ // Document half only for document captures. A navigationOnly entry's
1890
+ // HTML is never served (document reads skip the flag; partial replay
1891
+ // consumes only snapshot/docKey), so storing it would ride every KV
1892
+ // write/read as dead weight — the prerender still ran above as the
1893
+ // completeness arbiter and sanity gate, its output is dropped here.
1894
+ ...(capture.navigationOnly
1895
+ ? {}
1896
+ : {
1897
+ // slice() copies just this view's bytes into a fresh
1898
+ // ArrayBuffer, so a prelude that is a subarray of a larger
1899
+ // backing buffer encodes only its own region — bufferToBase64
1900
+ // reads the whole ArrayBuffer it is handed.
1901
+ prelude: bufferToBase64(
1902
+ result.prelude.slice().buffer as ArrayBuffer,
1903
+ ),
1904
+ postponed: result.postponed,
1905
+ }),
1754
1906
  reactVersion: React.version,
1755
1907
  buildVersion: capture.buildVersion,
1756
1908
  // The theme this capture's payload was built with (buildFullPayload
@@ -20,7 +20,11 @@ import React from "react";
20
20
  import { isPprEntry, type EntryData } from "../server/context.js";
21
21
  import { sortedSearchString } from "../cache/cache-key-utils.js";
22
22
  import type { SearchParamsFilter } from "../cache/search-params-filter.js";
23
- import type { ShellCacheEntry, SegmentCacheStore } from "../cache/types.js";
23
+ import type {
24
+ DocumentShellCacheEntry,
25
+ ShellCacheEntry,
26
+ SegmentCacheStore,
27
+ } from "../cache/types.js";
24
28
  import { SHELL_CAPTURE_MAX_WAIT_MS } from "./shell-capture-constants.js";
25
29
 
26
30
  /** Debug/status header the browser (and e2e assertions) can read: HIT | MISS. */
@@ -150,18 +154,29 @@ export function isValidShellHit(
150
154
  }
151
155
 
152
156
  /**
153
- * Payload integrity gate, run BEFORE the HIT response commits: a stored entry
154
- * whose prelude is not decodable base64 or whose postponed blob is not
155
- * parseable JSON would otherwise throw AFTER the 200 + full static prelude
156
- * flushed (`serveShellHit` decodes at stream construction, `resumeShellHTML`
157
- * parses in the tail) — the client gets a visually complete page that never
158
- * hydrates, re-served on every request until the entry ages out (no eviction
159
- * path exists; failure schedules no recapture by itself). Checking here turns
160
- * a corrupt entry (store-layer fault) into a plain MISS the recapture
161
- * overwrites. Cost: one duplicate decode/parse per HIT, sub-ms against a
162
- * prelude flush that dominates the path.
157
+ * DOCUMENT-half integrity gate and type narrowing, run BEFORE the HIT response
158
+ * commits: a stored entry whose prelude is not decodable base64 or whose
159
+ * postponed blob is not parseable JSON would otherwise throw AFTER the 200 +
160
+ * full static prelude flushed (`serveShellHit` decodes at stream construction,
161
+ * `resumeShellHTML` parses in the tail) — the client gets a visually complete
162
+ * page that never hydrates, re-served on every request until the entry ages
163
+ * out (no eviction path exists; failure schedules no recapture by itself).
164
+ * Checking here turns a corrupt entry (store-layer fault) into a plain MISS
165
+ * the recapture overwrites. Cost: one duplicate decode/parse per HIT, sub-ms
166
+ * against a prelude flush that dominates the path.
167
+ *
168
+ * navigationOnly entries store no document half (prelude/postponed absent) and
169
+ * therefore never pass. The partial-replay path skips this gate for them
170
+ * (replayableShellSnapshot) — snapshot fragment corruption is caught by the
171
+ * consumer-side Flight decoders instead (SegmentFragmentDecodeError → healing
172
+ * capture).
163
173
  */
164
- export function hasIntactShellPayload(entry: ShellCacheEntry): boolean {
174
+ export function hasIntactShellPayload(
175
+ entry: ShellCacheEntry,
176
+ ): entry is DocumentShellCacheEntry {
177
+ if (typeof entry.prelude !== "string" || entry.postponed === undefined) {
178
+ return false;
179
+ }
165
180
  try {
166
181
  base64ToBytes(entry.prelude);
167
182
  if (entry.postponed !== null) JSON.parse(entry.postponed);