Skip to content

fix(vuepress): set NODE_ENV before loading bundler in bin shorthands - #1723

Merged
Mister-Hope merged 1 commit into
vuepress:mainfrom
maoger:fix/vuepress-node-env
Sep 29, 2026
Merged

Mister-Hope merged 1 commit into
vuepress:mainfrom
maoger:fix/vuepress-node-env

Conversation

@maoger

@maoger maoger commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

Before submitting the PR, please make sure you do the following

  • Read the Contributing Guidelines.
  • Provide a description in this PR that addresses what the PR is solving. If this PR is going to solve an existing issue, please reference the issue (e.g. close #123).

What is the purpose of this pull request?

  • Bug fix
  • New feature
  • Documentation update
  • Other

Description

Symptom

vuepress-vite build and vuepress-webpack build run the Node side with the development builds of Vue (vue.cjs.js, @vue/runtime-core, @vue/reactivity, @vue/shared, @vue/compiler-core, @vue/compiler-dom), while vue/server-renderer is loaded as the production build. So dev and prod builds of Vue end up mixed in one process. This has two effects:

  • SSR ("Rendering N pages") is much slower than it should be.
  • The output differs from vuepress build with the same bundler set in the config file, because the template compiler that @vitejs/plugin-vue / vue-loader use is also the dev build. For example, the dev compiler keeps HTML comments of Markdown pages (comments defaults to __DEV__), and emits "v-if" instead of "" as the text of the v-if placeholder comment.

The dev command of these bins is effectively unaffected (the dev builds are the expected ones there), and the plain vuepress bin is not affected at all.

Root cause

The shorthand bins import the bundler statically:

import { viteBundler } from '@vuepress/bundler-vite'
import { cli } from '@vuepress/cli'

cli({ bundler: viteBundler() })

Static imports are evaluated before cli() runs, but the default NODE_ENV is only set inside the commands (process.env.NODE_ENV ??= 'production' at the start of build(), ??= 'development' in dev()). Meanwhile the bundler packages load vue at module top level:

  • @vuepress/bundlerutils imports ssrContextKey from vue (used by both bundlers);
  • @vitejs/plugin-vue imports computed / shallowRef from vue;
  • vue-loader requires vue/compiler-sfc.

The CJS entries of vue and @vue/* (index.js) choose *.cjs.prod.js or *.cjs.js by process.env.NODE_ENV when they are first required, and the result stays in the module cache. At that moment NODE_ENV is still undefined, so the dev builds are selected. Later, renderPageToString dynamically imports vue/server-renderer after NODE_ENV has become production, so the prod server renderer is loaded, while its require('vue') gets the cached dev build.

With the plain vuepress bin, the bundler comes from the user config, which is loaded after NODE_ENV has been set, so everything is the production build. That's why only the shorthand bins are affected.

Note that making the vue import in @vuepress/bundlerutils lazy would not be enough, because @vitejs/plugin-vue and vue-loader also load Vue eagerly (and so could any future dependency).

How it was detected

On a real site (1253 pages, vuepress-theme-hope@2.0.0-rc.109, vuepress@2.0.0-rc.31, Vite 8, Node 24, built with vuepress-vite build):

  • A --require preload wrapping Module._load showed that the first load of vue/index.js happened with NODE_ENV=undefined; a module.registerHooks() resolve hook showed that the importers were @vuepress/bundlerutils/dist/index.js and @vitejs/plugin-vue/dist/index.mjs. At exit require.cache contained vue/dist/vue.cjs.js, @vue/runtime-core/dist/runtime-core.cjs.js, and both server-renderer.cjs.js and server-renderer.cjs.prod.js.
  • Starting the same build with NODE_ENV=production already set (4-core limit): "Rendering 1253 pages" went from 22.2 s / 25.3 s to 14.3 s / 18.6 s (about 30% faster). The generated HTML was the same apart from one page whose entry order also varies between two builds in the same mode (the site sets template.compilerOptions.comments: false, so the comment difference described above did not show up there). The only client bundle difference was the v-if placeholder comment text ("v-if" vs "") in a few chunks, i.e. the client build was using the dev template compiler too.

The fix

In vuepress-vite and vuepress-webpack, set the default NODE_ENV according to the command before loading anything else, then import the bundler and the cli dynamically:

const command = process.argv[2]
if (command === 'dev') {
  process.env.NODE_ENV ??= 'development'
} else if (command === 'build') {
  process.env.NODE_ENV ??= 'production'
}

const { viteBundler } = await import('@vuepress/bundler-vite')
const { cli } = await import('@vuepress/cli')

cli({ bundler: viteBundler() })

This makes vuepress-vite build behave the same as vuepress build with bundler: viteBundler() in the config file. Vite's own bin uses the same pattern: it inspects process.argv and sets env vars (DEBUG) before dynamically importing its CLI.

Trade-offs / design notes:

  • Existing semantics are kept: ??= never overrides an explicitly provided NODE_ENV; dev still defaults to development and build to production; the ??= in the dev / build commands of @vuepress/cli is kept for Node API users and for the cases below.
  • Only process.argv[2] is checked on purpose. When it is dev / build, cac is guaranteed to match the same command (it is the first positional argument), so the early default can never be wrong. When options come before the command (e.g. vuepress-webpack --debug build docs, rare) or for info / --help / --version, nothing changes compared to now and the cli handles it as before. Parsing argv with cac here would duplicate the command definitions of the cli.
  • The defaults are duplicated between these two bins and the commands in @vuepress/cli; a comment asks to keep them in sync. Alternatives considered: lazy-loading vue inside the bundler packages (insufficient, see above, and fragile); a lazy Bundler proxy (not possible, Bundler.mergeConfig is synchronous); letting cli() accept a lazy default app config (public API change for the same effect).
  • Behavior change for users of the shorthand bins: build now uses the production builds of Vue on the Node side, exactly like vuepress build. Visible consequences are the expected production ones: HTML comments in Markdown are no longer rendered, and SSR is faster. One thing reviewers may want to be aware of: bundler-webpack: SSR bundle inlines a second Vue runtime -> 'Cannot redefine property' when rendering multiple pages #1718 (Cannot redefine property with @vuepress/bundler-webpack) only reproduces with the production runtime, so vuepress-webpack build users who were masked by the dev runtime would now see it, just like vuepress build users with the webpack bundler already do. The e2e site builds fine with webpack after this change.
  • No unit test is added: the change lives in the bin scripts of the vuepress package, which has no tests (and is excluded from coverage), and a meaningful test would need to spawn the built bin and hook Node's module loader. It is verified manually as below instead.

Verification

Environment: Node 24.21.0, pnpm 11.15.1, Vite 8.1.5, Vue 3.5.40.

  • pnpm build, pnpm lint (ESLint + oxfmt --check), pnpm check-types, pnpm test:unit (63 files, 666 tests): all pass.
  • e2e site built with the preload hook below (NODE_OPTIONS="--require ./vue-env-hook.cjs"), before and after this change. Files marked loaded=false are entries that Node's ESM-to-CJS export pre-parsing adds to require.cache without executing them; they are left out except in the first block. Output is sorted and paths are shortened.

vuepress-vite build docs --clean-cache --clean-temp, before:

first import of 'vue' from <repo>/packages/bundlerutils/dist/index.js with NODE_ENV=undefined
first load of vue/index.js with NODE_ENV=undefined
success VuePress build completed in 837ms!
NODE_ENV at exit=production
  @vue/compiler-core/dist/compiler-core.cjs.js (executed)
  @vue/compiler-dom/dist/compiler-dom.cjs.js (executed)
  @vue/compiler-sfc/dist/compiler-sfc.cjs.js (executed)
  @vue/compiler-ssr/dist/compiler-ssr.cjs.js (executed)
  @vue/reactivity/dist/reactivity.cjs.js (executed)
  @vue/runtime-core/dist/runtime-core.cjs.js (executed)
  @vue/runtime-dom/dist/runtime-dom.cjs.js (executed)
  @vue/server-renderer/dist/server-renderer.cjs.js (loaded=false, never executed)
  @vue/server-renderer/dist/server-renderer.cjs.prod.js (executed)
  @vue/shared/dist/shared.cjs.js (executed)
  vue/dist/vue.cjs.js (executed)

vuepress-vite build docs --clean-cache --clean-temp, after (identical to vuepress build docs, i.e. pnpm docs:build, before and after):

first import of 'vue' from <repo>/packages/bundlerutils/dist/index.js with NODE_ENV=production
first load of vue/index.js with NODE_ENV=production
success VuePress build completed in 754ms!
NODE_ENV at exit=production
  @vue/compiler-core/dist/compiler-core.cjs.prod.js (executed)
  @vue/compiler-dom/dist/compiler-dom.cjs.prod.js (executed)
  @vue/compiler-sfc/dist/compiler-sfc.cjs.js (executed)
  @vue/compiler-ssr/dist/compiler-ssr.cjs.js (executed)
  @vue/reactivity/dist/reactivity.cjs.prod.js (executed)
  @vue/runtime-core/dist/runtime-core.cjs.prod.js (executed)
  @vue/runtime-dom/dist/runtime-dom.cjs.prod.js (executed)
  @vue/server-renderer/dist/server-renderer.cjs.prod.js (executed)
  @vue/shared/dist/shared.cjs.prod.js (executed)
  vue/dist/vue.cjs.prod.js (executed)

E2E_BUNDLER=webpack vuepress-webpack build docs: before, the first load of vue also happened with NODE_ENV=undefined and the same dev builds plus server-renderer.cjs.prod.js were executed; after, the first load happens with NODE_ENV=production and only *.cjs.prod.js builds are executed (same as vuepress build with the webpack bundler). The build succeeds.

Output comparison of the e2e site built with vuepress-vite build (content hashes normalized):

  • before vs after: one HTML page differs (markdown/images/images.html), and only in HTML comments that come from the Markdown source and were kept by the dev template compiler; client chunks differ only in the code generated by the dev / prod template compiler (and the resulting hashes).
  • after vs vuepress build: identical except for the order of routes in the app chunk.

Rendering time, with 1000 extra copies of components/route-link.md / components/auto-link.md added to the e2e site (1055 pages), taskset -c 0-3, 3 alternating runs each:

vuepress-vite build Rendering 1055 pages Wall time
before 4.61 / 4.88 / 5.42 s 11.8 / 12.3 / 13.1 s
after 2.88 / 2.81 / 2.73 s 10.2 / 9.9 / 10.3 s

Other checks with the new bins:

  • NODE_ENV=development vuepress-vite build docs: the first load of vue still sees development and vue.cjs.js is used, i.e. an explicit value is not overridden.
  • vuepress-vite dev docs and E2E_BUNDLER=webpack vuepress-webpack dev docs: the first load of vue now sees NODE_ENV=development (before: undefined); the dev servers start and respond with HTTP 200. pnpm docs:dev also still works.
  • vuepress-vite --help, --version, build --help, info, vuepress-webpack --help, and no arguments behave as before.
Preload hook used above
// vue-env-hook.cjs, use with NODE_OPTIONS="--require ./vue-env-hook.cjs"
const Module = require('node:module')
const { isMainThread } = require('node:worker_threads')

let firstLoad = true
const load = Module._load
Module._load = function (request, parent, isMain) {
  if (firstLoad && /(^|\/)(vue|@vue\/runtime-core)(\/index\.js)?$/.test(request)) {
    firstLoad = false
    console.error(`first load of ${request} with NODE_ENV=${process.env.NODE_ENV}`)
  }
  return load.apply(this, arguments)
}

let firstImport = true
Module.registerHooks?.({
  resolve(specifier, context, next) {
    if (firstImport && specifier === 'vue') {
      firstImport = false
      console.error(
        `first import of 'vue' from ${context.parentURL} with NODE_ENV=${process.env.NODE_ENV}`,
      )
    }
    return next(specifier, context)
  },
})

process.on('exit', () => {
  if (!isMainThread) return
  console.error(`NODE_ENV at exit=${process.env.NODE_ENV}`)
  for (const [file, mod] of Object.entries(require.cache)) {
    if (/\/node_modules\/(vue|@vue\/[^/]+)\/dist\//.test(file)) {
      console.error(
        `  ${file.replace(/^.*node_modules\//, '')} (${mod.loaded ? 'executed' : 'loaded=false, never executed'})`,
      )
    }
  }
})

Screenshots

N/A

`vuepress-vite` and `vuepress-webpack` imported the bundler statically,
so it was evaluated before the `dev` / `build` command set the default
`NODE_ENV`. The bundlers load `vue` at module top level (e.g.
`@vuepress/bundlerutils` imports `ssrContextKey` from `vue`, and
`@vitejs/plugin-vue` / `vue-loader` load `vue` / `vue/compiler-sfc`),
and the CJS entries of `vue` and `@vue/*` pick their development or
production builds by `NODE_ENV` when first required.

As a result, `vuepress-vite build` and `vuepress-webpack build` ran with
the development builds of the Vue runtime and template compiler, while
`vue/server-renderer`, imported later for SSR, used its production
build, mixing both in one process. SSR was noticeably slower, and the
output differed from `vuepress build` with the bundler set in the config
file, e.g. the dev compiler kept HTML comments of Markdown pages.

Set the default `NODE_ENV` according to the command in these bins, and
import the bundler and the cli dynamically afterwards. An explicitly
provided `NODE_ENV` is still respected, and other commands and options
are left to the cli as before.
Copilot AI lite review requested due to automatic review settings September 27, 2026 04:14

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot wasn't able to review any files in this pull request. Check if the Files changed in this pull request are included in default exclusions.


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@Mister-Hope

Copy link
Copy Markdown
Member

Nice catch, I believe this also fixes #1718.

@maoger

maoger commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review! I don't think this PR fixes #1718, though — it actually makes vuepress-webpack build run into it, as mentioned in the trade-offs above.

#1718 comes from config.externals(['vue']) in createServerConfig.ts not matching vue/server-renderer, so a second Vue runtime is inlined into the server bundle. This PR doesn't change the bundle; it only makes the shorthand bins use the production build of Vue, like vuepress build already does. Since #1718 is masked by the dev runtime, vuepress-webpack build users on main don't see it today.

Verified with a minimal site (webpack bundler, two pages whose <script setup> both call useTemplateRef('box')):

result
vuepress-webpack build on main no error
vuepress-webpack build with this PR TypeError: Cannot redefine property: box
vuepress build with webpackBundler() in config same error
main bin with NODE_ENV=production same error
this PR + externalizing vue and vue/* no error
vuepress build + externalizing vue and vue/* no error

The actual fix for #1718 is in #1725. It would be good to merge it together with or before this PR, so that vuepress-webpack build users don't hit #1718 in between.

@Mister-Hope
Mister-Hope merged commit c7234f9 into vuepress:main Sep 29, 2026
18 checks passed
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.

3 participants