Skip to content

fix(dev): refresh Pages hydration after render errors - #2823

Draft
james-elicx wants to merge 4 commits into
mainfrom
codex/fix-pages-dev-html-proxy-invalidation
Draft

fix(dev): refresh Pages hydration after render errors#2823
james-elicx wants to merge 4 commits into
mainfrom
codex/fix-pages-dev-html-proxy-invalidation

Conversation

@james-elicx

@james-elicx james-elicx commented Aug 6, 2026

Copy link
Copy Markdown
Member

Summary

  • serialize Pages HTML transforms per clean document URL, then let Vite run its supported pre hooks and parser before capture
  • capture the exact Vite-generated inline JavaScript proxy sources inside a normal HTML hook and rewrite response tags to immutable content-addressed vinext modules
  • preserve supported user hook context, request CSP nonces, base paths, encoded directories, relative imports, source maps, module side effects, HMR relationships, root/trailing/query paths, and filesystem-backed proxy IDs
  • dedupe identical executable modules so changes limited to the SSR body or __NEXT_DATA__ reuse the same immutable URLs

Regression coverage

  • real Pages fixture with an API-controlled barrier for overlapping same-URL success and error responses
  • request-dependent user inline modules with a real relative TypeScript import
  • stateful order: pre HTML hook output captured after Vite processing
  • repeated responses with different body and __NEXT_DATA__ values reuse proxy URLs
  • request-derived middleware CSP nonce remains on immutable hydration scripts
  • focused Vite server coverage for hook paths, root and trailing paths with queries, filesystem path collisions, static and dynamic CSP nonces, encoded public bases and directories, duplicate proxy indices, and lock recovery
  • pre-fix source fails the overlap and mutable-URL assertions

Next.js recovery reference: https://github.com/vercel/next.js/blob/canary/test/development/acceptance/error-recovery.test.ts

Vite implementation reference: https://github.com/vitejs/vite/blob/main/packages/vite/src/node/server/middlewares/indexHtml.ts

Validation

  • vp test run tests/pages-html-proxy.test.ts — 10 passed
  • affected real Pages fixture cases — 10 passed
  • vp test run tests/pages-router.test.ts — 382 passed
  • PLAYWRIGHT_PROJECT=pages-router-basepath-dev vp run test:e2e — 3 passed
  • touched check and vp run vinext#build passed
  • two independent exact-diff reviews — NO FINDINGS

@pkg-pr-new

pkg-pr-new Bot commented Aug 6, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2823
npm i https://pkg.pr.new/create-vinext-app@2823
npm i https://pkg.pr.new/@vinext/types@2823
npm i https://pkg.pr.new/vinext@2823

commit: 3814400

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 3814400 against base 3c5cea4 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 134.6 KB 134.7 KB ⚫ +0.0%
Client entry size (gzip) vinext 122.1 KB 122.2 KB ⚫ +0.0%
Dev server cold start vinext 2.86 s 2.85 s ⚫ -0.3%
Production build time vinext 3.03 s 3.02 s ⚫ -0.1%
RSC entry closure size (gzip) vinext 113.9 KB 113.9 KB ⚫ +0.0%
Server bundle size (gzip) vinext 191.4 KB 191.4 KB ⚫ +0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@james-elicx
james-elicx force-pushed the codex/fix-pages-dev-html-proxy-invalidation branch 3 times, most recently from 953e309 to 7cbe49a Compare August 6, 2026 11:00
@james-elicx
james-elicx force-pushed the codex/fix-pages-dev-html-proxy-invalidation branch from 7cbe49a to 31bb9b4 Compare August 6, 2026 11:44
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(dev): refresh Pages hydration after render errors

I reviewed the diff against the actual Vite (@voidzero-dev/vite-plus-core) HTML transform pipeline to confirm the mechanism. The fix is correct and addresses a real bug, but I found one substantive issue plus a couple of minor notes.

Root cause confirmed (fix is sound)

Vite's dev HTML proxy cache is htmlProxyMap: WeakMap<config, Map<filePath, result[]>>, and addToHTMLProxyCache overwrites map.get(filePath)[index] on every transform of the same document URL (node.js addToHTMLProxyCache). So a slow error render and a fast success render to the same clean URL clobber each other's inline-proxy contents while the stable ?html-proxy&index=N.js URL is reused — producing stale/mismatched hydration. Capturing the exact proxy source, content-addressing it into an immutable __vinext_html_proxy_content_<hash>_<index>.js URL, and serializing same-URL transforms with a per-document lock is the right fix.

Hook ordering also checks out: in dev, Vite runs preHooks → htmlEnvHook → devHtmlHook (generates proxy tags) → normalHooks → postHooks → injectNonceAttributeTagHook. The capture hook is registered as a plain transformIndexHtml function, which resolveHtmlTransforms places in normalHooks — i.e. after devHtmlHook — so it sees the generated proxy tags. Nonce handling is also correct: applyProxyScriptNonce runs before Vite's injectNonceAttributeTagHook, which only injects when no nonce attribute already exists, so there's no double nonce.

Issue 1 (significant): unbounded memory growth in the dev process

createPagesHtmlProxyCapturePlugin keeps two module-scoped maps:

const modules = new Map<string, CapturedProxyModule>();
const publicToResolvedId = new Map<string, string>();

Entries are only ever added (pages-html-proxy.ts:176-178) — there is no eviction, size cap, watchChange/hotUpdate invalidation, or TTL. Because the key is a content hash of {code, document, index, map}, every render whose captured module source differs mints a new permanent entry. That is unbounded for exactly the scenarios this PR targets:

  • alternating error/success renders of the same route (each captures a distinct module)
  • stateful order: pre hooks (the added test captures stateful pre HTML hook output in immutable modules proves each render yields a new URL/entry)
  • any request-dependent inline module (nonce, per-request data baked into the inline script)

Vite's own htmlProxyMap does not have this problem: it is keyed by filePath + index and overwrites in place, so it's bounded by the number of distinct document URLs. This PR replaces a bounded-by-URL cache with a grows-forever-by-content cache. A long-lived dev server hitting a page with a per-request nonce or changing data on every reload will leak steadily.

Suggested mitigations (any one):

  • Bound retention per (documentUrl, index) — e.g. keep only the last N captured hashes per document/index and drop older ones (matches the "immutable per render, but old renders are unreachable" reality; a stale tab could 404 on an evicted proxy, which is acceptable in dev and already possible after an HMR reload).
  • Evict a document's captured entries on hotUpdate/watchChange for files in that document graph.
  • At minimum, cap total map size (LRU) so the leak can't grow without limit.

Please also add a regression test asserting the cache is bounded (e.g. many distinct-content renders of one URL don't retain every entry).

Issue 2 (minor): misleading enforce: "pre"

The plugin sets enforce: "pre", but resolveHtmlTransforms ignores plugin-level enforce for transformIndexHtml entirely — it only branches on the hook's own order. The capture hook works because it's a plain function (→ normalHooks, after devHtmlHook), not because of enforce. enforce: "pre" only affects this plugin's resolveId/load ordering. That's probably fine (and may be intended for the content-module resolve/load), but the current setup makes the critical ordering invariant implicit. Consider a short comment documenting that the capture hook MUST stay a plain (normal-order) transformIndexHtml so it runs after devHtmlHook, since switching it to order: "pre" would silently break capture.

Issue 3 (nit): test fixture global state persists across the process

tests/fixtures/pages-basic/html-proxy-race-state.ts keeps a module-level races Map that never clears entries. Within the fixture server's lifetime that's harmless (each test uses a unique Date.now()-based race id), but it's another never-freed map living in the dev process for the whole suite. Not blocking; noting for symmetry with Issue 1.

Verification performed

  • vp test run tests/pages-html-proxy.test.ts — 10 passed
  • vp check packages/vinext/src/server/pages-html-proxy.ts — format/lint/types clean
  • Traced Vite dev HTML transform ordering, htmlProxyMap overwrite semantics, and injectNonceAttributeTagHook guard to confirm the fix's correctness and the nonce/ordering claims.

Overall: correct, well-tested fix for a real hydration-staleness bug. The memory-retention behavior (Issue 1) is the one thing I'd want addressed before merge, since it's a new unbounded leak introduced specifically on the dynamic-content paths this PR exercises.

@ask-bonk

ask-bonk Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2823. Summary of findings:

Verdict: Correct fix for a real bug, with one issue I'd want addressed before merge

I traced the actual Vite (vite-plus-core) HTML transform pipeline to verify the mechanism rather than take the PR description at face value.

Root cause confirmed — Vite's htmlProxyMap is keyed by filePath + index and addToHTMLProxyCache overwrites in place, so a slow error render and fast success render to the same URL clobber each other's inline-proxy content while reusing the stable ?html-proxy&index=N.js URL. Capturing the exact source, content-addressing to an immutable URL, and a per-document lock is the right fix. Hook ordering (plain transformIndexHtmlnormalHooks, running after devHtmlHook) and nonce handling both check out.

Issue 1 (significant): The plugin's modules and publicToResolvedId maps grow unboundedly — no eviction, no HMR invalidation, no cap. Keyed by content hash, every distinct render on exactly the dynamic paths this PR targets (error/success alternation, stateful pre-hooks, per-request nonces/data) mints a permanent entry. This is a new leak — Vite's own map is bounded by URL count. Suggested LRU/per-document-index cap or hotUpdate eviction, plus a bounded-cache regression test.

Issue 2 (minor): enforce: "pre" is misleading — it's ignored for transformIndexHtml ordering; the hook works only because it's a plain (normal-order) function. Worth a comment documenting the invariant.

Issue 3 (nit): Test fixture html-proxy-race-state.ts also keeps a never-cleared global Map.

Verified locally: new test file (10 passed) and vp check on the new source (clean).

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(dev): refresh Pages hydration after render errors

I re-traced the fix against the actual @voidzero-dev/vite-plus-core@0.2.6 dev HTML pipeline and module graph implementation (not the PR description), and checked that the two prior-review findings that were actionable have been resolved in commit 05ee82b.

Verdict: approve

The root-cause fix is correct and the follow-up commit closes the one blocking issue from the previous round. New tests pass (12) and vp check on the new source is clean.

Root cause + mechanism — confirmed sound

Vite's dev HTML proxy cache (EnvironmentModuleGraph, chunks/node.js:39642) keys inline proxies by filePath + index and addToHTMLProxyCache overwrites in place, so a slow error render and a fast success render to the same clean URL clobber each other's inline-proxy source while reusing the stable ?html-proxy&index=N.js URL. Capturing the exact loaded source, content-addressing it to an immutable __vinext_html_proxy_content_<hash>_<index>.js URL, and serializing same-clean-URL transforms with a per-document lock is the right fix.

  • Hook ordering — the capture hook is a plain (normal-order) transformIndexHtml, so resolveHtmlTransforms places it in normalHooks, after devHtmlHook generates the proxy tags. The added comment at pages-html-proxy.ts:237 now documents that this MUST stay a normal-order hook (prior Issue 2). Good.
  • NonceapplyProxyScriptNonce strips any existing nonce then re-adds the request nonce before Vite's injectNonceAttributeTagHook (which only injects when absent), so no double nonce. The pages-router test now asserts every immutable proxy tag carries nonce="pages-response".

Prior Issue 1 (unbounded growth) — resolved

05ee82b replaces the grows-forever maps with a per-(documentUrl, index) LRU capped at MAX_RETAINED_PROXY_VERSIONS = 8 (retainProxyModule), plus evictViteModule which tears the evicted module out of the client module graph. I verified the internal map names against chunks/node.js:39644-39651urlToModuleMap, idToModuleMap, etagToModuleMap, fileToModulesMap, and _unresolvedUrlToModuleMap all match. _hasResolveFailedErrorModules is not cleared explicitly, but invalidateModule (called first) deletes the module from it (chunks/node.js:39713), so that reference is released too. The new bounds retained request-dependent proxy modules test drives 300 distinct renders and asserts ≤8 entries remain in both urlToModuleMap and _unresolvedUrlToModuleMap, and that the oldest URL 404s while the newest resolves.

The cap is per-index, so a 12-script document retains all 12 current indices (covered by retains every current proxy index in a document) while still bounding versions-per-index. Correct design.

Prior Issue 3 (fixture global) — resolved

html-proxy-race-state.ts now clears each race entry via queueMicrotask in releaseHtmlProxyRace (:71-73), so the fixture no longer accumulates state for the suite lifetime.

Notes (non-blocking)

  1. Eviction races with concurrent proxy fetches. transformPagesHtml serializes transforms per clean URL, but proxy-module transformRequest/load calls are not under that lock. A capture that evicts an old version can race a still-in-flight browser request for that exact evicted URL, yielding a dev-only 404. This is acceptable (same outcome as an HMR reload invalidating a stale tab) and matches the immutable-URL contract, but worth a one-line comment near evictViteModule noting the intended dev-only staleness window.
  2. _unresolvedUrlToModuleMap transiently holds a Promise. For an in-flight resolution the map value is a Promise (chunks/node.js:39803), so candidate === module won't match it and that entry won't be swept. In practice the resolved node overwrites it (:39800) and the next eviction cleans it, so it doesn't leak beyond one cycle — fine, just flagging that the sweep relies on that overwrite behavior.

Parity

This is a Pages-Router-dev-only change. transformIndexHtml exists only in the Vite dev server; App Router dev goes through the RSC plugin, and prod/worker entries don't use the html-proxy mechanism. Both server.transformIndexHtml call sites in dev-server.ts were converted to transformPagesHtml; no other call sites exist. No parity gap.

Verification performed

  • vitest run tests/pages-html-proxy.test.ts — 12 passed
  • vp check packages/vinext/src/server/pages-html-proxy.ts — format/lint/types clean
  • Confirmed Vite EnvironmentModuleGraph internal map names and _unresolvedUrlToModuleMap Promise-then-node overwrite semantics against the resolved vite-plus-core@0.2.6 source.

@ask-bonk

ask-bonk Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2823.

Verdict: approve

I re-traced the fix against the actual @voidzero-dev/vite-plus-core@0.2.6 dev HTML pipeline and module graph, and confirmed the two actionable findings from the previous review round are resolved in the follow-up commit 05ee82b.

Root cause + mechanism — confirmed sound. Vite's dev html-proxy cache keys inline proxies by filePath + index and overwrites in place, so overlapping error/success renders to the same clean URL clobber each other's inline source while reusing the stable ?html-proxy&index=N.js URL. Content-addressing to immutable __vinext_html_proxy_content_<hash>_<index>.js URLs + a per-document lock is the right fix. Hook ordering (normal-order transformIndexHtml runs after devHtmlHook) and nonce handling both check out.

Prior Issue 1 (unbounded growth) — resolved. Replaced with a per-(documentUrl, index) LRU capped at 8, plus evictViteModule tearing evicted modules out of the client module graph. I verified all the internal map names against the resolved Vite source and confirmed invalidateModule handles the _hasResolveFailedErrorModules cleanup. New bounds retained... test drives 300 renders and asserts ≤8 retained.

Prior Issues 2 & 3 — resolved via the documenting comment on the capture hook and queueMicrotask cleanup of the fixture race map.

Two non-blocking notes: the eviction can race a still-in-flight proxy fetch (dev-only 404, acceptable, worth a comment); and the _unresolvedUrlToModuleMap sweep relies on Vite's Promise→node overwrite (doesn't leak beyond one cycle).

Verified: tests/pages-html-proxy.test.ts (12 passed) and vp check on the new source (clean).

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review: fix(dev): refresh Pages hydration after render errors (re-review at bc4c141)

I re-traced the fix and focused on the newest commit bc4c141 ("finish pending HTML proxy eviction") on top of the previously-approved 05ee82b. The root-cause fix remains correct — capturing Vite's exact inline proxy source, content-addressing it to an immutable URL, and serializing same-clean-URL transforms with a per-document lock. The bc4c141 change also correctly closes the prior non-blocking note about eviction racing an in-flight resolution: evictViteModule now sweeps _unresolvedUrlToModuleMap and attaches .then(...) to finish teardown once a pending EnvironmentModuleNode resolves, with a targeted regression test. Good.

But re-reviewing the retention model as a whole surfaced one issue the prior rounds missed.

Issue (moderate): retention is bounded per document, but unbounded across documents

The 05ee82b LRU bounds versions per (documentUrl, index) to MAX_RETAINED_PROXY_VERSIONS = 8. It does not bound the number of distinct documents. Every distinct clean document URL mints:

  • one permanent retainedByDocumentIndex Set entry per (documentUrl, index) — never removed,
  • up to 8 retained entries each in modules and publicToResolvedId,
  • the corresponding modules pinned in the client moduleGraph.

There is no hotUpdate/watchChange eviction and no cap on the document dimension, so a dev server that browses a dynamic route with many distinct paths (/blog/[slug], /item/[id], a large docs site) accumulates captured modules linearly in the number of distinct URLs visited — for the whole dev-server lifetime.

This is the same class of growth the prior Issue 1 was meant to close, just moved up one level. It is also a regression versus the baseline this PR replaces: Vite's own htmlProxyMap is keyed by filePath + index and overwrites in place, so it retains exactly one entry per (document, index). vinext now retains up to 8 × (documents × indices) plus a never-freed retainedByDocumentIndex map that grows one entry per distinct (documentUrl, index).

Verified locally with a focused repro against the real plugin: 500 distinct document URLs each rendering identical inline content retained 500 modules in client.moduleGraph.urlToModuleMap (vs. the bounded-per-URL behavior of Vite's native cache). The bounds retained... test only exercises the single-URL/many-versions axis, so this axis is untested.

Suggested mitigations (any one):

  • Add a hotUpdate/watchChange handler that evicts a document's captured entries (and drops its retainedByDocumentIndex keys) when a file in that document's graph changes — this is the natural invalidation boundary and matches "immutable per render, superseded on edit."
  • Additionally cap the total number of retained documents (LRU on retainedByDocumentIndex keys), evicting the least-recently-transformed document's modules, so the map can't grow without limit even without edits.
  • At minimum, add a regression test asserting the total retained-module count is bounded across many distinct document URLs (not just many versions of one URL), so this axis doesn't silently regress.

Things that check out

  • Cross-document isolation is correct. The content hash identity includes document: active.documentUrl (pages-html-proxy.ts:284), while contentModuleUrl only uses documentDirectory. I initially suspected two siblings in the same directory (/a, /b) with identical inline content would collide on resolvedId and let one document's LRU evict a module still referenced by the other — but the document field in the hash makes the resolvedIds distinct even for byte-identical inline code in the same directory. Confirmed with a repro. No cross-document eviction hazard.
  • Pending-resolution eviction (bc4c141) — the _unresolvedUrlToModuleMap sweep now handles the Promise case; the new test drives it with a gated _resolveId and asserts both urlToModuleMap and _unresolvedUrlToModuleMap are cleared after the pending node resolves.
  • Fixture global (prior Issue 3)releaseHtmlProxyRace now clears the race entry via queueMicrotask.
  • Hook ordering / nonce — unchanged and still correct (normal-order transformIndexHtml runs after devHtmlHook; applyProxyScriptNonce strips-then-adds before Vite's injectNonceAttributeTagHook).
  • Dev/prod parity — both Pages server.transformIndexHtml call sites in dev-server.ts are converted to transformPagesHtml; no other Pages call sites exist. transformIndexHtml is dev-only, so no prod/worker parity gap.

Verification performed

  • vitest run tests/pages-html-proxy.test.ts — 13 passed.
  • Repro (removed after): 500 distinct document URLs → 500 retained modules in client.moduleGraph.urlToModuleMap, demonstrating the unbounded-across-documents growth.
  • Repro (removed after): /a vs /b byte-identical inline content → distinct resolvedIds, confirming no cross-document eviction hazard.

Verdict

Correct fix for the hydration-staleness bug; the newest commit resolves the last non-blocking note. Not blocking, but I'd want the cross-document retention growth addressed (ideally an edit-driven eviction handler plus a document-count bound), since it's a new unbounded-in-practice leak on exactly the dynamic-route dev workflows this plugin now runs on every Pages render.

@ask-bonk

ask-bonk Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2823.

Summary

I re-reviewed the fix, focusing on the newest commit bc4c141 on top of the previously-approved 05ee82b.

Root cause fix — still correct. Capturing Vite's exact inline proxy source, content-addressing to an immutable URL, and per-document lock serialization is the right approach. The bc4c141 change correctly closes the prior non-blocking note about eviction racing an in-flight resolution (sweeps _unresolvedUrlToModuleMap and attaches .then to finish teardown once a pending node resolves, with a regression test).

One new finding (moderate, not blocking): The 05ee82b LRU bounds versions per (documentUrl, index) to 8, but does not bound the number of distinct documents. Every distinct clean document URL permanently mints a retainedByDocumentIndex Set entry plus up to 8 retained modules pinned in the client module graph, with no hotUpdate/watchChange eviction. Browsing a dynamic route with many paths (/blog/[slug], large docs sites) accumulates modules linearly in distinct URLs for the dev-server lifetime. I verified with a repro: 500 distinct document URLs → 500 retained modules. This is a regression vs. baseline Vite's htmlProxyMap (bounded to 1 entry per doc-index) and is untested (the bounds retained... test only covers the single-URL/many-versions axis).

Things that check out:

  • Cross-document isolation is correct — I suspected same-directory siblings with identical inline content could collide resolvedId and cross-evict, but the hash identity includes the full document URL, so resolvedIds stay distinct (confirmed with repro). No cross-document eviction hazard.
  • Pending-resolution eviction, fixture global cleanup (prior Issue 3), hook ordering, nonce handling, and dev/prod parity all verified.

Verdict: correct fix; the newest commit resolves the last non-blocking note. Recommend addressing the cross-document retention growth (edit-driven eviction + a document-count bound) before merge, but not strictly blocking.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

UnknownError: ProviderInitError

github run

@ask-bonk

ask-bonk Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(dev): refresh Pages hydration after render errors (re-review at 3814400)

I re-traced the fix and focused on the newest commit 3814400 ("bound retained HTML proxy documents") on top of the previously-reviewed bc4c141. This commit is a direct, correct response to the moderate finding I raised in the last round (retention bounded per document but unbounded across documents).

Verdict: approve

The root-cause fix remains correct, and the cross-document retention leak is now closed. vp check on the new source is clean and all tests pass (15 in pages-html-proxy.test.ts, 382 in pages-router.test.ts).

Prior finding (cross-document unbounded growth) — resolved

3814400 replaces retainedByDocumentIndex: Map<string, Set> with a nested retainedByDocument: Map<documentUrl, Map<index, Set<resolvedId>>> and adds a second LRU dimension:

  • Per-(documentUrl, index) version cap stays at MAX_RETAINED_PROXY_VERSIONS = 8 (unchanged).
  • New per-document cap MAX_RETAINED_PROXY_DOCUMENTS = 128, with LRU-refresh on touch (retainedByDocument.delete(documentUrl) + re-set at pages-html-proxy.ts:246-252). When the document count exceeds 128, the oldest document's every retained version is evicted through the shared evictRetainedProxyModule helper (:270-280).
  • hotUpdate handler (:351-356) evicts all retained proxies and clears retainedByDocument on any client-graph file change — over-invalidates, but that's safe in dev and matches "immutable per render, superseded on edit."

The eviction path was also refactored into evictRetainedProxyModule, which now drops the modules entry, the publicToResolvedId aliases, and the Vite module graph node in one place — so both LRU dimensions and hotUpdate share identical teardown. Good.

New regression tests cover both axes:

  • bounds retained modules across distinct document URLs drives 160 distinct /docs/N URLs and asserts ≤128 retained graph modules, oldest 404s, newest resolves.
  • evicts retained proxies when the source graph updates calls the hotUpdate handler and asserts the captured module is torn down.

Things I re-verified

  • hotUpdate signatureHotUpdateOptions includes server: ViteDevServer (vite-plus-core@0.2.6 index.d.ts:1198-1205), so hotUpdate({ server }) is valid. The handler is idempotent (every eviction null-checks), so per-environment / multi-fire invocation is safe.
  • Pending-resolution eviction (bc4c141)evictViteModule sweeps _unresolvedUrlToModuleMap, and the .then(onFulfilled, onRejected) has both handlers with void, so no unhandled-rejection risk; the rejection branch only deletes if the entry hasn't been overwritten.
  • Concurrency — different document URLs transform concurrently and share modules/retainedByDocument, but retainProxyModule has no await internally, so no torn state. Document-LRU eviction only removes other documents; worst case is an evict-then-re-add, which is harmless.
  • Cross-document isolation — the content hash identity still includes document: active.documentUrl (:315), so byte-identical inline content in sibling routes gets distinct resolvedIds; no cross-document eviction hazard.
  • Dev/prod parity — both Pages server.transformIndexHtml call sites in dev-server.ts (:600, :2155) are converted to transformPagesHtml; renderErrorPage now forwards scriptNonce to streamPageToResponse (:2098) closing a nonce gap on error-page hydration. transformIndexHtml is dev-only, so no worker/prod parity gap.
  • Prior Issues 2 & 3 — normal-order hook comment and fixture queueMicrotask cleanup remain in place.

Non-blocking notes

  1. hotUpdate is a blunt invalidation. It clears all documents' captured proxies on any file change, not just files in the changed document's graph. In dev that's fine (worst case an in-flight stale tab 404s), and it's strictly safe, but a filter or file-scoped eviction would be more surgical if this ever shows up as churn on large apps. Not worth blocking.
  2. MAX_RETAINED_PROXY_DOCUMENTS = 128 × 8 versions × N indices is the new theoretical ceiling. That's bounded and reasonable for a dev process; just noting the constants are the tuning surface if memory ever matters.

Verification performed

  • vitest run tests/pages-html-proxy.test.ts — 15 passed.
  • vitest run tests/pages-router.test.ts — 382 passed.
  • vp check packages/vinext/src/server/pages-html-proxy.ts — format/lint/types clean.
  • Confirmed HotUpdateOptions.server and the nested-map LRU eviction paths against the resolved vite-plus-core@0.2.6 source.

Correct fix for the hydration-staleness bug; the newest commit closes the last outstanding retention finding on both the version and document axes.

@ask-bonk

ask-bonk Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2823.

Verdict: approve

I re-reviewed the fix focusing on the newest commit 3814400 ("bound retained HTML proxy documents"), which directly addresses the moderate finding from the previous round (retention bounded per document but unbounded across documents).

Cross-document leak — resolved. 3814400 adds a second LRU dimension:

  • Per-(documentUrl, index) version cap stays at 8.
  • New per-document cap MAX_RETAINED_PROXY_DOCUMENTS = 128 with LRU-refresh on touch.
  • A hotUpdate handler that evicts all captured proxies on any client-graph file change.
  • Eviction refactored into a shared evictRetainedProxyModule helper so both LRU axes and hotUpdate share identical teardown.

Two new regression tests cover both axes (160 distinct doc URLs → ≤128 retained; hotUpdate teardown).

Re-verified: hotUpdate({ server }) matches Vite's HotUpdateOptions.server; pending-resolution eviction has no unhandled-rejection risk; no torn state under concurrent transforms; cross-document isolation intact via the document-keyed content hash; dev/prod parity holds (both call sites converted, error-page scriptNonce gap closed, transformIndexHtml is dev-only).

Non-blocking notes: hotUpdate over-invalidates (clears all documents on any file change — safe but blunt); the 128 × 8 × N ceiling is the tuning surface.

Verification: pages-html-proxy.test.ts (15 passed), pages-router.test.ts (382 passed), vp check clean.

Correct fix; the newest commit closes the last outstanding retention finding on both the version and document axes.

github run

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