Skip to content

fix(plugin-vue): don't build the HMR descriptor when there is no dev server - #842

Open
cainrus wants to merge 1 commit into
vitejs:mainfrom
cainrus:fix/no-hmr-descriptor-on-build
Open

cainrus wants to merge 1 commit into
vitejs:mainfrom
cainrus:fix/no-hmr-descriptor-on-build

Conversation

@cainrus

@cainrus cainrus commented Sep 10, 2026

Copy link
Copy Markdown

fixes #841

Description

transformMain builds a second descriptor for every .vue, solely for HMR, on any build. The only
guard is fs.existsSync(filename) — no check for build mode, no check for a dev server. The result
goes into hmrCache, whose only reader is handleHotUpdate, which never runs on a build. Both
caches are unbounded Maps that live until the process exits, so the cost is linear in the number
of components and nothing reads it.

This PR gates that one call on devServer.

It does not undo #227 / #232. The HMR descriptor is still built from the raw fs read exactly as
those PRs intended, and #236's fs.existsSync guard for virtual files stays; this only skips
seeding the cache when there is no dev server to consume it.

Why it is invisible in an ordinary project

The two parse calls happen back to back in the same tick, and @vue/compiler-sfc caches by
genCacheKey(source, options) — with identical text the second call is a guaranteed cache hit and
costs nothing. The cost appears when an enforce: 'pre' plugin rewrites .vue before plugin-vue
sees it (auto test-id injection, compile-time env substitution in templates, i18n extraction): the
two calls then parse different text and a second descriptor really is built and retained. Same
divergence as #301, seen from the build side.

Reproduction, driving the plugin hooks directly, one arm per process:
https://github.com/cainrus/vite-plugin-vue-hmr-descriptor-repro — 500 components, distinct
descriptors 500 → 1000 once a pre-plugin is present. On a production app (~1963 components, library
-mode build of a large monorepo) this gate measured -192 MB of live set on average over two arm
pairs with the order swapped, peak RSS -450/-536 MB, 7-12 s off the build, emitted JS byte-for-byte
identical.

vite build --watch

Also has no reader, so the gate is safe there: Vite invokes the hook from handleHMRUpdate, whose
only call site is inside the dev server (onHMRUpdate in _createServer), and the plugin's own
handleHotUpdate opens with ctx.server.ws.send(...).

Why only the first conjunct

The neighbouring HMR condition is
devServer && devServer.config.server.hmr !== false && !ssr && !isProduction. The others govern
emitting HMR code; the question here is whether hmrCache has a reader at all, and it has one
exactly when a dev server exists. The asymmetry matters: handleHotUpdate returns silently when the
descriptor is missing, so an extra conjunct would break HMR with no error, while a missing one would
only leave today's waste.

Tests

packages/plugin-vue/__tests__/hmr-descriptor-cache.spec.ts drives transformMain and asserts
hmrCache directly: empty after a build, populated when a dev server is present. Verified it fails
without the change — with the gate removed, is not populated on a build fails on the
toBeUndefined assertion while the dev-server case stays green. eslint and oxfmt --check are
clean on both files.

The test writes a real file to a temp dir on purpose: the branch is guarded by fs.existsSync, so
against a non-existent path it would be skipped for the wrong reason and could not fail.

What is the purpose of this pull request?

  • Bug fix
  • New Feature
  • Documentation update
  • Other

…server

transformMain built a second descriptor for every SFC, solely for HMR, on any
build: the only guard was fs.existsSync(filename), with no check for a dev
server. The result goes into hmrCache, whose only reader is handleHotUpdate,
which never runs on a build. Both caches are unbounded Maps that live until the
process exits, so the cost is linear in the number of components and nothing
reads it.

The two parse calls happen back to back, so with identical text the second one
hits the compiler-sfc parse cache and costs nothing - which is why a plain
project shows no difference. The cost appears when an enforce: 'pre' plugin
rewrites .vue before plugin-vue sees it (auto test-id injection, compile-time
env substitution in templates, i18n extraction): the two calls then parse
different text and a second descriptor is really built and retained.

Only the first conjunct of the neighbouring condition is used deliberately. The
others (hmr !== false, !ssr, !isProduction) govern emitting HMR code, whereas
the question here is whether hmrCache has a reader at all - and it has one
exactly when a dev server exists. handleHotUpdate returns silently when the
descriptor is missing, so an extra conjunct would break HMR with no error.
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.

plugin-vue builds a second SFC descriptor for HMR on every build and retains it until the process exits

1 participant