Skip to content

perf(vite): dev prerender result cache keyed by router-instance identity, CF handler-edit freshness fix - #662

Merged
ivogt merged 2 commits into
mainfrom
perf/654-dev-prerender-cache
Jul 4, 2026
Merged

ivogt merged 2 commits into
mainfrom
perf/654-dev-prerender-cache

Conversation

@ivogt

@ivogt ivogt commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Closes #654.

Problem

In dev, every request to a prerendered route re-did all the work: the /__rsc_prerender endpoint re-imported the entry chain and re-rendered the full segment chain (handler + layouts + parallels + all intercept variants), re-running getParams()/handler side effects (fs reads, DB via buildEnv) per navigation — despite the result being deterministic between edits. On the Cloudflare path there was also a correctness bug hiding under the perf problem: the temp Node runner's registry was only refreshed on route-file edits (files matching the urls(/createRouter( sniff), so a handler-only edit served stale prerender content indefinitely.

Before vs. after (consumer's seat)

# before — every dev request to a Prerender route
GET /__rsc_prerender?pathname=/releases   → full render + getParams, every time
# on Cloudflare: edit releases-handler.tsx → content stays STALE until a urls.tsx edit

# after
GET /__rsc_prerender?pathname=/releases   → x-rango-prerender-cache: MISS (renders once)
GET /__rsc_prerender?pathname=/releases   → x-rango-prerender-cache: HIT  (Map lookup)
# edit releases-handler.tsx → next request re-renders fresh (both presets), then HITs again

Consumers configure nothing. Dev now matches production semantics more closely: prerender output is frozen between edits (production freezes it at build), instead of re-rendering per request.

How it works (the invariant)

The endpoint re-imports the user's entry through a module runner on every request — the Node preset on the main RSC environment (pre-existing, commit 11434658), the Cloudflare preset now on the shared temp Node server (new). When any module in the entry → router → urls → handler chain was HMR-invalidated, that import re-runs createRouter() and RouterRegistry.set replaces the router instance with a new object; when nothing changed, the import is a module-cache hit and the instance is the same object. Router-instance identity therefore IS the HMR generation, and the cache is a WeakMap<routerInstance, Map<key, body>> (src/vite/discovery/dev-prerender-cache.ts) — no watcher bookkeeping, no invalidation calls, stale generations garbage-collect when the registry drops the old instance. One render warms both the main and intercept=1 variant keys (matchForPrerender computes intercept segments unconditionally). Cache key = pathname × intercept × passthrough × routeName. Errors, passthroughs, and non-matches are never cached; multi-router fall-through checks the cache per router in registry loop order, preserving uncached semantics exactly.

Semantics table

Case Before After
Repeat request, no edits (both presets) full render + getParams HIT — Map lookup
Edit any chain module, next request fresh render (Node) / stale (Cloudflare bug) fresh render + re-memoize (both)
Handler-only edit, Cloudflare dev stale until a route-file edit fresh on next request
Data-only change (DB/fs), no module edit re-rendered per request frozen until next edit — matches production (frozen at build)
Intercept variant after main render second full render HIT (warmed by the main render)
Render error / passthrough / no match retried per request unchanged — never cached
Production serving manifest store, no endpoint unchanged (pinned by new e2e)

Code changes

  • src/vite/discovery/dev-prerender-cache.ts (new, ~120 lines): identity-keyed cache + key derivation + variant payload derivation; FIFO cap 500/router. Chosen as a pure module in vite/discovery/ (the plugin-discovery helpers' home per feature-file-map) so the semantics are unit-testable without a Vite server.
  • src/vite/router-discovery.ts: endpoint consults the cache per router before matchForPrerender, warms both variant keys on a render, sets x-rango-prerender-cache; the Cloudflare temp path gains a per-request importEntryAndRegistry (mirrors the Node path's freshness mechanism; warm imports are module-cache hits).
  • Docs: prerender-api-design.md dev-mode section rewritten (also fixes a stale claim — the Node preset DOES set __PRERENDER_DEV_URL and round-trips the endpoint; the doc said otherwise), feature-map + feature-file-map rows.

Measured (local, M-series, trivial fixture handlers)

Path MISS HIT
Node preset (test-app, /docs/getting-started) 25ms 21–22ms
Cloudflare preset (cloudflare-basic, /articles/page/1) 31ms 21–23ms

Absolute deltas are small on fixture apps because their handlers are trivial; the structural win is that HITs skip matchForPrerender + getParams entirely, so it scales with real handler cost (fs/DB work — the issue's motivation). Cloudflare handler-edit freshness verified live: edit releases-handler.tsx → fresh content in ~2s → next request HIT (this exact loop served stale content on the old code).

Test plan

  • Unit (12 new, src/vite/discovery/__tests__/dev-prerender-cache.test.ts): identity semantics (new instance = new generation), variant-payload parity with the endpoint's historical format (intercept merge, no-intercept fallback, missing-handles fallback), FIFO cap behavior, key dimensions.
  • E2e dev + production, both apps (Hard rule 3):
    • test-app: prerender-dev-cache.test.ts — MISS→HIT byte-identical body; intercept-variant warmed by one render; (production) counterpart pins no header leak + endpoint absent in prod. Red-before-green: run against the pre-change dist fails at the header assertion (Expected: "MISS", Received: undefined); green after rebuild — 9/9 across --repeat-each=3 with retries disabled.
    • cloudflare-basic: workerd-path MISS→HIT in prerender-dev.test.ts; production header-absence case in prerender.test.ts. 3/3 dev, 1/1 production.
  • HMR contract (local-only project, CI-skipped like all watcher suites): existing 3 prerender-hmr tests pass unchanged — including the imported-component regression case the per-request re-import exists for — plus a new endpoint-level case: warm HIT → edit → MISS with fresh content → converges back to HIT. 4/4.
  • route-types-hmr run locally per the Vite-plugin-change mandate (17 passed, 1 flaky-recovered — watcher timing, the reason the suite is CI-skipped).
  • Gate: pnpm run typecheck, pnpm run test:unit:all (router 4815 + demo 26 + mini 23 + cloudflare-basic 46 + RSC suites), pnpm run lint, pnpm run format, check:e2e-bucketing, check:doc-links — all green.

Notes

  • The post-edit convergence test polls to HIT rather than asserting the literal next request: an edited route file triggers the watcher's debounced rediscovery, which legitimately churns the identity once more (~100ms later) before stabilizing. Comment in the test explains this.
  • Deliberate behavior change, endorsed by the issue: data-only changes (a DB row added, fs content changed) no longer appear per-request in dev prerender output — they appear on the next edit, matching production's freeze-at-build semantics. getParams enumeration follows the same rule.
  • Deferred from the issue's option list: a client-side cache in createDevPrerenderStore (item 4) — measured HITs are ~22ms round-trip, so a store-side cache would save little; revisit only with evidence.
  • PPR: build-time shell capture (prerender × ppr) #649 (build-time shell capture) will reuse matchForPrerender at build; this memoized endpoint also speeds up any future dev-mode shell seeding, per the issue's note.

@ivogt
ivogt marked this pull request as ready for review July 4, 2026 12:28
…entity (#654)

The /__rsc_prerender endpoint re-rendered the full segment chain (and re-ran
getParams) on every request. Results are now cached per router instance:
the per-request entry re-import re-runs createRouter() exactly when the HMR
chain was invalidated, so instance identity IS the edit generation (WeakMap;
stale generations GC). One render warms both the main and intercept variant
keys; x-rango-prerender-cache: HIT|MISS reports the outcome.

The Cloudflare temp-runner path now re-imports the entry per request too,
fixing handler-only-edit staleness (files without urls()/createRouter() never
triggered a temp-registry refresh, so edits served stale prerender content
until a route-file edit).
@ivogt
ivogt force-pushed the perf/654-dev-prerender-cache branch from d8e9c0d to ecd8def Compare July 4, 2026 12:34
@ivogt

ivogt commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

Hardened the HIT assertions against concurrent-invalidation flakes (review feedback on test staging):

Interference surface mapped first. Every file-writing suite in both apps lives in a dedicated hmr* project that is testIgnore'd from dev and ordered after it (dependencies: ["dev"] / ["dev", "production"]; CI skips them entirely), and CF's dev project sits behind dev-warmup. So no source writes occur during the dev projects by construction. Residual vectors: a dep-optimizer re-optimization landing between the paired requests, an hmr project run concurrently in another terminal, and future dev-project tests that write files.

Assertions now immune by construction. The MISS assertions were already churn-proof (first request for a key has no cached body under ANY identity). The HIT assertions are now paired polls — each attempt issues the request pair and requires the second response to be a HIT byte-identical to the first; a legitimate identity churn between the pair retries, while a broken cache never returns HIT and times out. The variant-warming attempt re-warms via the main variant each iteration; its precise both-keys-from-one-render semantics stay pinned by the unit tests.

Adversarial verification: ran the dev suite while a loop toggled src/urls/prerender.tsx (a urls() route file — triggers full rediscovery churn) every 400ms for the run's duration: 2/2 and 4/4 passed under active churn, churn file restored clean. Clean runs: 9/9 (--repeat-each=3 --retries=0) on test-app, 3/3 twice sequentially on cloudflare-basic.

…r-cache

# Conflicts:
#	packages/rangojs-router/docs/internal/feature-file-map.md
@pkg-pr-new

pkg-pr-new Bot commented Jul 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: e7b1cd6

@ivogt
ivogt merged commit eb272cd into main Jul 4, 2026
30 checks passed
@ivogt
ivogt deleted the perf/654-dev-prerender-cache branch July 4, 2026 12:49
ivogt added a commit that referenced this pull request Jul 4, 2026
…, dev prerender memoization (#662); map loader-snapshot.ts
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.

Dev-time prerender: make it partial — HMR-generation-keyed caching instead of full re-import + full re-render per request

1 participant