Repository navigation
fix(ppr): dev-stability — producer-B first-request-HIT race (#719) + settled-fast hidden-artifact scoping (#718) - #727
Merged
Merged
Conversation
…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.
commit: |
…minal temp-server/registry failures fail fast, not 10s-retry; + cold/injection dev coverage. Addresses review P2/P3 on #719.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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: HITwith the frozen prelude. In dev that contract was probabilistic. On a fast machine the very first document request servedx-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'warmToHithelper masked it by warming before asserting.Root cause
The dev
/__rsc_shellendpoint 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.codeis 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)
Zero cost off the boot-race path
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-fastassertions 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$RCleaves fizz's hidden segment container (<div hidden id="S:0">) orphaned as a direct child of<body>. It carries thehiddenattribute (invisible, zero layout, out of the a11y tree) but holds a second-settled-fastnode, so a baregetByTestIdmatched both.Fix
Scope the
-label/-fastassertions under the visible settled container (data-testid="shell-settled"in the test-app,ppr-settledin cloudflare-basic); the orphan lives outside it, so the strict-mode locator selects only the in-place copy.Red then green
shell-build-manifest.test.ts— the newre-polls a NOT-READY boot-race signal, then serves once readytest is red on the old one-shot read-through (503 NOT-READY mapped straight to MISS, no first-request HIT), green with the bounded re-poll. The two guard tests pin the zero-cost contract:does NOT re-poll a genuine 404 negativeandconcedes a MISS when NOT-READY never clears (readiness wait is bounded).$RCdelay (forcing the client-render-before-resume window); 1/1 green after the container scope. Confirmed the orphan is once-on-wire (hidden, out of a11y tree).Release requirement
The endpoint half lives in the Vite plugin, so
pnpm build-routeris required fordist/viteto 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 infetchDevShellEntry+ theDEV_SHELL_NOT_READY/*_DEADLINE_MS/*_POLL_MSconstants and the boot-race readiness doc block.src/vite/router-discovery.ts—sendNotReady+isReoptimizingon the/__rsc_shellhandler; 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
packages/rangojs-routervitest): 5144 pass / 6 todo, incl. the 3 new shell-build-manifest readiness tests.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 indist/vite/index.js).--project=dev --no-deps(prerender-ppr, shell-cache, shell-secure, shell-cache-action): 45 passed.--project=production --no-deps: 45 passed.--project=dev(ppr-shell, ppr-tag-eviction): 31 passed.--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: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 intohandledAsReoptimizing(err): boolean(shared by all 3) +handleShellImportError(err)(the two import sites). The capture-error site keeps its distinctconsole.warn+ 404 fallback — only the shared guard was hoisted.isReoptimizing. Noted WHY the message-regex fallback exists alongside the structured.code === "ERR_OUTDATED_OPTIMIZED_DEP"check:.codeis stripped when the error serializes across the module-runner/workerd RPC boundary (the message survives), so under the Cloudflare preset.codealone would miss it.Skipped (with rationale):
devShellFetchTimeoutMsto a fixed liveness backstop. Cross-check note added to the issue.