Skip to content

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

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

fix(dev): refresh Pages hydration after render errors#2823
james-elicx wants to merge 1 commit 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: 31bb9b4

@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 31bb9b4 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.6 KB ⚫ -0.0%
Client entry size (gzip) vinext 122.2 KB 122.2 KB ⚫ +0.0%
Dev server cold start vinext 3.29 s 3.27 s ⚫ -0.8%
Production build time vinext 3.44 s 3.44 s ⚫ -0.2%
RSC entry closure size (gzip) vinext 113.9 KB 113.8 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

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