Skip to content

fix(ppr): dev-stability — producer-B first-request-HIT race (#719) + settled-fast hidden-artifact scoping (#718) - #727

Merged
ivogt merged 3 commits into
mainfrom
fix/ppr-dev-stability
Jul 8, 2026
Merged

ivogt merged 3 commits into
mainfrom
fix/ppr-dev-stability

Conversation

@ivogt

@ivogt ivogt commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Two dev-mode PPR stability fixes for the shell/prerender path, kept as separate commits.

Problem 1 — #719: first-request-HIT contract broke on fast machines

A Prerender+ppr route promises the browser can't tell it was baked: the FIRST request must serve x-rango-shell: HIT with the frozen prelude. In dev that contract was probabilistic. On a fast machine the very first document request served x-rango-shell: MISS (axis-1, the live path) while a later request HIT — so a consumer refreshing a just-started dev server saw a shell miss, then a hit, with no error. It read as "consistent locally, 0-flake on CI" precisely because slow CI arrives after the boot infra has settled, and the tests' warmToHit helper masked it by warming before asserting.

Root cause

The dev /__rsc_shell endpoint boots its capture realm lazily on the first hit: the temp Node server (Cloudflare preset), the registry import, and — on either preset — a Vite dependency re-optimization that the first import of the shell-capture graph can trigger. All three are transient (a retry seconds later succeeds). The read-through (fetchDevShellEntry) fired ONCE and mapped any non-2xx to a hard MISS, so the boot window was conceded as a permanent first-request miss. Fast machines lose that race deterministically because the document request beats the boot infra.

Fix

The endpoint tags only the transient not-ready branches with x-rango-shell-dev: NOT-READY (HTTP 503) — a dep re-optimization catch (ERR_OUTDATED_OPTIMIZED_DEP, or its message when .code is stripped across the workerd RPC boundary), missing runners, and an empty registry. The read-through re-polls ONLY that signal: a bounded await of readiness, 10s deadline / 150ms interval. Every genuine outcome resolves on the first attempt.

Before vs. after (read-through)

// before — one shot, any non-2xx => permanent MISS
const res = await fetch(`${devUrl}/__rsc_shell?${params}`, { signal });
if (!res.ok) return undefined;
return (await res.json()) as BuildShellEntry;

// after — re-poll ONLY the NOT-READY boot-race signal, bounded
for (;;) {
  const res = await fetch(shellUrl, { signal });
  if (res.status === 503 &&
      res.headers.get("x-rango-shell-dev") === "NOT-READY" &&
      Date.now() < readinessDeadline) {
    await sleep(150); continue;
  }
  if (!res.ok) return undefined;         // genuine 404/fault: first attempt
  return (await res.json()) as BuildShellEntry;
}

Zero cost off the boot-race path

Endpoint outcome Status Read-through behavior Extra latency
Served entry (HIT/MISS, incl. memo HIT) 200 returns immediately none
Not prerenderable / capture refused 404 resolves to MISS at once none
Network fault / fetch timeout throw resolves to MISS at once none
Transient boot (re-optimize/runner/reg.) 503 NOT-READY re-polls to readiness one 150ms tick typically; capped 10s

Only the last row pays anything, and only until the boot infra settles (normally inside the first poll). A non-baked or already-warm route never stalls the foreground.

Problem 2 — #718: a hidden fizz artifact tripped a strict-mode locator

The *-settled-fast assertions in the shell/ppr settled-value tests intermittently strict-mode-collided on two matching nodes. Not a product bug.

Root cause

When the nested-promise Flight payload fills the dehydrated boundary before the trailing $RC("B:0","S:0") completeSegment script runs — a streaming window that #706's byte-copy fragment splice widens (the baked payload now finishes well ahead of the resume) — React client-renders the boundary in place and $RC leaves fizz's hidden segment container (<div hidden id="S:0">) orphaned as a direct child of <body>. It carries the hidden attribute (invisible, zero layout, out of the a11y tree) but holds a second -settled-fast node, so a bare getByTestId matched both.

Fix

Scope the -label/-fast assertions under the visible settled container (data-testid="shell-settled" in the test-app, ppr-settled in cloudflare-basic); the orphan lives outside it, so the strict-mode locator selects only the in-place copy.

// before — bare testid, strict-mode-collides with the hidden orphan
await expect(page.getByTestId("shell-settled-fast")).toHaveText(/Settled fast \d+/);
// after — scoped to the visible container
const settled = page.getByTestId("shell-settled");
await expect(settled.getByTestId("shell-settled-fast")).toHaveText(/Settled fast \d+/);

Red then green

Release requirement

The endpoint half lives in the Vite plugin, so pnpm build-router is required for dist/vite to reflect src before the plugin serves the NOT-READY signal in a consumer app. (dist is gitignored; the published package carries the built plugin.)

Code-change summary

Commit 1 (#719):

  • src/rsc/shell-build-manifest.ts — bounded re-poll loop in fetchDevShellEntry + the DEV_SHELL_NOT_READY / *_DEADLINE_MS / *_POLL_MS constants and the boot-race readiness doc block.
  • src/vite/router-discovery.ts — sendNotReady + isReoptimizing on the /__rsc_shell handler; the transient branches now emit NOT-READY.
  • src/rsc/__tests__/shell-build-manifest.test.ts — 3 new tests.

Commit 2 (#718):

  • e2e/test-app/src/components/ShellSettledValue.tsx — data-testid="shell-settled" container.
  • e2e/shell-cache.test.ts, tests/cloudflare-basic/e2e/ppr-shell.test.ts — scoped assertions.

Test plan

  • typecheck (root turbo): 16/16 pass.
  • unit (packages/rangojs-router vitest): 5144 pass / 6 todo, incl. the 3 new shell-build-manifest readiness tests.
  • lint (oxlint --deny-warnings): 0 warnings/errors. format (oxfmt --check): clean.
  • pnpm build-router: dist rebuilt so the plugin serves NOT-READY (verified the fold helpers landed in dist/vite/index.js).
  • e2e (all green, CI=true so canonical ports):
    • rangojs-router --project=dev --no-deps (prerender-ppr, shell-cache, shell-secure, shell-cache-action): 45 passed.
    • rangojs-router --project=production --no-deps: 45 passed.
    • cloudflare-basic --project=dev (ppr-shell, ppr-tag-eviction): 31 passed.
    • cloudflare-basic --project=production (pulls the unfiltered dev dependency): 333 passed, 2 skipped.

Simplify

Ran the /simplify protocol on the diff; two survivors applied, both in src/vite/router-discovery.ts:

  1. Fold the reoptimize-guard. The if (isReoptimizing(err)) { sendNotReady(...); return; } block was pasted at 3 catch sites, and the two entry-import catches were byte-identical (reoptimize then NOT-READY, else 500). Folded into handledAsReoptimizing(err): boolean (shared by all 3) + handleShellImportError(err) (the two import sites). The capture-error site keeps its distinct console.warn + 404 fallback — only the shared guard was hoisted.
  2. Altitude comment on isReoptimizing. Noted WHY the message-regex fallback exists alongside the structured .code === "ERR_OUTDATED_OPTIMIZED_DEP" check: .code is stripped when the error serializes across the module-runner/workerd RPC boundary (the message survives), so under the Cloudflare preset .code alone would miss it.

Skipped (with rationale):

ivogt added 2 commits July 7, 2026 16:37
…h re-polls to keep first-request HIT (#719)

Root cause: the dev `/__rsc_shell` endpoint boots its capture realm lazily on
the first hit — temp server (Cloudflare preset), registry import, and a Vite
dependency re-optimization the first shell-capture import can trigger. All are
transient. The read-through fired once and mapped any non-2xx to a hard
first-request MISS, so a Prerender+ppr route's FIRST request lost the race and
served axis-1 (x-rango-shell: MISS) while a later poll HIT — the build-time
first-request-HIT contract held only after a warm-up. Fast machines lose the
race deterministically; slow CI arrives after the boot infra settled, so it
read "consistent locally, 0-flake on CI". warmToHit in the tests masked it, but
the literal first-request-HIT contract was broken.

Fix: the endpoint tags the transient not-ready branches with
`x-rango-shell-dev: NOT-READY` (HTTP 503) — re-optimization catches, missing
runners, and an empty registry. The read-through re-polls ONLY that signal, a
bounded await of readiness (10s deadline, 150ms interval). Every genuine
outcome — a served entry, a real negative (404: not prerenderable / capture
refused), or a network fault — resolves on the first attempt, so a non-baked or
already-warm route pays zero extra latency. The endpoint half lives in the Vite
plugin, so `pnpm build-router` is required for dist/vite to reflect src.

Closes #719.
…to skip a hidden fizz artifact (#718)

The `*-settled-fast` duplicate is a benign, hidden fizz artifact, not a bug.
When the nested-promise Flight payload fills the dehydrated boundary before the
trailing `$RC("B:0","S:0")` completeSegment script runs — a streaming window
that #706's byte-copy fragment splice widens (the baked payload now finishes
well ahead of the resume) — React client-renders the boundary in place and
`$RC` leaves fizz's hidden segment container (`<div hidden id="S:0">`) orphaned
as a direct child of `<body>`. It carries the `hidden` attribute: invisible,
zero layout, out of the a11y tree — but it holds a second `-settled-fast` node,
so a bare testid locator strict-mode-collides with it.

Fix: scope the `-label` / `-fast` assertions under the visible settled
container (`data-testid="shell-settled"` / `ppr-settled`) so the strict-mode
locator selects only the in-place copy; the orphan lives outside it. Confirmed
once-on-wire; reproduced 6/6 by widening the $RC delay, 1/1 after the scope fix.

Closes #718.
@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@727

commit: 1c1f42f

…minal temp-server/registry failures fail fast, not 10s-retry; + cold/injection dev coverage. Addresses review P2/P3 on #719.
@ivogt
ivogt merged commit 4b907f4 into main Jul 8, 2026
33 of 34 checks passed
@ivogt
ivogt deleted the fix/ppr-dev-stability branch July 8, 2026 15:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant