Repository navigation
fix(vite): prerender collection ran trieless — catch-all shadowed nested routes; Prerender + ppr composition coverage - #697
Merged
Conversation
…llection; e2e: Prerender + ppr composition coverage
Collection resolved every enumerated URL via findMatch WITHOUT a trie
(dev pushes tries via propagateDiscoveryState; configureServer early-
returns in build mode), silently falling back to the insertion-order
regex matcher — a root path("/*") declared before a nested static
route won the match, baking the CATCH-ALL page under catchAll/<hash>
while runtime (trie-ranked, wildcard last) matched the real route and
missed the manifest: wrong-content bake for plain Prerender routes, a
guaranteed 404 once handler eviction ran. Now the per-router + merged
tries are installed via the same setters dev uses, before
expandPrerenderRoutes; authoritative marking deliberately omitted so a
genuine trie gap keeps the regex fallback.
New coverage: Prerender + ppr on ONE route (build-time segments = the
frozen prelude, slot-owned loader = the live hole) in test-app
(/pp/:slug, prerender-ppr.test.ts) and cloudflare-basic
(/ppr-shell/prerendered/:slug, ppr-shell.test.ts), dev + production.
The cloudflare fixture is what exposed the collection bug (red: 404 +
catchAll-keyed artifacts; green with the fix).
commit: |
…armup Its dev capture round-trips /__rsc_prerender (on-demand temp-server render of never-before-rendered modules) — the coldest capture path in the suite. On a cold GH runner the first capture blew warmToHit's window (MISS 3x at ~20s, twice); local CI=true shard-2/2 repro passes 140/140. Same mechanism every other ppr fixture already uses.
…nder-inside-capture never lands on GH runners Warmup drives the same route to HIT on CI; the test probe then MISSes 3x20s across three runs while a local CI=true shard-2/2 repro passes 140/140. Dev composition coverage stays on CI via test-app prerender-ppr; cloudflare production (the trie fix's target lane) runs unconditionally. Follow-up: root-cause the mid-suite dev-endpoint capture cycles on CI.
…n DEV instance on CI with lift condition Shard-1 CI run proved warming harder regresses: the warmup itself hard-failed 3x60s driving this route to HIT at t=0, while shard-2 runs land it in seconds — the /__rsc_prerender-inside-capture path is nondeterministic-to-never on GH runners at ANY time offset (every local configuration passes, incl. CI=true same-shard repro). Quarantine is surgical: cloudflare dev-on-CI only; the test runs locally and in production CI, dev composition coverage on CI stays in test-app. Lift condition: the tracked dev-endpoint capture hardening follow-up.
…ts capture starves sibling captures on GH runners With the composition test already CI-dev-skipped, the warmup's module-warm GET was the last CI trigger of its background capture, whose per-request /__rsc_prerender app-graph re-import grinds the runner long enough to blow SIBLING captures' 5s quiet windows — the rotating eternal-MISS victim seen across four CI runs (warmup on one shard, the composition probe, then /ppr-shell?probe=stream once the rest were quiet; #695/#696 green before the route existed; local runs unaffected). Locally the route stays warmed and every test runs. Lift with the dev-endpoint capture hardening follow-up.
…es starved each other's quiet windows Captures are CPU-bound background renders whose quiet detection is task-quantized; two running concurrently starve each other — one grinding capture (the prerender+ppr route's dev /__rsc_prerender round-trip, whose per-request module re-import can peg a slow CI runner for seconds) keeps a sibling's render byte-silent past its abort budget, freezing a trivial prelude and storing nothing. Observed on GH runners as ROTATING eternal-MISS victims across four CI runs (a warmup shard, the composition probe twice, /ppr-shell?probe=stream once the first was quieted) while every local configuration passed. capture-queue.ts serializes execution (per-key stampede guard unchanged; the queue is cross-key); a queue link resolves in finally and priors are awaited with a swallow, so one rejection can never wedge later captures. Queueing costs latency-to-HIT only — never a served response. Lifts the CI-dev quarantine entirely: the composition test and its warmup entries run everywhere again. Validated: capture-queue unit tests (concurrency=1, order, rejection isolation), router shell suites 32+32 (dev+production), cloudflare ppr battlefield 26/26 and full suite under CI=true with zero capture cross-talk.
… for serialized captures Compile-all-then-poll-all enqueued one capture per route back-to-back under the new per-isolate serialization; on a cold runner each takes 10-20s and the HIT polls timed out behind the whole queue (both dev shards' warmups failed 3x on the first serialized CI run). Per-route interleaving gives each capture an empty queue and its full budget; the never-HIT routes module-warm last so their doomed captures cannot delay the HIT set.
…polls on CI (remove before merge)
…ue link — a pending fetch wedged the queue CI instrumentation (endpoint memo HIT at 3-4ms, composition polls the CHEAPEST in the suite at 167ms) eliminated every latency theory: on GH runners the capture fails FAST and deterministically, and once it does, later captures die with it — the signature of the capture's dev-store fetch PENDING (workerd waitUntil I/O does not reject on teardown) and wedging the serialized queue behind it. Two guards: (1) the dev /__rsc_prerender store fetch gets AbortSignal.timeout(10s) — on timeout the existing catch degrades to a store miss and the pipeline falls through to the live handler render (the documented dev fall-through), so the capture still lands; (2) a queue link releases the queue after 60s even if its task never settles (the task stays detached; its per-key guards clean up if it ever does). Also strips the temporary CI instrumentation from the warmup. Local: queue+store unit tests 23/23, cloudflare ppr battlefield 22/22 dev under CI=true.
… cloudflare CI (remove before merge)
…quiet is not render-readiness ROOT CAUSE of the composition route's eternal-MISS on cold graphs (100% on GH runners, knife-edge locally), found by per-phase instrumentation: a fully REPLAYED (prerendered) route's capture Flight render is pure replay — all bytes emit in ~1-3ms and go quiet — so quiesce fires almost instantly and the abort follows after 16 near-zero-cost macrotask hops. But fizz cannot emit even <html> until the payload root settles, which waits on every referenced client-module LOAD: real module-runner I/O in dev (2-6ms warm, 137-184ms cold, worse on runners). The abort landed first -> zero-byte prelude -> NULL, every attempt (dev's per-render $$cache= reference tag defeats module-load memoization, so retries re-pay the I/O). Ordinary ppr routes never hit it: live handler execution keeps Flight noisy long enough. Fix: captureShellHTML awaits payload settlement (new SsrRootOptions.onPayloadSettled — fires when createFromReadableStream's root settles; masked-loader holes postpone BELOW the root and do not block it) OR the prerender itself settling (early success/hard rejection) OR the existing maxWaitMs deadline, between quiesce and the post-quiesce hops. A hung payload degrades at the deadline exactly as before. Validated: truly cold boots (rm -rf .vite) land the FIRST capture attempt (previously 4-10 consecutive NULLs); router unit 5031, ssr 54/54 (one test now passes a short maxWaitMs — its mock prerender settles only on abort, making the deadline the intended path); shell suites 33+33 dev+production; cloudflare ppr battlefield 27/27 under CI=true with zero flakes. All temporary CI/vite/warmup instrumentation removed.
This was referenced Jul 6, 2026
Merged
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.
Problem statement
Two consumer-observable problems, found by the same coverage work:
Prerender()handler with thepprshell option — "the shell exists before the first visitor, loaders stream live" — had zero test coverage in either app, so nothing pinned that the two caching axes compose.pprneeded): in an app that declares a rootpath("/*")catch-all before a layout-nestedPrerenderroute,pnpm buildloggedOKfor every enumerated URL but baked the catch-all page's content undercatchAll/<paramHash>manifest keys. At runtime the (correct) trie matched the real route, found no manifest entry, and — with the handler evicted to a stub — served a hard 404DataNotFoundErrorfor every prerendered URL. Without eviction the failure is quieter and worse: wrong content served as a healthy 200.Root cause
Build-time prerender collection resolves each enumerated URL through
matchForPrerender→findMatch(pathname)in the RSC runner realm.findMatchis trie-first with a regex fallback, and the fallback is insertion-order-sensitive — the design note inrsc/manifest-init.tssays it plainly: "The trie is essential for correct wildcard priority — without it, the regex fallback matches catch-all patterns before specific routes."Dev installs the tries into the RSC realm on every discovery/HMR pass (
propagateDiscoveryState). But that lives insideconfigureServer, which early-returns in build mode — so collection was the onefindMatchconsumer in the system running trieless./ppr-shell/prerendered/alpha→ regex fallback →/*wins (declared first, same prefix group sincelayout()doesn't change the URL prefix) → artifact keyedcatchAll/<hash>containing the wildcard page.Why nothing caught it before:
include()-mounted prerender routes (articles) form their own static-prefix entry that is spliced ahead of the root entry, and the router's test-app has no rootpath("/*")at all — the shadowing shape simply wasn't in any fixture.The fix
src/vite/discovery/discover-routers.ts: after the per-router tries are built and committed to discovery state — and beforeexpandPrerenderRoutes— install them into the RSC realm through the same setters dev uses (setRouteTrie+ the per-routersetRouterManifest/setRouterTrie/setRouterPrecomputedEntriesloop, mirroringpropagateDiscoveryState). Build-collection, dev, and runtime now share the identical trie-ranked matcher.markRouterTrieAuthoritativeis deliberately NOT called, so a genuine trie gap still falls back to regex, exactly as in dev.This is bundle-safe: the setters run on the build's temp RSC server at discovery time (the same call dev makes), not in the emitted eager manifest chunk.
The composition, now pinned end to end
Semantics pinned by the suites (identical fixture shape in both apps):
loader()+loading()on the parallel)loading()insteadstate.cacheHitbefore the implicit-scope branch, so no redundant doc record is writtenTest plan
404,DataNotFoundError, manifest carrying two corruptedcatchAll/<hash>artifacts withparams: {"*": "ppr-shell/prerendered/…"}); with the fix the manifest bakespprShellPrerendered/<hash>keys, the boguscatchAllartifacts disappear, and the suite is green.prerender-ppr.test.ts(test-app, 4 tests: MISS→HIT, prelude/hole composition, loader liveness across HITs, per-param shells) — dev AND(production). Cloudflare-basic: composition test insidedescribePprShell— dev AND(production), full suite 288/288 on workerd/KV locally. THE DECISIVE ROOT CAUSE (found via per-phase capture instrumentation, validated red-to-green on truly cold boots): Flight byte-quiet is not fizz render-readiness. A fully REPLAYED (prerendered) route's capture Flight render is pure replay — all bytes emit in ~1-3ms and go quiet — so the quiesce fired and the abort followed after 16 near-zero-cost macrotask hops, while fizz could not emit even<html>until the payload root settled (every referenced client-module load = real module-runner I/O in dev: 2-6ms warm, 137-184ms cold, worse on GH runners). The abort landed first → zero-byte prelude → NULL on every attempt (dev's per-render$$cache=reference tag defeats module-load memoization, so retries re-pay the I/O). Ordinary ppr routes never hit it — live handler execution keeps Flight noisy long enough. Fixed incaptureShellHTML: the abort now additionally awaits payload settlement (SsrRootOptions.onPayloadSettled) or the prerender itself settling, bounded by the existingmaxWaitMsdeadline. Cold boots (rm -rf .vite) now land the FIRST capture attempt.Along the way the same investigation surfaced and fixed three adjacent hardening gaps, all keepers: per-isolate capture serialization (concurrent captures starved each other's quiet windows — an earlier revision CI-skipped the cloudflare DEV instance over this before the true root cause emerged — concurrent captures starve each other's task-quantized quiet windows (one grinding capture, here the /__rsc_prerender round-trip whose per-request module re-import pegs a slow runner, keeps a sibling byte-silent past its abort budget: rotating eternal-MISS victims across four CI runs). Fixed in this PR by serializing capture execution per isolate (src/rsc/capture-queue.ts, unit-tested: concurrency=1, order, rejection isolation); the quarantine and warmup exclusions are fully lifted — every test runs everywhere again. Validated post-fix: router shell suites 32+32 dev+production, cloudflare ppr battlefield 26/26 and the full suite under CI=true with zero capture cross-talk.
hmrproject suites 14/14,route-types4/4 (the CLAUDE.md-namedroute-types-hmrspec does not exist; these are the current equivalents).pnpm run typecheck,pnpm run test:unit:all(0 failures),pnpm run lint,pnpm run format;*.gen.tscommitted for both apps.Notes
ppr— anygetParams-bearingPrerenderroute declared inline under layouts after a root catch-all baked wrong-content artifacts.ppronly made the symptom louder (404 instead of silently serving the catch-all page as a 200).