Skip to content

fix(vite): prerender collection ran trieless — catch-all shadowed nested routes; Prerender + ppr composition coverage - #697

Merged
ivogt merged 11 commits into
mainfrom
feat/prerender-ppr-composition
Jul 6, 2026
Merged

ivogt merged 11 commits into
mainfrom
feat/prerender-ppr-composition

Conversation

@ivogt

@ivogt ivogt commented Jul 5, 2026 •

Copy link
Copy Markdown
Collaborator

Problem statement

Two consumer-observable problems, found by the same coverage work:

  1. Prerender + ppr composition was unverified. A route combining a build-time Prerender() handler with the ppr shell 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.
  2. A real collection bug (pre-existing, no ppr needed): in an app that declares a root path("/*") catch-all before a layout-nested Prerender route, pnpm build logged OK for every enumerated URL but baked the catch-all page's content under catchAll/<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 404 DataNotFoundError for 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. findMatch is trie-first with a regex fallback, and the fallback is insertion-order-sensitive — the design note in rsc/manifest-init.ts says 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 inside configureServer, which early-returns in build mode — so collection was the one findMatch consumer in the system running trieless. /ppr-shell/prerendered/alpha → regex fallback → /* wins (declared first, same prefix group since layout() doesn't change the URL prefix) → artifact keyed catchAll/<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 root path("/*") 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 before expandPrerenderRoutes — install them into the RSC realm through the same setters dev uses (setRouteTrie + the per-router setRouterManifest/setRouterTrie/setRouterPrecomputedEntries loop, mirroring propagateDiscoveryState). Build-collection, dev, and runtime now share the identical trie-ranked matcher. markRouterTrieAuthoritative is deliberately NOT called, so a genuine trie gap still falls back to regex, exactly as in dev.

if (serverMod.setRouteTrie && newMergedRouteTrie) {
  serverMod.setRouteTrie(newMergedRouteTrie);
}
const perRouterSetters: Array<[Map<string, unknown>, string]> = [
  [newPerRouterManifestDataMap, "setRouterManifest"],
  [newPerRouterTrieMap, "setRouterTrie"],
  [newPerRouterPrecomputedMap, "setRouterPrecomputedEntries"],
];
for (const [map, fn] of perRouterSetters) { /* … */ }

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

path(
  "/pp/:slug",
  PrerenderPprArticle,                                   // Prerender(getParams, handler)
  { name: "pp.article", ppr: { ttl: 300, swr: 120 } },   // + the shell option
  () => [
    parallel({
      "@ppseq": {
        handler: PrerenderPprSeqSlot,                    // slot-owned live data
        use: () => [loader(PrerenderPprSeqLoader), loading(<Fallback />)],
      },
    }),
  ],
),

Semantics pinned by the suites (identical fixture shape in both apps):

Piece Behavior
Build-time segments (article content + slot handler element) frozen prelude, replayed per HIT
Slot-owned loader (loader()+loading() on the parallel) live hole; seq advances on every HIT
Route-level loading() instead would make the WHOLE route subtree the hole — the fixture comments document the slot-hole playbook as the correct shape
Prerender handler at serve never executes (evicted in production; store-replayed in dev)
Per-param shells alpha and beta HIT independently with their own content
Fast-path interplay (#695) prerender-store hit sets state.cacheHit before the implicit-scope branch, so no redundant doc record is written

Test plan

  • Red-before-green for the fix: the cloudflare composition test failed on unfixed code with the exact consumer symptom (404, DataNotFoundError, manifest carrying two corrupted catchAll/<hash> artifacts with params: {"*": "ppr-shell/prerendered/…"}); with the fix the manifest bakes pprShellPrerendered/<hash> keys, the bogus catchAll artifacts disappear, and the suite is green.
  • New e2e: 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 inside describePprShell — 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 in captureShellHTML: the abort now additionally awaits payload settlement (SsrRootOptions.onPayloadSettled) or the prerender itself settling, bounded by the existing maxWaitMs deadline. 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.

  • Existing prerender nets green: test-app prerender suite 60/60 (dev+production); cloudflare prerender/articles/build-output suites inside the 288.
  • Vite-plugin safety nets: hmr project suites 14/14, route-types 4/4 (the CLAUDE.md-named route-types-hmr spec does not exist; these are the current equivalents).
  • Gate: pnpm run typecheck, pnpm run test:unit:all (0 failures), pnpm run lint, pnpm run format; *.gen.ts committed for both apps.

Notes

  • The bug predates the shell fast path and does not require ppr — any getParams-bearing Prerender route declared inline under layouts after a root catch-all baked wrong-content artifacts. ppr only made the symptom louder (404 instead of silently serving the catch-all page as a 200).
  • With the fix, collection also stops producing the corrupted catch-all artifacts, shrinking the manifest to only real entries.
  • This composition is the on-ramp to "producer B" (build-time shell entries — Prerender emitting the PPR shell itself); that remains a named follow-up.

…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).
@pkg-pr-new

pkg-pr-new Bot commented Jul 5, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 4a57aa1

ivogt added 9 commits July 6, 2026 01:41
…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.
…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.
…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.
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