diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index 7ea325d61c..464c600b00 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -69,7 +69,7 @@ export default defineConfig({ When `vite.config.ts` imports plugins at the top level, they are evaluated for every command, including `vp lint`, `vp fmt`, editor integrations, and long-lived background processes. This can make config loading slow and may trigger plugin setup side effects, such as reading files, starting watchers, or connecting to services. -Use `lazyPlugins` to load plugins only when the Vite pipeline actually runs (`dev`, `build`, `test`, `preview`): +Use `lazyPlugins` to skip the plugin factory when vite-plus loads your config only to read a metadata block (`lint`, `fmt`, `check`, `staged`, `pack`, `create`, the `run`/`cache` task lookup, and editor tooling). The plugins still load whenever Vite actually runs, `dev`, `build`, `test`, `preview`, and any build your own scripts spawn (a `vp run` task, `vp exec`): ```ts [vite.config.ts] import { defineConfig, lazyPlugins } from 'vite-plus'; diff --git a/packages/cli/bin/oxfmt b/packages/cli/bin/oxfmt index 142b0989ff..db9da6b699 100755 --- a/packages/cli/bin/oxfmt +++ b/packages/cli/bin/oxfmt @@ -23,8 +23,11 @@ const oxfmtBin = join(dirname(dirname(oxfmtMainPath)), 'bin', 'oxfmt'); // This allows oxfmt to load vite.config.ts. // For `vp check` and `vp fmt`, VP_VERSION is injected by -// `merge_resolved_envs_with_version()` in `cli.rs`, and VP_COMMAND is injected -// by `SubcommandResolver::resolve()`. +// `merge_resolved_envs_with_version()` in `cli.rs`. process.env.VP_VERSION = pkg.version; -process.env.VP_COMMAND ??= 'fmt'; +// oxfmt reads vite.config.ts only for the `fmt` block, so skip the user's +// Vite plugin factory (lazyPlugins) while the config evaluates. +// Literal kept in sync with CONFIG_METADATA_ENV in src/utils/constants.ts +// (this plain-JS bin can't import the bundled constant). +process.env.VP_RESOLVING_CONFIG_METADATA ??= '1'; await import(pathToFileURL(oxfmtBin).href); diff --git a/packages/cli/bin/oxlint b/packages/cli/bin/oxlint index e135a09cac..6b08f5afef 100755 --- a/packages/cli/bin/oxlint +++ b/packages/cli/bin/oxlint @@ -27,9 +27,12 @@ const tsgolintBin = resolveTsgolintExecutable( // This allows oxlint to load vite.config.ts. // For `vp check` and `vp lint`, VP_VERSION is injected by -// `merge_resolved_envs_with_version()` in `cli.rs`, and VP_COMMAND is injected -// by `SubcommandResolver::resolve()`. +// `merge_resolved_envs_with_version()` in `cli.rs`. process.env.VP_VERSION = pkg.version; -process.env.VP_COMMAND ??= 'lint'; +// oxlint reads vite.config.ts only for the `lint` block, so skip the user's +// Vite plugin factory (lazyPlugins) while the config evaluates. +// Literal kept in sync with CONFIG_METADATA_ENV in src/utils/constants.ts +// (this plain-JS bin can't import the bundled constant). +process.env.VP_RESOLVING_CONFIG_METADATA ??= '1'; process.env.OXLINT_TSGOLINT_PATH ??= tsgolintBin; await import(pathToFileURL(oxlintBin).href); diff --git a/packages/cli/binding/src/cli/resolver.rs b/packages/cli/binding/src/cli/resolver.rs index c050801113..578d525834 100644 --- a/packages/cli/binding/src/cli/resolver.rs +++ b/packages/cli/binding/src/cli/resolver.rs @@ -68,13 +68,7 @@ impl SubcommandResolver { resolved_vite_config: Option<&ResolvedUniversalViteConfig>, envs: &Arc, Arc>>, ) -> anyhow::Result { - let command_name = subcommand.command_name(); - let mut resolved = self.resolve_inner(subcommand, resolved_vite_config, envs).await?; - // Inject VP_COMMAND so that defineConfig's plugin factory knows which command is running, - // even when the subcommand is synthesized inside `vp run`. - let envs = Arc::make_mut(&mut resolved.envs); - envs.insert(Arc::from(OsStr::new("VP_COMMAND")), Arc::from(OsStr::new(command_name))); - Ok(resolved) + self.resolve_inner(subcommand, resolved_vite_config, envs).await } async fn resolve_inner( diff --git a/packages/cli/binding/src/cli/types.rs b/packages/cli/binding/src/cli/types.rs index e19fd9f148..b0b935af02 100644 --- a/packages/cli/binding/src/cli/types.rs +++ b/packages/cli/binding/src/cli/types.rs @@ -96,23 +96,6 @@ pub enum SynthesizableSubcommand { }, } -impl SynthesizableSubcommand { - /// Return the command name string for use in `VP_COMMAND` env var. - pub(super) fn command_name(&self) -> &'static str { - match self { - Self::Lint { .. } => "lint", - Self::Fmt { .. } => "fmt", - Self::Build { .. } => "build", - Self::Test { .. } => "test", - Self::Pack { .. } => "pack", - Self::Dev { .. } => "dev", - Self::Preview { .. } => "preview", - Self::Doc { .. } => "doc", - Self::Check { .. } => "check", - } - } -} - /// Top-level CLI argument parser for vite-plus. #[derive(Debug, Parser)] #[command(name = "vp", disable_help_subcommand = true)] diff --git a/packages/cli/snap-tests/vite-plugins-exec-build/build.mjs b/packages/cli/snap-tests/vite-plugins-exec-build/build.mjs new file mode 100644 index 0000000000..524f24aca0 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-exec-build/build.mjs @@ -0,0 +1,8 @@ +// Run a programmatic Vite build, mirroring a downstream framework CLI +// (e.g. `node framework-cli.js build`) that is spawned underneath `vp exec`. +// `vp exec` does not load the config for metadata, so the config-metadata +// marker is unset here; `lazyPlugins` must load the plugins for the build to +// produce a usable index.html. +import { build } from 'vite-plus'; + +await build({ root: import.meta.dirname, logLevel: 'silent' }); diff --git a/packages/cli/snap-tests/vite-plugins-exec-build/index.html b/packages/cli/snap-tests/vite-plugins-exec-build/index.html new file mode 100644 index 0000000000..7febab8c7e --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-exec-build/index.html @@ -0,0 +1,8 @@ + + + + + + diff --git a/packages/cli/snap-tests/vite-plugins-exec-build/my-plugin.ts b/packages/cli/snap-tests/vite-plugins-exec-build/my-plugin.ts new file mode 100644 index 0000000000..562ff320de --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-exec-build/my-plugin.ts @@ -0,0 +1,8 @@ +export default function myPlugin() { + return { + name: 'my-exec-build-plugin', + transformIndexHtml(html: string) { + return html.replace('', ''); + }, + }; +} diff --git a/packages/cli/snap-tests/vite-plugins-exec-build/package.json b/packages/cli/snap-tests/vite-plugins-exec-build/package.json new file mode 100644 index 0000000000..c078036ae0 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-exec-build/package.json @@ -0,0 +1,4 @@ +{ + "name": "vite-plugins-exec-build-test", + "private": true +} diff --git a/packages/cli/snap-tests/vite-plugins-exec-build/snap.txt b/packages/cli/snap-tests/vite-plugins-exec-build/snap.txt new file mode 100644 index 0000000000..45743526af --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-exec-build/snap.txt @@ -0,0 +1,3 @@ +> vp exec node build.mjs +> cat dist/index.html | grep 'exec-build-plugin-injected' # vp exec should still load vite plugins from factory + diff --git a/packages/cli/snap-tests/vite-plugins-exec-build/steps.json b/packages/cli/snap-tests/vite-plugins-exec-build/steps.json new file mode 100644 index 0000000000..cfbb1fac2f --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-exec-build/steps.json @@ -0,0 +1,9 @@ +{ + "commands": [ + { + "command": "vp exec node build.mjs", + "ignoreOutput": true + }, + "cat dist/index.html | grep 'exec-build-plugin-injected' # vp exec should still load vite plugins from factory" + ] +} diff --git a/packages/cli/snap-tests/vite-plugins-exec-build/vite.config.ts b/packages/cli/snap-tests/vite-plugins-exec-build/vite.config.ts new file mode 100644 index 0000000000..af45e31453 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-exec-build/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig, lazyPlugins } from 'vite-plus'; + +export default defineConfig({ + plugins: lazyPlugins(async () => { + const { default: myPlugin } = await import('./my-plugin'); + return [myPlugin()]; + }), +}); diff --git a/packages/cli/snap-tests/vite-plugins-run-verbatim-build/build.mjs b/packages/cli/snap-tests/vite-plugins-run-verbatim-build/build.mjs new file mode 100644 index 0000000000..07280e27d4 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-run-verbatim-build/build.mjs @@ -0,0 +1,8 @@ +// A verbatim (non-`vp`) package.json task that runs a programmatic Vite build. +// `vp run build` dispatches this as a Verbatim child. The config-metadata +// marker is cleared once `vp run`'s task discovery finishes, so by the time +// this build runs the marker is unset and `lazyPlugins` must load the user's +// plugins, otherwise it silently builds without them. +import { build } from 'vite-plus'; + +await build({ root: import.meta.dirname, logLevel: 'silent' }); diff --git a/packages/cli/snap-tests/vite-plugins-run-verbatim-build/index.html b/packages/cli/snap-tests/vite-plugins-run-verbatim-build/index.html new file mode 100644 index 0000000000..7febab8c7e --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-run-verbatim-build/index.html @@ -0,0 +1,8 @@ + + + + + + diff --git a/packages/cli/snap-tests/vite-plugins-run-verbatim-build/my-plugin.ts b/packages/cli/snap-tests/vite-plugins-run-verbatim-build/my-plugin.ts new file mode 100644 index 0000000000..adab1fe7ff --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-run-verbatim-build/my-plugin.ts @@ -0,0 +1,8 @@ +export default function myPlugin() { + return { + name: 'my-run-verbatim-plugin', + transformIndexHtml(html: string) { + return html.replace('', ''); + }, + }; +} diff --git a/packages/cli/snap-tests/vite-plugins-run-verbatim-build/package.json b/packages/cli/snap-tests/vite-plugins-run-verbatim-build/package.json new file mode 100644 index 0000000000..dcc53f94b9 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-run-verbatim-build/package.json @@ -0,0 +1,7 @@ +{ + "name": "vite-plugins-run-verbatim-build-test", + "private": true, + "scripts": { + "build": "node build.mjs" + } +} diff --git a/packages/cli/snap-tests/vite-plugins-run-verbatim-build/snap.txt b/packages/cli/snap-tests/vite-plugins-run-verbatim-build/snap.txt new file mode 100644 index 0000000000..c2a4209671 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-run-verbatim-build/snap.txt @@ -0,0 +1,3 @@ +> vp run build +> cat dist/index.html | grep 'run-verbatim-plugin-injected' # a verbatim build task under vp run should still load vite plugins + diff --git a/packages/cli/snap-tests/vite-plugins-run-verbatim-build/steps.json b/packages/cli/snap-tests/vite-plugins-run-verbatim-build/steps.json new file mode 100644 index 0000000000..e1d66735ce --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-run-verbatim-build/steps.json @@ -0,0 +1,9 @@ +{ + "commands": [ + { + "command": "vp run build", + "ignoreOutput": true + }, + "cat dist/index.html | grep 'run-verbatim-plugin-injected' # a verbatim build task under vp run should still load vite plugins" + ] +} diff --git a/packages/cli/snap-tests/vite-plugins-run-verbatim-build/vite.config.ts b/packages/cli/snap-tests/vite-plugins-run-verbatim-build/vite.config.ts new file mode 100644 index 0000000000..af45e31453 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-run-verbatim-build/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig, lazyPlugins } from 'vite-plus'; + +export default defineConfig({ + plugins: lazyPlugins(async () => { + const { default: myPlugin } = await import('./my-plugin'); + return [myPlugin()]; + }), +}); diff --git a/packages/cli/snap-tests/vite-plugins-skip-on-format/heavy-plugin.ts b/packages/cli/snap-tests/vite-plugins-skip-on-format/heavy-plugin.ts new file mode 100644 index 0000000000..7eb325e913 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-skip-on-format/heavy-plugin.ts @@ -0,0 +1,5 @@ +throw new Error('Plugins should not be loaded during vp format'); + +export default function heavyPlugin() { + return { name: 'heavy-plugin' }; +} diff --git a/packages/cli/snap-tests/vite-plugins-skip-on-format/package.json b/packages/cli/snap-tests/vite-plugins-skip-on-format/package.json new file mode 100644 index 0000000000..3a8fb99c2c --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-skip-on-format/package.json @@ -0,0 +1,4 @@ +{ + "name": "vite-plugins-skip-on-format-test", + "private": true +} diff --git a/packages/cli/snap-tests/vite-plugins-skip-on-format/snap.txt b/packages/cli/snap-tests/vite-plugins-skip-on-format/snap.txt new file mode 100644 index 0000000000..9c2d83a03f --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-skip-on-format/snap.txt @@ -0,0 +1,3 @@ +> vp format src/ # the `format` alias should not load plugins (heavy-plugin.ts throws if imported) +Finished in ms on 1 files using threads. +No config found, using defaults. Please add a config file or try `vp fmt --init` if needed. diff --git a/packages/cli/snap-tests/vite-plugins-skip-on-format/src/index.ts b/packages/cli/snap-tests/vite-plugins-skip-on-format/src/index.ts new file mode 100644 index 0000000000..c155820bf7 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-skip-on-format/src/index.ts @@ -0,0 +1 @@ +export const foo = 'bar'; diff --git a/packages/cli/snap-tests/vite-plugins-skip-on-format/steps.json b/packages/cli/snap-tests/vite-plugins-skip-on-format/steps.json new file mode 100644 index 0000000000..3fd061d18e --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-skip-on-format/steps.json @@ -0,0 +1,5 @@ +{ + "commands": [ + "vp format src/ # the `format` alias should not load plugins (heavy-plugin.ts throws if imported)" + ] +} diff --git a/packages/cli/snap-tests/vite-plugins-skip-on-format/vite.config.ts b/packages/cli/snap-tests/vite-plugins-skip-on-format/vite.config.ts new file mode 100644 index 0000000000..a166cd5ba0 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-skip-on-format/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig, lazyPlugins } from 'vite-plus'; + +export default defineConfig({ + plugins: lazyPlugins(async () => { + const { default: heavyPlugin } = await import('./heavy-plugin'); + return [heavyPlugin()]; + }), +}); diff --git a/packages/cli/src/__tests__/index.spec.ts b/packages/cli/src/__tests__/index.spec.ts index 6980002a12..db2aa66f7c 100644 --- a/packages/cli/src/__tests__/index.spec.ts +++ b/packages/cli/src/__tests__/index.spec.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { withConfigMetadataResolution } from '../define-config.ts'; import { configDefaults, coverageConfigDefaults, @@ -11,17 +12,18 @@ import { lazyPlugins, } from '../index.js'; -let originalVpCommand: string | undefined; +let originalMetadataEnv: string | undefined; beforeEach(() => { - originalVpCommand = process.env.VP_COMMAND; + originalMetadataEnv = process.env.VP_RESOLVING_CONFIG_METADATA; + delete process.env.VP_RESOLVING_CONFIG_METADATA; }); afterEach(() => { - if (originalVpCommand === undefined) { - delete process.env.VP_COMMAND; + if (originalMetadataEnv === undefined) { + delete process.env.VP_RESOLVING_CONFIG_METADATA; } else { - process.env.VP_COMMAND = originalVpCommand; + process.env.VP_RESOLVING_CONFIG_METADATA = originalMetadataEnv; } }); @@ -36,36 +38,68 @@ test('should keep vitest exports stable', () => { expect(defaultBrowserPort).toBeDefined(); }); -// lazyPlugins tests +// lazyPlugins tests — plugins load by default, and are skipped only while a +// config-metadata resolution is in progress (withConfigMetadataResolution). +// The decision does not depend on which command is running, so a build spawned +// by a `vp run` verbatim task or a `vp exec` child keeps its plugins. -test('lazyPlugins executes callback when VP_COMMAND is unset', () => { - delete process.env.VP_COMMAND; +test('lazyPlugins executes the callback by default', () => { const result = lazyPlugins(() => [{ name: 'test' }]); expect(result).toEqual([{ name: 'test' }]); }); -test.each(['dev', 'build', 'test', 'preview'])( - 'lazyPlugins executes callback when VP_COMMAND is %s', - (cmd) => { - process.env.VP_COMMAND = cmd; - const result = lazyPlugins(() => [{ name: 'my-plugin' }]); - expect(result).toEqual([{ name: 'my-plugin' }]); - }, -); - -test.each(['lint', 'fmt', 'check', 'staged', 'pack', 'install', 'run'])( - 'lazyPlugins returns undefined when VP_COMMAND is %s', - (cmd) => { - process.env.VP_COMMAND = cmd; - const cb = vi.fn(() => [{ name: 'my-plugin' }]); - const result = lazyPlugins(cb); - expect(result).toBeUndefined(); - expect(cb).not.toHaveBeenCalled(); - }, -); +test('lazyPlugins returns undefined during a config-metadata resolution', () => { + process.env.VP_RESOLVING_CONFIG_METADATA = '1'; + const cb = vi.fn(() => [{ name: 'my-plugin' }]); + const result = lazyPlugins(cb); + expect(result).toBeUndefined(); + expect(cb).not.toHaveBeenCalled(); +}); + +test('withConfigMetadataResolution skips plugins during the resolution and restores after', async () => { + const cb = vi.fn(() => [{ name: 'my-plugin' }]); + let during: ReturnType; + const returned = await withConfigMetadataResolution(async () => { + during = lazyPlugins(cb); + return 'result'; + }); + expect(returned).toBe('result'); + expect(during).toBeUndefined(); + expect(cb).not.toHaveBeenCalled(); + // marker cleared after → plugins load again + expect(lazyPlugins(() => [{ name: 'after' }])).toEqual([{ name: 'after' }]); +}); + +test('withConfigMetadataResolution restores a pre-existing marker (nesting)', async () => { + process.env.VP_RESOLVING_CONFIG_METADATA = '1'; + await withConfigMetadataResolution(async () => { + expect(process.env.VP_RESOLVING_CONFIG_METADATA).toBe('1'); + }); + expect(process.env.VP_RESOLVING_CONFIG_METADATA).toBe('1'); +}); + +test('withConfigMetadataResolution keeps the marker set across overlapping resolutions', async () => { + let releaseFirst!: () => void; + const firstPending = new Promise((resolve) => { + releaseFirst = resolve; + }); + // Two metadata resolutions overlap; the second finishes while the first is + // still awaiting. The marker must stay set until BOTH complete. + const first = withConfigMetadataResolution(async () => { + await firstPending; + return 'first'; + }); + const second = withConfigMetadataResolution(async () => 'second'); + expect(await second).toBe('second'); + // first is still in flight → lazyPlugins must still skip + expect(lazyPlugins(() => [{ name: 'plugin' }])).toBeUndefined(); + releaseFirst(); + expect(await first).toBe('first'); + // both done → marker cleared, plugins load again + expect(lazyPlugins(() => [{ name: 'after' }])).toEqual([{ name: 'after' }]); +}); test('lazyPlugins supports async callback', async () => { - process.env.VP_COMMAND = 'build'; const result = lazyPlugins(async () => { const plugin = await Promise.resolve({ name: 'async-plugin' }); return [plugin]; @@ -74,8 +108,8 @@ test('lazyPlugins supports async callback', async () => { expect(Array.isArray(result)).toBe(true); }); -test('lazyPlugins returns undefined for async callback when skipped', () => { - process.env.VP_COMMAND = 'lint'; +test('lazyPlugins returns undefined for async callback during metadata resolution', () => { + process.env.VP_RESOLVING_CONFIG_METADATA = '1'; const result = lazyPlugins(async () => { return [{ name: 'async-plugin' }]; }); @@ -83,7 +117,6 @@ test('lazyPlugins returns undefined for async callback when skipped', () => { }); test('lazyPlugins wraps sync function returning a Promise into array', () => { - process.env.VP_COMMAND = 'build'; // A sync function that returns a Promise (not an async function) — same handling as async const result = lazyPlugins(() => Promise.resolve([{ name: 'sync-promise-plugin' }])); expect(Array.isArray(result)).toBe(true); @@ -110,7 +143,6 @@ const userPlugins = (plugins: unknown): unknown[] => { // lazyPlugins return types satisfy Vite's plugins?: PluginOption[] field. test('lazyPlugins sync return type satisfies plugins field', () => { - process.env.VP_COMMAND = 'build'; // Must compile: plugins accepts PluginOption[] | undefined const config = defineConfig({ plugins: lazyPlugins(() => [{ name: 'sync-type-test' }]), @@ -119,7 +151,6 @@ test('lazyPlugins sync return type satisfies plugins field', () => { }); test('lazyPlugins async return type satisfies plugins field', () => { - process.env.VP_COMMAND = 'build'; // Must compile: async overload returns PluginOption[] | undefined, not Promise const config = defineConfig({ plugins: lazyPlugins(async () => { @@ -130,7 +161,7 @@ test('lazyPlugins async return type satisfies plugins field', () => { }); test('lazyPlugins undefined return satisfies plugins field', () => { - process.env.VP_COMMAND = 'lint'; + process.env.VP_RESOLVING_CONFIG_METADATA = '1'; // Must compile: undefined is accepted by plugins?: PluginOption[] const config = defineConfig({ plugins: lazyPlugins(() => [{ name: 'skipped' }]), @@ -140,7 +171,6 @@ test('lazyPlugins undefined return satisfies plugins field', () => { }); test('lazyPlugins with vitest configureVitest plugin satisfies plugins field', () => { - process.env.VP_COMMAND = 'test'; const config = defineConfig({ plugins: lazyPlugins(() => [ { diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 276e613617..ab86d07e83 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -45,7 +45,6 @@ if (args[0] === 'help' && args[1]) { } const command = args[0]; -process.env.VP_COMMAND = command ?? ''; // Global commands — handled by tsdown-bundled modules in dist/ if (command === 'create') { diff --git a/packages/cli/src/define-config.ts b/packages/cli/src/define-config.ts index 649cb713d2..eefe03e824 100644 --- a/packages/cli/src/define-config.ts +++ b/packages/cli/src/define-config.ts @@ -20,7 +20,7 @@ import type { CreateTemplateEntry } from './create/org-manifest.ts'; import type { PackUserConfig } from './pack.ts'; import type { RunConfig } from './run-config.ts'; import type { StagedConfig } from './staged-config.ts'; -import { VITEST_VERSION } from './utils/constants.ts'; +import { CONFIG_METADATA_ENV, VITEST_VERSION } from './utils/constants.ts'; declare module '@voidzero-dev/vite-plus-core' { interface UserConfig { @@ -719,20 +719,60 @@ export function defineProject(config: UserProjectConfigExport): UserProjectConfi return viteDefineProject(injectPluginIntoProjectExport(config) as never); } -const VITE_COMMANDS = new Set(['dev', 'build', 'test', 'preview']); +// Number of in-flight `withConfigMetadataResolution` calls, and the marker +// value captured before the first one set it. The marker stays set until the +// LAST overlapping resolution finishes (e.g. several package configs resolved +// concurrently in a monorepo), so an async config that reaches `lazyPlugins` +// while any metadata resolution is still pending is not mistaken for a build. +let configMetadataDepth = 0; +let configMetadataSavedValue: string | undefined; + +/** + * Run a config-metadata resolution (a `resolveConfig` call that loads the + * user's config purely to read a non-plugin block) with the metadata marker + * set, so any `lazyPlugins` evaluated during it skips the plugin factory. + * + * The marker is scoped in time, not by command name: it is set only while at + * least one resolution is in flight (ref-counted so overlapping/nested + * resolutions compose) and the prior value is restored afterwards. By the time + * the task runner spawns a child (a verbatim `vp run` build, a `vp exec` + * child), the marker is already gone, so those builds correctly load plugins. + * Keying on the resolution itself — rather than guessing from the command + * name — also means command aliases (`vp format`) and not-yet-known commands + * all load plugins. + */ +export async function withConfigMetadataResolution(fn: () => Promise): Promise { + if (configMetadataDepth === 0) { + configMetadataSavedValue = process.env[CONFIG_METADATA_ENV]; + process.env[CONFIG_METADATA_ENV] = '1'; + } + configMetadataDepth++; + try { + return await fn(); + } finally { + configMetadataDepth--; + if (configMetadataDepth === 0) { + if (configMetadataSavedValue === undefined) { + delete process.env[CONFIG_METADATA_ENV]; + } else { + process.env[CONFIG_METADATA_ENV] = configMetadataSavedValue; + } + configMetadataSavedValue = undefined; + } + } +} export function lazyPlugins(cb: () => PluginOption[]): PluginOption[] | undefined; export function lazyPlugins(cb: () => Promise): PluginOption[] | undefined; export function lazyPlugins( cb: () => PluginOption[] | Promise, ): PluginOption[] | undefined { - const cmd = process.env.VP_COMMAND; - if (!cmd || VITE_COMMANDS.has(cmd)) { - const result = cb(); - if (result instanceof Promise) { - return [result]; - } - return result; + if (process.env[CONFIG_METADATA_ENV] === '1') { + return undefined; + } + const result = cb(); + if (result instanceof Promise) { + return [result]; } - return undefined; + return result; } diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index d9288bb2e8..58ea7e75da 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -828,12 +828,14 @@ function showMigrationSummary(options: { async function checkRolldownCompatibility(rootDir: string, report: MigrationReport): Promise { try { const { resolveConfig } = await import('../index.js'); + const { withConfigMetadataResolution } = await import('../define-config.js'); const { checkManualChunksCompat } = await import('./compat.js'); // Use 'runner' configLoader to avoid Rolldown bundling the config file, // which prints UNRESOLVED_IMPORT warnings that cannot be suppressed via logLevel. - const config = await resolveConfig( - { root: rootDir, logLevel: 'silent', configLoader: 'runner' }, - 'build', + // Reads the config only for the manualChunks compat check, so skip the + // user's plugin factory while it resolves. + const config = await withConfigMetadataResolution(() => + resolveConfig({ root: rootDir, logLevel: 'silent', configLoader: 'runner' }, 'build'), ); checkManualChunksCompat(config.build?.rollupOptions?.output, report); } catch { diff --git a/packages/cli/src/resolve-fmt.ts b/packages/cli/src/resolve-fmt.ts index f9f319296b..9c2c2e2cc6 100644 --- a/packages/cli/src/resolve-fmt.ts +++ b/packages/cli/src/resolve-fmt.ts @@ -13,7 +13,7 @@ import { dirname, join } from 'node:path'; -import { DEFAULT_ENVS, resolve } from './utils/constants.ts'; +import { CONFIG_METADATA_ENV, DEFAULT_ENVS, resolve } from './utils/constants.ts'; /** * Resolves the oxfmt binary path and environment variables. @@ -42,6 +42,9 @@ export async function fmt(): Promise<{ // TODO: provide envs inference API envs: { ...DEFAULT_ENVS, + // oxfmt loads vite.config.ts only to read the `fmt` block, so skip the + // user's Vite plugin factory (lazyPlugins) while it evaluates the config. + [CONFIG_METADATA_ENV]: '1', }, }; } diff --git a/packages/cli/src/resolve-lint.ts b/packages/cli/src/resolve-lint.ts index faa13fc77a..f552962c4b 100644 --- a/packages/cli/src/resolve-lint.ts +++ b/packages/cli/src/resolve-lint.ts @@ -13,7 +13,7 @@ import { dirname, join } from 'node:path'; -import { DEFAULT_ENVS, resolve } from './utils/constants.ts'; +import { CONFIG_METADATA_ENV, DEFAULT_ENVS, resolve } from './utils/constants.ts'; import { resolveTsgolintExecutable } from './utils/tsgolint-path.ts'; export { resolveWindowsTsgolintExecutable } from './utils/tsgolint-path.ts'; @@ -50,6 +50,9 @@ export async function lint(): Promise<{ envs: { ...DEFAULT_ENVS, OXLINT_TSGOLINT_PATH: oxlintTsgolintPath, + // oxlint loads vite.config.ts only to read the `lint` block, so skip the + // user's Vite plugin factory (lazyPlugins) while it evaluates the config. + [CONFIG_METADATA_ENV]: '1', }, }; return result; diff --git a/packages/cli/src/resolve-vite-config.ts b/packages/cli/src/resolve-vite-config.ts index cdd9c14869..8cafa5bb6a 100644 --- a/packages/cli/src/resolve-vite-config.ts +++ b/packages/cli/src/resolve-vite-config.ts @@ -1,6 +1,8 @@ import fs from 'node:fs'; import path from 'node:path'; +import { withConfigMetadataResolution } from './define-config.ts'; + // Mirrors Vite's own DEFAULT_CONFIG_FILES order so finders here pick the same // file Vite loads when a directory contains more than one config (e.g. a // `vite.config.js` next to a stray `vite.config.ts`). Readers evaluate via @@ -96,17 +98,21 @@ export interface ResolveViteConfigOptions { export async function resolveViteConfig(cwd: string, options?: ResolveViteConfigOptions) { const { resolveConfig } = await import('./index.js'); - if (options?.traverseUp && !hasViteConfig(cwd)) { - const workspaceRoot = findWorkspaceRoot(cwd); - if (workspaceRoot) { - const configFile = findViteConfigUp(path.dirname(cwd), workspaceRoot); - if (configFile) { - return resolveConfig({ root: cwd, configFile }, 'build'); + // This loads the config purely to read a non-plugin block (lint/fmt/pack/run/ + // staged/create…), so skip the user's plugin factory while it evaluates. + return withConfigMetadataResolution(async () => { + if (options?.traverseUp && !hasViteConfig(cwd)) { + const workspaceRoot = findWorkspaceRoot(cwd); + if (workspaceRoot) { + const configFile = findViteConfigUp(path.dirname(cwd), workspaceRoot); + if (configFile) { + return resolveConfig({ root: cwd, configFile }, 'build'); + } } } - } - return resolveConfig({ root: cwd }, 'build'); + return resolveConfig({ root: cwd }, 'build'); + }); } export async function resolveUniversalViteConfig(err: null | Error, viteConfigCwd: string) { diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index 2ae6ba9c52..7d10f74588 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -93,3 +93,10 @@ export const DEFAULT_ENVS = { // Indicate that vite-plus is the package manager NODE_PACKAGE_MANAGER: 'vite-plus', } as const; + +// Env var set while `vite.config.ts` is loaded only to read a config block, not +// to run the Vite pipeline. `lazyPlugins` skips the user's plugin factory while +// it is `'1'`. Single source of truth shared by `withConfigMetadataResolution` +// (in-process) and the oxlint/oxfmt resolvers + bins (which load the config in +// a subprocess). Keep the `bin/oxlint`/`bin/oxfmt` literals in sync with this. +export const CONFIG_METADATA_ENV = 'VP_RESOLVING_CONFIG_METADATA';