Skip to content

feat(ppr): captureTimeout + handle-settle capture gate; nameless-ppr coverage (#714, #715) - #716

Merged
ivogt merged 1 commit into
mainfrom
feat/ppr-capture-timeout
Jul 7, 2026
Merged

ivogt merged 1 commit into
mainfrom
feat/ppr-capture-timeout

Conversation

@ivogt

@ivogt ivogt commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

fix(ppr): nameless routes verified + pinned on the shell lane (#714); per-route captureTimeout capture budget for deferred shell material (#715)

Closes #714, closes #715.

Problem statement

A production storefront's document routes push deferred shell material — ctx.use(Meta)(dataPromise.then(...)) and top-level handle pushes carrying promises that settle in parts (an immediate part, a slow upstream part, and a Meta title chained off the slow part). Under PPR the intended semantic is that the capture awaits the complete settlement sequence and bakes the settled values into the stored shell. The capture's settle budget was a hard-coded 5 s: any route whose material settles slower was uncapturable forever — eternal x-rango-shell: MISS, backoff, and the once-per-key no-usable-shell warning — with no way to declare "this route's shell material takes ~7 s, wait for it" (#715). Separately, #714 reported that declaring ppr on a nameless route was silently ignored (no header, no store read, no log).

#714 root cause (investigated first, as filed)

The reported name-dependence does not exist in the entry pipeline, and could not be reproduced on main in any shape. The DSL stores the ppr option on the route EntryData regardless of name (src/urls/path-helper.ts:180-182 — nameless routes register under a synthesized $path_* manifest key with the option intact), and the serve gate reads only that entry (resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry), src/rsc/rsc-rendering.ts:127). Verified end to end, including in the discovering app itself (rsc-cloudflare-app, experimental.146) with instrumentation at the gate:

[714-debug] gate {"pathname":"/shop/test/123","routeKey":"shop.$path__test__id",
  "entryPpr":{"ttl":300,"swr":86400},"pprConfig":{"ttl":300,"swr":86400}}

Nameless ppr flips MISS→HIT on main and on exp.146 across: node dev/production (mini), Cloudflare vite dev / vite preview / wrangler dev on workerd (cloudflare-basic + the real app), host-router with path-based mounts + basename (the real app's exact topology), named/merged/bare/lazy include() shapes, and an HMR-added route.

What does reproduce the reported symptom exactly — no header, no store read, no capture, no log — is a document GET whose Accept lacks text/html (curl's default */*): on experimental.146 the old negotiation rule served such requests the Flight wire format (text/x-component), and the shell lane is (correctly) skipped for Flight responses. That bypass is name-independent (verified: on exp.146, the named control showed the identical no-header shape under plain curl) and was fixed the same day the issue was filed by #705 (bc054c10, "RSC flight wire format becomes explicit opt-in"), which landed after the .146 publish. Conclusion: the fix bar's preferred path — nameless ppr WORKS — already holds; what was missing was the coverage the issue calls out ("the nameless+ppr combination has zero coverage"). This PR pins it so it can never silently regress:

  • e2e MISS→HIT for a nameless param-bearing ppr route in both apps, dev + production (/shell-cache/nameless/:probe, /ppr-nameless/:probe);
  • a unit round-trip: nameless path() → loadManifest under the synthesized $path_* key → ppr intact → resolvePprConfig resolves (shell-serve-ppr-config.test.ts).

#715: ppr.captureTimeout — before vs. after

// BEFORE: this route's shell material settles at ~6.5s; the capture budget is a
// hard-coded 5s, so the route is uncapturable — eternal MISS + warning.
path("/pdp/:id", ProductPage, { name: "product", ppr: { ttl: 600, swr: 120 } });

// AFTER: declare the settle budget; the capture awaits the full settlement
// sequence and bakes the resolved values. Everything else unchanged.
path("/pdp/:id", ProductPage, {
  name: "product",
  ppr: { ttl: 600, swr: 120, captureTimeout: 10_000 },
});

End-to-end consumer example (the storefront meta pattern)

import { urls, Meta, createHandle } from "@rangojs/router";
import type { HandlerContext } from "@rangojs/router";

export const PdpHandles = createHandle<string, string[]>((v) => v.flat());

function PdpLayout(ctx: HandlerContext) {
  const data = fetchProduct(ctx.params.id);        // settles ~5.5s upstream
  const push = ctx.use(PdpHandles);
  push(Promise.resolve("badge:new"));               // immediate part
  push(data.then((p) => `brand:${p.brand}`));       // slow part
  // Meta CHAINED off the slow data with extra latency (~6.5s total): the
  // capture rides the COMPLETE sequence — never a partial prefix.
  ctx.use(Meta)(data.then(async (p) => ({ title: await seoTitle(p) })));
  return <main><PdpView /><Outlet /></main>;
}

export const pdpPatterns = urls(({ path, loader, loading }) => [
  path("/pdp/:id", PdpPage, {
    name: "pdp",
    ppr: { ttl: 600, swr: 120, captureTimeout: 10_000 },
  }, () => [loader(LivePriceLoader), loading(<PriceSkeleton />)]),
]);

First document GET: MISS (axis 1). Background capture awaits all three parts, bakes the resolved <title> and handle values into the stored prelude. Every later GET: HIT — frozen head/meta flushed immediately, the price hole resumed live. If the upstream ever outlasts the budget, the capture refuses (stays MISS, once-per-key warning) — a shell with missing or unsettled head material is never stored.

Semantics table

Case Before After
Deferred material settles < 5 s, no captureTimeout captured, baked unchanged — captured, baked
Deferred material settles > 5 s, no captureTimeout eternal MISS + no-usable-shell warning unchanged (deliberate) — the default budget stays 5 s; the negative e2e pins it
Deferred material settles > 5 s, captureTimeout large enough eternal MISS captured — the gate holds until the FULL settlement sequence (chained pushes included), then bakes all resolved parts
Budget expires with pushes still pending (any budget) refuse (root-suspended prelude, no <body>) unchanged — refusal; a partial-prefix bake is structurally unrepresentable (holdUntil blocks the freeze; SsrRoot suspends at the root until the final snapshot)
Invalid captureTimeout (0, negative, NaN, ∞, non-number) n/a normalized to the capture default (5 s); never reaches setTimeout
Live (axis 1) render of the same route blocks on the handle barrier until pushes settle unchanged — pinned by a dedicated e2e
Producer B (Prerender+ppr build capture) + dev /__rsc_shell fixed 5 s honor the same knob; the dev read-through fetch bound scales with it (devShellFetchTimeoutMs)
workerd waitUntil lifetime (~30 s past response) implicit ceiling unchanged — documented as the physical ceiling (Platform notes)
Nameless route with ppr works (verified; uncovered) works, pinned by e2e (both apps, dev+prod) + unit round-trip
Flight-negotiated GET (no text/html Accept) shell lane skipped silently (named AND nameless) unchanged by this PR — #705 already made HTML the default for generic clients
SHELL_CAPTURE_WRITE_BARRIER_MS / SHELL_SNAPSHOT_WRITE_SETTLE_MS / retry delay / backoff windows 1.5 s / 1 s / 400 ms / exp. deliberately unchanged — they bound store I/O and scheduling, not shell-material settlement

Code-change summary

One knob, one deadline: the resolved option rides the existing descriptor into captureShellHTML's maxWaitMs, which already bounds both the fizz prerender and the deferred-material settle window (the holdUntil gate and the quiesce race are inputs to the same deadline) — no second timer to drift.

  • src/urls/pattern-types.ts — PartialPrerenderProps.captureTimeout?: number (ms, default 5000; consumer-facing JSDoc incl. refusal semantics and the workerd ceiling).

  • src/rsc/shell-serve.ts — ResolvedPprConfig.captureTimeout + exported normalizeCaptureTimeout (finite ≥ 1 ms passes; anything else → undefined → capture default; the ONE normalizer — the dev endpoint reuses it at its query-string boundary) + DEV_SHELL_PROBE_TIMEOUT_MS (the dev endpoint's pre-flight probe timeout, hoisted so the client fetch bound enumerates the same term). Key block:

    return {
      ttl: ppr.ttl ?? DEFAULT_PPR_TTL_SECONDS,
      swr: ppr.swr,
      tags: ppr.tags,
      captureTimeout: normalizeCaptureTimeout(ppr.captureTimeout),
    };
  • src/rsc/shell-capture.ts — ShellCaptureDescriptor.captureTimeout; captureAndStoreShell passes maxWaitMs: capture.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS (the default's single owner stays here).

  • src/rsc/rsc-rendering.ts — threads pprConfig.captureTimeout onto the runtime descriptor AND the dev build-shell read-through options.

  • src/rsc/shell-build-manifest.ts — DevShellLookup.captureTimeout; the /__rsc_shell query param; devShellFetchTimeoutMs sizes the dev fetch bound to the endpoint's FULL sequential worst case: DEV_SHELL_PROBE_TIMEOUT_MS + 2×budget + DEV_SHELL_RETRY_MARGIN_MS (probe + two attempts + retry margin — a bound that omitted the sequential 10 s pre-flight probe could abort a still-healthy capture into a spurious MISS, latent even at defaults: 21 s worst case vs the old 20 s bound).

  • src/vite/router-discovery.ts — the dev endpoint re-normalizes captureTimeout at its query-string boundary via the shared normalizeCaptureTimeout (a garbage param must not reach setTimeout as NaN), includes it in the memo key, forwards to captureShellForBuild; the pre-flight probe uses the shared DEV_SHELL_PROBE_TIMEOUT_MS.

  • src/prerender/build-shell-capture.ts, src/vite/discovery/{state,shell-prerender-phase}.ts — producer B parity: the candidate's raw option flows through resolveBuildPprConfig into the build capture descriptor.

  • docs/design/ppr-shell-resume.md — new "captureTimeout: the capture settle budget" section (ordering contract, refusal, producer B parity, deliberately-unchanged bounds) + Platform notes documenting the workerd waitUntil lifetime as the physical ceiling; the opt-in section records the ppr on a NAMELESS route is silently ignored — no shell lane, no header, no warning #714 verdict. docs/internal/feature-map.md / feature-file-map.md updated in the same PR.

Chosen location over alternatives: no new timer primitive and no per-phase budget split — the existing single maxWaitMs deadline already expresses "bounded end to end", and the ordering contract (freeze never fires while a tracked top-level push is pending; expiry with pending pushes refuses via the trivial-prelude sanity gate) is enforced by the existing holdUntil/root-suspension structure, now documented and pinned by tests.

Test plan

  • Red→green (ppr capture budget must be configurable — deferred shell material (async Meta / top-level handle pushes) beyond 5s makes a route uncapturable #715): with the one plumb line reverted (maxWaitMs: SHELL_CAPTURE_MAX_WAIT_MS), /shell-cache/slow-meta (captureTimeout: 10000 declared) stays MISS forever and logs Shell capture for "…slow-meta?probe=red:shell" produced no usable shell after an in-place retry; with the plumb restored the same route flips MISS → HIT and the stored prelude carries all three resolved parts. Unit red: the new shell-capture.test.ts maxWaitMs pins fail without the descriptor plumb.
  • Red-adjacent (ppr on a NAMELESS route is silently ignored — no shell lane, no header, no warning #714): the nameless e2e passes on the unmodified pipeline — consistent with the root-cause finding that no code change is needed; the coverage is the fix.
  • Unit: shell-serve-ppr-config.test.ts (12 tests — captureTimeout parsing matrix + the nameless $path_* loadManifest round-trip; pins the entry-carries-ppr contract), shell-capture.test.ts (+2: descriptor→maxWaitMs, default 5000), rsc-rendering-shell-ppr.test.ts (+2: descriptor flow, invalid-value normalization; +1 assert). pnpm run test:unit:all green (router + every consumer app).
  • e2e test-app (e2e/shell-capture-timeout.test.ts, isolatedServer): 3 dev + 3 production — nameless MISS→HIT (ppr on a NAMELESS route is silently ignored — no shell lane, no header, no warning #714); staged slow-meta MISS→HIT with all three parts baked in the stored prelude at one capture seq, <title> = the resolved chained value, values frozen across consecutive HITs, and the live path pinned off the same test's MISS body (the axis-1 render awaits the full sequence); the no-knob sibling stays MISS with the once-per-key warning asserted from the server log. The staged fixture (immediate + 5.5 s + chained +1 s) pins staged resolution, chaining, and meta correctness in one route.
  • e2e cloudflare-basic (e2e/ppr-capture-timeout.test.ts, workerd + KV CFCacheStore): 3 dev + 3 production — nameless MISS→HIT; slow-meta MISS→HIT with the full prelude/title/frozen-seq assertions; a too-short explicit budget (1500 ms vs ~3.5 s material) refuses and stays MISS. The no-knob default-budget negative is test-app-only by design: two 5 s default attempts would park this suite's shared serialized capture queue (capture-queue.ts), and the shared webServer exposes no log handle for the warning assert — flagged here rather than silently skipped.
  • Batteries: rangojs-router shell-cache + shell-secure + shell-cache-action + prerender-ppr — 46 dev + 46 production, all green; cloudflare-basic ppr-shell + ppr-tag-eviction — 33 dev (1 unrelated flaky, passed on retry: strict-mode duplicate element in the bake-lane settled test) + 32 production, green; new suites 3+3 (test-app) and 3+3 (cloudflare). Post-simplify re-gate: rangojs-router shell-cache + shell-capture-timeout 33 dev (2 pre-existing shell-cache flaky, passed on retry) + 34 production (1 flaky, passed on retry — fixture pnpm spawn noise); cloudflare-basic ppr-capture-timeout + ppr-shell 30 dev + 28 production, all green.
  • Gate: pnpm run typecheck, pnpm run test:unit:all, pnpm run lint, pnpm run format, pnpm check:e2e-bucketing, pnpm check:docs-api — all green. router.named-routes.gen.ts regenerated in both apps whose routes changed.

Notes

Simplify review

Applied:

  1. One shared captureTimeout normalizer — normalizeCaptureTimeout(value: unknown) exported from src/rsc/shell-serve.ts; the dev /__rsc_shell endpoint (src/vite/router-discovery.ts) re-normalizes its query param through it instead of an inline re-implementation (the redundant ?? NaN dropped — Number(null) is 0 and already fails ≥ 1).
  2. Dev fetch bound made complete (real latent bug) — devShellFetchTimeoutMs omitted the endpoint's sequential 10 s pre-flight probe, so the server's worst case (probe 10 s + 2×budget + retry ≈ 21 s at defaults) exceeded the 20 s client bound — a spurious client abort of a healthy capture. Now DEV_SHELL_PROBE_TIMEOUT_MS + 2×budget + DEV_SHELL_RETRY_MARGIN_MS, with the probe constant hoisted into shell-serve.ts and imported by BOTH sides (and the default budget imported from SHELL_CAPTURE_MAX_WAIT_MS, now exported) so no term can drift. Deeper fix noted as a follow-up: the endpoint owns one total deadline; the client bound becomes a plain liveness backstop.
  3. Duplicate page components removed — test-app ShellSlowMetaPage (byte-identical to ShellCachePricePage) deleted, both slow-meta routes point at ShellCachePricePage; cloudflare-basic PprSlowMetaPricePage deleted, the slow/short routes reuse PprShellPricePage.
  4. Comment triplication — the 10-line in-body comment above maxWaitMs in shell-capture.ts shrunk to one pointer at the canonical spec (PartialPrerenderProps.captureTimeout, urls/pattern-types.ts).
  5. Stale comment — shell-cache.defs.ts: "(+2s, ~7.5s total)" corrected to "(+1s, ~6.5s total)" (SLOW_META_CHAIN_EXTRA_MS is 1_000).
  6. Test fold — the standalone "live path renders the full sequence" e2e folded into the captureTimeout test's existing MISS leg (two toMatch asserts on the first response body); ~13 s saved across modes.

Skipped (deliberate):

  • Test-local fetchSplit vs splitPrelude duplication — pre-existing per-file convention (3× already in the suites).
  • Cross-app fixture duplication (test-app vs cloudflare-basic slow-meta) — deliberate: different presets and different negatives (no-knob default-budget vs explicit too-short budget).

…nameless-ppr coverage (closes #714, #715)

resolvePprConfig -> ShellCaptureDescriptor -> captureShellHTML maxWaitMs,
bounding BOTH the fizz prerender and the deferred-material settle window.
The freeze is ORDERED behind full handle-stream convergence: the gate
never fires while a top-level push is pending, staged/chained pushes
(immediate + slow + Meta chained off the slow one) converge to a single
yield, and budget expiry with pending pushes yields a body-less prelude
-> sanity-gate refusal — a partial-prefix bake (wrong/missing meta in
the snapshot) is unrepresentable. Producer B and the dev /__rsc_shell
read-through honor the same knob; the dev fetch bound now models the
FULL server envelope (probe + 2x budget + retry margin — the old
2x+10s bound was exceedable even at defaults, aborting healthy captures
into spurious MISSes). workerd waitUntil (~30s) documented as ceiling.

DSL stores ppr on the synthesized $path_* entry regardless of name
(path-helper.ts); the field symptom was a stale-server artifact plus
the pre-#705 Accept-negotiation silence. Nameless param-bearing ppr
fixtures + MISS->HIT e2e now pin the combination in both apps.

Red->green: slow-meta fixture (5.5s push + 1s chained Meta) with the
plumb reverted stays eternal-MISS with the once-per-key warning;
restored, it HITs with the resolved chained <title> frozen in the
stored prelude. The no-knob sibling remains the permanent in-tree red
pin. Validated end-to-end in a real storefront (7s deferred meta chain,
captureTimeout: 12000 -> HIT at 16s, baked title byte-frozen across
HITs, package restored after).

Simplify review applied (6): shared normalizeCaptureTimeout (was
3 copies), complete dev fetch-bound formula w/ hoisted
DEV_SHELL_PROBE_TIMEOUT_MS, 2 duplicate fixture components deleted,
comment triplication collapsed, stale timing comment fixed, duplicate
6.5s live-path test folded into the MISS body read. Skipped (noted):
test-local splitPrelude convention; cross-app fixture duplication
(different presets/negatives — deliberate).

Batteries: router unit 5112; shell-cache+capture-timeout 33 dev/34
prod; cloudflare ppr suites 30 dev/28 prod; typecheck/lint/format/
bucketing/docs-api green.
@ivogt
ivogt force-pushed the feat/ppr-capture-timeout branch from eb751d2 to 4873773 Compare July 7, 2026 04:44
@pkg-pr-new

pkg-pr-new Bot commented Jul 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@716

commit: 4873773

@ivogt
ivogt merged commit 48e48c5 into main Jul 7, 2026
34 checks passed
@ivogt
ivogt deleted the feat/ppr-capture-timeout branch July 7, 2026 04:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant