diff --git a/docs/config/index.md b/docs/config/index.md index 78807ce104..810a406189 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -28,4 +28,4 @@ Vite+ extends the basic Vite configuration with these additions: - [`test`](/config/test) for Vitest - [`run`](/config/run) for Vite Task - [`pack`](/config/pack) for tsdown -- [`staged`](/config/staged) for staged-file checks +- [`staged`](/config/staged) for staged-file checks \ No newline at end of file diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index 306bf83655..4d3af8ad46 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -89,6 +89,37 @@ export default defineConfig({ }); ``` +## Slow config loading caused by heavy plugins + +When `vite.config.ts` imports heavy plugins at the top level, every `import` is evaluated eagerly, even for commands like `vp lint` or `vp fmt` that don't need those plugins. This can make config loading noticeably slow. + +Pass a factory function to `plugins` in `defineConfig` to defer plugin loading. The factory is only called for commands that need plugins (`dev`, `build`, `test`, `preview`), and skipped for everything else: + +```ts +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + plugins: () => [myPlugin()], +}); +``` + +For heavy plugins that should be lazily imported, combine with dynamic `import()`: + +```ts +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + plugins: async () => { + const { default: heavyPlugin } = await import('vite-plugin-heavy'); + return [heavyPlugin()]; + }, +}); +``` + +::: warning +The plugins factory requires `defineConfig` from `vite-plus`, not from `vite`. Vite's native `defineConfig` does not support factory functions for the `plugins` field. +::: + ## Asking for Help If you are stuck, please reach out: diff --git a/packages/cli/binding/src/cli.rs b/packages/cli/binding/src/cli.rs index 7dd66bf906..9a799b59b5 100644 --- a/packages/cli/binding/src/cli.rs +++ b/packages/cli/binding/src/cli.rs @@ -123,6 +123,24 @@ pub enum SynthesizableSubcommand { }, } +impl SynthesizableSubcommand { + /// Return the command name string for use in `VP_COMMAND` env var. + 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::Install { .. } => "install", + Self::Check { .. } => "check", + } + } +} + /// Top-level CLI argument parser for vite-plus. #[derive(Debug, Parser)] #[command(name = "vp", disable_help_subcommand = true)] @@ -233,6 +251,22 @@ impl SubcommandResolver { resolved_vite_config: Option<&ResolvedUniversalViteConfig>, envs: &Arc, Arc>>, cwd: &Arc, + ) -> anyhow::Result { + let command_name = subcommand.command_name(); + let mut resolved = self.resolve_inner(subcommand, resolved_vite_config, envs, cwd).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) + } + + async fn resolve_inner( + &self, + subcommand: SynthesizableSubcommand, + resolved_vite_config: Option<&ResolvedUniversalViteConfig>, + envs: &Arc, Arc>>, + cwd: &Arc, ) -> anyhow::Result { match subcommand { SynthesizableSubcommand::Lint { mut args } => { diff --git a/packages/cli/snap-tests/vite-plugins-async-test/my-vitest-plugin.ts b/packages/cli/snap-tests/vite-plugins-async-test/my-vitest-plugin.ts new file mode 100644 index 0000000000..64f960c1c0 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-async-test/my-vitest-plugin.ts @@ -0,0 +1,14 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export default function myVitestPlugin() { + return { + name: 'my-vitest-plugin', + configureVitest() { + fs.writeFileSync( + path.join(import.meta.dirname, '.vitest-plugin-loaded'), + 'configureVitest hook executed', + ); + }, + }; +} diff --git a/packages/cli/snap-tests/vite-plugins-async-test/package.json b/packages/cli/snap-tests/vite-plugins-async-test/package.json new file mode 100644 index 0000000000..4e89e18133 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-async-test/package.json @@ -0,0 +1,4 @@ +{ + "name": "vite-plugins-async-test", + "private": true +} diff --git a/packages/cli/snap-tests/vite-plugins-async-test/snap.txt b/packages/cli/snap-tests/vite-plugins-async-test/snap.txt new file mode 100644 index 0000000000..cb8596d640 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-async-test/snap.txt @@ -0,0 +1,10 @@ +> vp test # async plugins factory should load vitest plugin with configureVitest hook + RUN + + ✓ src/index.test.ts (1 test) ms + + Test Files 1 passed (1) + Tests 1 passed (1) + Start at + Duration ms (transform ms, setup ms, import ms, tests ms, environment ms) + diff --git a/packages/cli/snap-tests/vite-plugins-async-test/src/index.test.ts b/packages/cli/snap-tests/vite-plugins-async-test/src/index.test.ts new file mode 100644 index 0000000000..757c1049bd --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-async-test/src/index.test.ts @@ -0,0 +1,11 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { expect, test } from '@voidzero-dev/vite-plus-test'; + +test('async plugin factory should load vitest plugin with configureVitest hook', () => { + const markerPath = path.join(import.meta.dirname, '..', '.vitest-plugin-loaded'); + expect(fs.existsSync(markerPath)).toBe(true); + expect(fs.readFileSync(markerPath, 'utf-8')).toBe('configureVitest hook executed'); + fs.unlinkSync(markerPath); +}); diff --git a/packages/cli/snap-tests/vite-plugins-async-test/steps.json b/packages/cli/snap-tests/vite-plugins-async-test/steps.json new file mode 100644 index 0000000000..c14115b8f0 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-async-test/steps.json @@ -0,0 +1,5 @@ +{ + "commands": [ + "vp test # async plugins factory should load vitest plugin with configureVitest hook" + ] +} diff --git a/packages/cli/snap-tests/vite-plugins-async-test/vite.config.ts b/packages/cli/snap-tests/vite-plugins-async-test/vite.config.ts new file mode 100644 index 0000000000..0c6f1a7bfe --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-async-test/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + plugins: async () => { + const { default: myVitestPlugin } = await import('./my-vitest-plugin'); + return [myVitestPlugin()]; + }, +}); diff --git a/packages/cli/snap-tests/vite-plugins-async/index.html b/packages/cli/snap-tests/vite-plugins-async/index.html new file mode 100644 index 0000000000..7febab8c7e --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-async/index.html @@ -0,0 +1,8 @@ + + + + + + diff --git a/packages/cli/snap-tests/vite-plugins-async/my-plugin.ts b/packages/cli/snap-tests/vite-plugins-async/my-plugin.ts new file mode 100644 index 0000000000..33ba10fcfe --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-async/my-plugin.ts @@ -0,0 +1,8 @@ +export default function myLazyPlugin() { + return { + name: 'my-lazy-plugin', + transformIndexHtml(html: string) { + return html.replace('', ''); + }, + }; +} diff --git a/packages/cli/snap-tests/vite-plugins-async/package.json b/packages/cli/snap-tests/vite-plugins-async/package.json new file mode 100644 index 0000000000..be4b007f68 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-async/package.json @@ -0,0 +1,4 @@ +{ + "name": "lazy-loading-plugins-test", + "private": true +} diff --git a/packages/cli/snap-tests/vite-plugins-async/snap.txt b/packages/cli/snap-tests/vite-plugins-async/snap.txt new file mode 100644 index 0000000000..5415a2e9e2 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-async/snap.txt @@ -0,0 +1,3 @@ +> vp build +> cat dist/index.html | grep 'lazy-plugin-injected' # async vitePlugins() should apply plugins during build + diff --git a/packages/cli/snap-tests/vite-plugins-async/steps.json b/packages/cli/snap-tests/vite-plugins-async/steps.json new file mode 100644 index 0000000000..8c76d60634 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-async/steps.json @@ -0,0 +1,9 @@ +{ + "commands": [ + { + "command": "vp build", + "ignoreOutput": true + }, + "cat dist/index.html | grep 'lazy-plugin-injected' # async vitePlugins() should apply plugins during build" + ] +} diff --git a/packages/cli/snap-tests/vite-plugins-async/vite.config.ts b/packages/cli/snap-tests/vite-plugins-async/vite.config.ts new file mode 100644 index 0000000000..ee0176811a --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-async/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + plugins: async () => { + const { default: myLazyPlugin } = await import('./my-plugin'); + return [myLazyPlugin()]; + }, +}); diff --git a/packages/cli/snap-tests/vite-plugins-run-build/index.html b/packages/cli/snap-tests/vite-plugins-run-build/index.html new file mode 100644 index 0000000000..7febab8c7e --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-run-build/index.html @@ -0,0 +1,8 @@ + + + + + + diff --git a/packages/cli/snap-tests/vite-plugins-run-build/my-plugin.ts b/packages/cli/snap-tests/vite-plugins-run-build/my-plugin.ts new file mode 100644 index 0000000000..c71288346a --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-run-build/my-plugin.ts @@ -0,0 +1,8 @@ +export default function myPlugin() { + return { + name: 'my-run-build-plugin', + transformIndexHtml(html: string) { + return html.replace('', ''); + }, + }; +} diff --git a/packages/cli/snap-tests/vite-plugins-run-build/package.json b/packages/cli/snap-tests/vite-plugins-run-build/package.json new file mode 100644 index 0000000000..a5b7ddf746 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-run-build/package.json @@ -0,0 +1,7 @@ +{ + "name": "vite-plugins-run-build-test", + "private": true, + "scripts": { + "build-task": "vp build" + } +} diff --git a/packages/cli/snap-tests/vite-plugins-run-build/snap.txt b/packages/cli/snap-tests/vite-plugins-run-build/snap.txt new file mode 100644 index 0000000000..4403408fc7 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-run-build/snap.txt @@ -0,0 +1,3 @@ +> vp run build-task +> cat dist/index.html | grep 'run-build-plugin-injected' # vp run build should load plugins from factory + diff --git a/packages/cli/snap-tests/vite-plugins-run-build/steps.json b/packages/cli/snap-tests/vite-plugins-run-build/steps.json new file mode 100644 index 0000000000..2cf00506db --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-run-build/steps.json @@ -0,0 +1,9 @@ +{ + "commands": [ + { + "command": "vp run build-task", + "ignoreOutput": true + }, + "cat dist/index.html | grep 'run-build-plugin-injected' # vp run build should load plugins from factory" + ] +} diff --git a/packages/cli/snap-tests/vite-plugins-run-build/vite.config.ts b/packages/cli/snap-tests/vite-plugins-run-build/vite.config.ts new file mode 100644 index 0000000000..d41886f704 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-run-build/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + plugins: async () => { + const { default: myPlugin } = await import('./my-plugin'); + return [myPlugin()]; + }, +}); diff --git a/packages/cli/snap-tests/vite-plugins-skip-on-lint/heavy-plugin.ts b/packages/cli/snap-tests/vite-plugins-skip-on-lint/heavy-plugin.ts new file mode 100644 index 0000000000..970cc641d0 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-skip-on-lint/heavy-plugin.ts @@ -0,0 +1,5 @@ +throw new Error('Plugins should not be loaded during lint'); + +export default function heavyPlugin() { + return { name: 'heavy-plugin' }; +} diff --git a/packages/cli/snap-tests/vite-plugins-skip-on-lint/package.json b/packages/cli/snap-tests/vite-plugins-skip-on-lint/package.json new file mode 100644 index 0000000000..9212954e86 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-skip-on-lint/package.json @@ -0,0 +1,4 @@ +{ + "name": "vite-plugins-skip-on-lint-test", + "private": true +} diff --git a/packages/cli/snap-tests/vite-plugins-skip-on-lint/snap.txt b/packages/cli/snap-tests/vite-plugins-skip-on-lint/snap.txt new file mode 100644 index 0000000000..19f530d162 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-skip-on-lint/snap.txt @@ -0,0 +1,3 @@ +> vp lint src/ # vp lint should not load plugins (heavy-plugin.ts throws if imported) +Found 0 warnings and 0 errors. +Finished in ms on 1 file with rules using threads. diff --git a/packages/cli/snap-tests/vite-plugins-skip-on-lint/src/index.ts b/packages/cli/snap-tests/vite-plugins-skip-on-lint/src/index.ts new file mode 100644 index 0000000000..c155820bf7 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-skip-on-lint/src/index.ts @@ -0,0 +1 @@ +export const foo = 'bar'; diff --git a/packages/cli/snap-tests/vite-plugins-skip-on-lint/steps.json b/packages/cli/snap-tests/vite-plugins-skip-on-lint/steps.json new file mode 100644 index 0000000000..1107efd99f --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-skip-on-lint/steps.json @@ -0,0 +1,5 @@ +{ + "commands": [ + "vp lint src/ # vp lint should not load plugins (heavy-plugin.ts throws if imported)" + ] +} diff --git a/packages/cli/snap-tests/vite-plugins-skip-on-lint/vite.config.ts b/packages/cli/snap-tests/vite-plugins-skip-on-lint/vite.config.ts new file mode 100644 index 0000000000..b9366f41f3 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-skip-on-lint/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + plugins: async () => { + const { default: heavyPlugin } = await import('./heavy-plugin'); + return [heavyPlugin()]; + }, +}); diff --git a/packages/cli/snap-tests/vite-plugins-sync/index.html b/packages/cli/snap-tests/vite-plugins-sync/index.html new file mode 100644 index 0000000000..7febab8c7e --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-sync/index.html @@ -0,0 +1,8 @@ + + + + + + diff --git a/packages/cli/snap-tests/vite-plugins-sync/my-plugin.ts b/packages/cli/snap-tests/vite-plugins-sync/my-plugin.ts new file mode 100644 index 0000000000..dc921e8186 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-sync/my-plugin.ts @@ -0,0 +1,8 @@ +export default function mySyncPlugin() { + return { + name: 'my-sync-plugin', + transformIndexHtml(html: string) { + return html.replace('', ''); + }, + }; +} diff --git a/packages/cli/snap-tests/vite-plugins-sync/package.json b/packages/cli/snap-tests/vite-plugins-sync/package.json new file mode 100644 index 0000000000..ea51c4e4e1 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-sync/package.json @@ -0,0 +1,4 @@ +{ + "name": "vite-plugins-sync-test", + "private": true +} diff --git a/packages/cli/snap-tests/vite-plugins-sync/snap.txt b/packages/cli/snap-tests/vite-plugins-sync/snap.txt new file mode 100644 index 0000000000..82865c8566 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-sync/snap.txt @@ -0,0 +1,3 @@ +> vp build +> cat dist/index.html | grep 'sync-plugin-injected' # sync vitePlugins() should apply plugins during build + diff --git a/packages/cli/snap-tests/vite-plugins-sync/steps.json b/packages/cli/snap-tests/vite-plugins-sync/steps.json new file mode 100644 index 0000000000..1309b02cdc --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-sync/steps.json @@ -0,0 +1,9 @@ +{ + "commands": [ + { + "command": "vp build", + "ignoreOutput": true + }, + "cat dist/index.html | grep 'sync-plugin-injected' # sync vitePlugins() should apply plugins during build" + ] +} diff --git a/packages/cli/snap-tests/vite-plugins-sync/vite.config.ts b/packages/cli/snap-tests/vite-plugins-sync/vite.config.ts new file mode 100644 index 0000000000..fa0b1f13e5 --- /dev/null +++ b/packages/cli/snap-tests/vite-plugins-sync/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite-plus'; + +import mySyncPlugin from './my-plugin'; + +export default defineConfig({ + plugins: () => [mySyncPlugin()], +}); diff --git a/packages/cli/src/__tests__/index.spec.ts b/packages/cli/src/__tests__/index.spec.ts index ca5757de8b..345d8ddbfd 100644 --- a/packages/cli/src/__tests__/index.spec.ts +++ b/packages/cli/src/__tests__/index.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@voidzero-dev/vite-plus-test'; +import { afterEach, beforeEach, expect, test, vi } from '@voidzero-dev/vite-plus-test'; import { configDefaults, @@ -10,6 +10,20 @@ import { defineProject, } from '../index.js'; +let originalVpCommand: string | undefined; + +beforeEach(() => { + originalVpCommand = process.env.VP_COMMAND; +}); + +afterEach(() => { + if (originalVpCommand === undefined) { + delete process.env.VP_COMMAND; + } else { + process.env.VP_COMMAND = originalVpCommand; + } +}); + test('should keep vitest exports stable', () => { expect(defineConfig).toBeTypeOf('function'); expect(defineProject).toBeTypeOf('function'); @@ -20,124 +34,199 @@ test('should keep vitest exports stable', () => { expect(defaultBrowserPort).toBeDefined(); }); -test('should support lazy loading of plugins', async () => { - const config = await defineConfig({ - lazy: () => Promise.resolve({ plugins: [{ name: 'test' }] }), +test('defineConfig passes through plain plugins array', () => { + process.env.VP_COMMAND = 'build'; + const config = defineConfig({ + plugins: [{ name: 'test-plugin' }], }); expect(config.plugins?.length).toBe(1); }); -test('should merge lazy plugins with existing plugins', async () => { - const config = await defineConfig({ - plugins: [{ name: 'existing' }], - lazy: () => Promise.resolve({ plugins: [{ name: 'lazy' }] }), +test.each(['dev', 'build', 'test', 'preview'])( + 'defineConfig executes sync plugins factory when VP_COMMAND is %s', + (cmd) => { + process.env.VP_COMMAND = cmd; + const config = defineConfig({ + plugins: () => [{ name: 'factory-plugin' }], + }); + expect(config.plugins?.length).toBe(1); + expect((config.plugins?.[0] as { name: string })?.name).toBe('factory-plugin'); + }, +); + +test.each(['dev', 'build', 'test', 'preview'])( + 'defineConfig executes async plugins factory when VP_COMMAND is %s', + async (cmd) => { + process.env.VP_COMMAND = cmd; + const config = defineConfig({ + plugins: async () => { + const plugin = await Promise.resolve({ name: 'async-factory-plugin' }); + return [plugin]; + }, + }); + // Async factory wraps the promise in an array for asyncFlatten + expect(config.plugins?.length).toBe(1); + const resolved = await (config.plugins?.[0] as Promise<{ name: string }[]>); + expect(resolved.length).toBe(1); + expect(resolved[0].name).toBe('async-factory-plugin'); + }, +); + +test.each(['lint', 'fmt', 'check', 'pack', 'install', 'run'])( + 'defineConfig skips plugins factory when VP_COMMAND is %s', + (cmd) => { + process.env.VP_COMMAND = cmd; + const factoryFn = vi.fn(() => [{ name: 'should-not-load' }]); + const config = defineConfig({ + plugins: factoryFn, + }); + expect(factoryFn).not.toHaveBeenCalled(); + expect(config.plugins?.length).toBe(0); + }, +); + +test('defineConfig executes plugins factory when VP_COMMAND is unset', () => { + delete process.env.VP_COMMAND; + const config = defineConfig({ + plugins: () => [{ name: 'no-vp-plugin' }], }); - expect(config.plugins?.length).toBe(2); - expect((config.plugins?.[0] as { name: string })?.name).toBe('existing'); - expect((config.plugins?.[1] as { name: string })?.name).toBe('lazy'); + expect(config.plugins?.length).toBe(1); + expect((config.plugins?.[0] as { name: string })?.name).toBe('no-vp-plugin'); }); -test('should handle lazy with empty plugins array', async () => { - const config = await defineConfig({ - lazy: () => Promise.resolve({ plugins: [] }), - }); - expect(config.plugins?.length).toBe(0); +test('defineConfig handles function config with plugins factory', () => { + process.env.VP_COMMAND = 'build'; + const configFn = defineConfig(() => ({ + plugins: () => [{ name: 'fn-factory-plugin' }], + })); + const config = configFn({ command: 'build', mode: 'production' }); + const plugins = config.plugins as { name: string }[]; + expect(plugins?.length).toBe(1); + expect(plugins?.[0]?.name).toBe('fn-factory-plugin'); }); -test('should handle lazy returning undefined plugins', async () => { - const config = await defineConfig({ - lazy: () => Promise.resolve({}), - }); - expect(config.plugins?.length).toBe(0); +test('defineConfig handles async function config with plugins factory', async () => { + process.env.VP_COMMAND = 'build'; + const configFn = defineConfig(async () => ({ + plugins: () => [{ name: 'async-fn-factory-plugin' }], + })); + const config = await configFn({ command: 'build', mode: 'production' }); + const plugins = config.plugins as { name: string }[]; + expect(plugins?.length).toBe(1); + expect(plugins?.[0]?.name).toBe('async-fn-factory-plugin'); }); -test('should handle Promise config with lazy', async () => { +test('defineConfig handles Promise config with plugins factory', async () => { + process.env.VP_COMMAND = 'build'; const config = await defineConfig( Promise.resolve({ - lazy: () => Promise.resolve({ plugins: [{ name: 'lazy-from-promise' }] }), + plugins: () => [{ name: 'promise-factory-plugin' }], }), ); expect(config.plugins?.length).toBe(1); - expect((config.plugins?.[0] as { name: string })?.name).toBe('lazy-from-promise'); + expect((config.plugins?.[0] as { name: string })?.name).toBe('promise-factory-plugin'); }); -test('should handle Promise config with lazy and existing plugins', async () => { - const config = await defineConfig( - Promise.resolve({ - plugins: [{ name: 'existing' }], - lazy: () => Promise.resolve({ plugins: [{ name: 'lazy' }] }), - }), - ); +// Vite/Vitest PluginOption compatibility tests +// PluginOption = Thenable + +test('defineConfig supports Plugin objects in plugins array', () => { + const config = defineConfig({ + plugins: [{ name: 'plugin-a' }, { name: 'plugin-b' }], + }); expect(config.plugins?.length).toBe(2); - expect((config.plugins?.[0] as { name: string })?.name).toBe('existing'); - expect((config.plugins?.[1] as { name: string })?.name).toBe('lazy'); }); -test('should handle Promise config without lazy', async () => { - const config = await defineConfig( - Promise.resolve({ - plugins: [{ name: 'no-lazy' }], - }), - ); - expect(config.plugins?.length).toBe(1); - expect((config.plugins?.[0] as { name: string })?.name).toBe('no-lazy'); +test('defineConfig supports falsy values in plugins array', () => { + const config = defineConfig({ + plugins: [{ name: 'real-plugin' }, false, null, undefined], + }); + expect(config.plugins?.length).toBe(4); }); -test('should handle function config with lazy', async () => { - const configFn = defineConfig(() => ({ - lazy: () => Promise.resolve({ plugins: [{ name: 'lazy-from-fn' }] }), - })); - expect(typeof configFn).toBe('function'); - const config = await configFn({ command: 'build', mode: 'production' }); +test('defineConfig supports nested plugin arrays', () => { + const config = defineConfig({ + plugins: [[{ name: 'nested-a' }, { name: 'nested-b' }], { name: 'top-level' }], + }); + expect(config.plugins?.length).toBe(2); +}); + +test('defineConfig supports Promise in plugins array', () => { + const config = defineConfig({ + plugins: [Promise.resolve({ name: 'async-plugin' })], + }); expect(config.plugins?.length).toBe(1); - expect((config.plugins?.[0] as { name: string })?.name).toBe('lazy-from-fn'); }); -test('should handle function config with lazy and existing plugins', async () => { - const configFn = defineConfig(() => ({ - plugins: [{ name: 'existing' }], - lazy: () => Promise.resolve({ plugins: [{ name: 'lazy' }] }), - })); - const config = await configFn({ command: 'build', mode: 'production' }); - expect(config.plugins?.length).toBe(2); - expect((config.plugins?.[0] as { name: string })?.name).toBe('existing'); - expect((config.plugins?.[1] as { name: string })?.name).toBe('lazy'); +test('defineConfig supports mixed PluginOption types in array', () => { + const config = defineConfig({ + plugins: [ + { name: 'sync-plugin' }, + false, + Promise.resolve({ name: 'promised-plugin' }), + [{ name: 'nested-plugin' }], + null, + undefined, + ], + }); + expect(config.plugins?.length).toBe(6); +}); + +test('defineConfig supports empty plugins array', () => { + const config = defineConfig({ + plugins: [], + }); + expect(config.plugins?.length).toBe(0); +}); + +test('defineConfig supports config without plugins', () => { + const config = defineConfig({}); + expect(config.plugins).toBeUndefined(); }); -test('should handle function config without lazy', () => { +test('defineConfig supports function config with plain plugins array', () => { const configFn = defineConfig(() => ({ - plugins: [{ name: 'no-lazy' }], + plugins: [{ name: 'fn-plugin' }], })); const config = configFn({ command: 'build', mode: 'production' }); expect(config.plugins?.length).toBe(1); - expect((config.plugins?.[0] as { name: string })?.name).toBe('no-lazy'); }); -test('should handle async function config with lazy', async () => { +test('defineConfig supports async function config with plain plugins array', async () => { const configFn = defineConfig(async () => ({ - lazy: () => Promise.resolve({ plugins: [{ name: 'lazy-from-async-fn' }] }), + plugins: [{ name: 'async-fn-plugin' }], })); const config = await configFn({ command: 'build', mode: 'production' }); expect(config.plugins?.length).toBe(1); - expect((config.plugins?.[0] as { name: string })?.name).toBe('lazy-from-async-fn'); }); -test('should handle async function config with lazy and existing plugins', async () => { - const configFn = defineConfig(async () => ({ - plugins: [{ name: 'existing' }], - lazy: () => Promise.resolve({ plugins: [{ name: 'lazy' }] }), - })); - const config = await configFn({ command: 'build', mode: 'production' }); - expect(config.plugins?.length).toBe(2); - expect((config.plugins?.[0] as { name: string })?.name).toBe('existing'); - expect((config.plugins?.[1] as { name: string })?.name).toBe('lazy'); +test('defineConfig supports vitest plugin with configureVitest hook', () => { + const config = defineConfig({ + plugins: [ + { + name: 'vitest-plugin', + configureVitest() { + // vitest plugin hook + }, + }, + ], + }); + expect(config.plugins?.length).toBe(1); + expect((config.plugins?.[0] as { name: string })?.name).toBe('vitest-plugin'); }); -test('should handle async function config without lazy', async () => { - const configFn = defineConfig(async () => ({ - plugins: [{ name: 'no-lazy' }], - })); - const config = await configFn({ command: 'build', mode: 'production' }); +test('defineConfig supports vitest plugin with configureVitest in plugins factory', () => { + process.env.VP_COMMAND = 'test'; + const config = defineConfig({ + plugins: () => [ + { + name: 'vitest-factory-plugin', + configureVitest() { + // vitest plugin hook + }, + }, + ], + }); expect(config.plugins?.length).toBe(1); - expect((config.plugins?.[0] as { name: string })?.name).toBe('no-lazy'); + expect((config.plugins?.[0] as { name: string })?.name).toBe('vitest-factory-plugin'); }); diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 056e4c6846..5aecc7905e 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -45,6 +45,7 @@ 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 6b5a8a8acd..bddf117887 100644 --- a/packages/cli/src/define-config.ts +++ b/packages/cli/src/define-config.ts @@ -1,8 +1,7 @@ -import type { UserConfig } from '@voidzero-dev/vite-plus-core'; +import type { PluginOption, UserConfig } from '@voidzero-dev/vite-plus-core'; import { defineConfig as viteDefineConfig, type ConfigEnv, - type Plugin as VitestPlugin, } from '@voidzero-dev/vite-plus-test/config'; import type { OxfmtConfig } from 'oxfmt'; import type { OxlintConfig } from 'oxlint'; @@ -25,75 +24,78 @@ declare module '@voidzero-dev/vite-plus-core' { run?: RunConfig; staged?: StagedConfig; - - // temporary solution to load plugins lazily - // We need to support this in the upstream vite - lazy?: () => Promise<{ - plugins?: VitestPlugin[]; - }>; } } -type ViteUserConfigFnObject = (env: ConfigEnv) => UserConfig; -type ViteUserConfigFnPromise = (env: ConfigEnv) => Promise; -type ViteUserConfigFn = (env: ConfigEnv) => UserConfig | Promise; -type ViteUserConfigExport = - | UserConfig - | Promise - | ViteUserConfigFnObject - | ViteUserConfigFnPromise - | ViteUserConfigFn; - -export function defineConfig(config: UserConfig): UserConfig; -export function defineConfig(config: Promise): Promise; -export function defineConfig(config: ViteUserConfigFnObject): ViteUserConfigFnObject; -export function defineConfig(config: ViteUserConfigFnPromise): ViteUserConfigFnPromise; -export function defineConfig(config: ViteUserConfigExport): ViteUserConfigExport; - -export function defineConfig(config: ViteUserConfigExport): ViteUserConfigExport { - if (typeof config === 'object') { - if (config instanceof Promise) { - return config.then((config) => { - if (config.lazy) { - return config.lazy().then(({ plugins }) => - viteDefineConfig({ - ...config, - plugins: [...(config.plugins || []), ...(plugins || [])], - }), - ); - } - return viteDefineConfig(config); - }); - } else if (config.lazy) { - return config.lazy().then(({ plugins }) => - viteDefineConfig({ - ...config, - plugins: [...(config.plugins || []), ...(plugins || [])], - }), - ); - } - } else if (typeof config === 'function') { - return viteDefineConfig((env) => { +type PluginsFactory = () => PluginOption[] | Promise; + +type VPUserConfig = Omit & { + plugins?: PluginOption[] | PluginsFactory; +}; + +type VPUserConfigFnObject = (env: ConfigEnv) => VPUserConfig; +type VPUserConfigFnPromise = (env: ConfigEnv) => Promise; +type VPUserConfigFn = (env: ConfigEnv) => VPUserConfig | Promise; +type VPUserConfigExport = + | VPUserConfig + | Promise + | VPUserConfigFnObject + | VPUserConfigFnPromise + | VPUserConfigFn; + +export function defineConfig(config: VPUserConfig): UserConfig; +export function defineConfig(config: Promise): Promise; +export function defineConfig(config: VPUserConfigFnObject): VPUserConfigFnObject; +export function defineConfig(config: VPUserConfigFnPromise): VPUserConfigFnPromise; +export function defineConfig(config: VPUserConfigExport): VPUserConfigExport; + +export function defineConfig(config: VPUserConfigExport): VPUserConfigExport { + if (typeof config === 'function') { + return viteDefineConfig((env: ConfigEnv) => { const c = config(env); if (c instanceof Promise) { - return c.then((v) => { - if (v.lazy) { - return v - .lazy() - .then(({ plugins }) => - viteDefineConfig({ ...v, plugins: [...(v.plugins || []), ...(plugins || [])] }), - ); - } - return v; - }); - } - if (c.lazy) { - return c - .lazy() - .then(({ plugins }) => ({ ...c, plugins: [...(c.plugins || []), ...(plugins || [])] })); + return c.then((v) => transformConfig(v)); } - return c; + return transformConfig(c); }); } - return viteDefineConfig(config); + if (typeof config === 'object') { + if (config instanceof Promise) { + return config.then((c) => viteDefineConfig(transformConfig(c))); + } + return viteDefineConfig(transformConfig(config)); + } + return viteDefineConfig(config as UserConfig); +} + +const VITE_COMMANDS = new Set(['dev', 'build', 'test', 'preview']); + +function shouldLoadPlugins(): boolean { + const cmd = process.env.VP_COMMAND; + if (!cmd) { + return true; + } + return VITE_COMMANDS.has(cmd); +} + +function resolvePlugins(plugins: PluginOption[] | PluginsFactory): PluginOption[] { + if (typeof plugins !== 'function') { + return plugins; + } + if (!shouldLoadPlugins()) { + return []; + } + + const result = plugins(); + if (result instanceof Promise) { + return [result]; + } + return result; +} + +function transformConfig(config: VPUserConfig): UserConfig { + if (config.plugins && typeof config.plugins === 'function') { + return { ...config, plugins: resolvePlugins(config.plugins) } as UserConfig; + } + return config as UserConfig; } diff --git a/packages/tools/src/__tests__/utils.spec.ts b/packages/tools/src/__tests__/utils.spec.ts index f4b1889cb4..47cafd5630 100644 --- a/packages/tools/src/__tests__/utils.spec.ts +++ b/packages/tools/src/__tests__/utils.spec.ts @@ -116,8 +116,6 @@ Done in 171ms using pnpm v10.16.1 describe.skipIf(process.platform !== 'win32')('Windows cwd replacement', () => { test('mixed-separator cwd matches all-backslash output', () => { - // Simulates the CI failure: cwd has mixed separators (template literal), - // but Vite outputs all-backslash paths (path.resolve) const cwd = 'C:\\Users\\RUNNER~1\\AppData\\Local\\Temp/vite-plus-test-abc/command-staged-broken-config'; const output = @@ -127,6 +125,14 @@ Done in 171ms using pnpm v10.16.1 ); }); + test('mixed-separator cwd matches all-forward-slash output', () => { + const cwd = + 'C:\\Users\\RUNNER~1\\AppData\\Local\\Temp/vite-plus-test-abc/vite-plugins-async-test'; + const output = + ' RUN C:/Users/RUNNER~1/AppData/Local/Temp/vite-plus-test-abc/vite-plugins-async-test\n'; + expect(replaceUnstableOutput(output, cwd)).toBe(' RUN \n'); + }); + test('all-backslash cwd matches all-backslash output', () => { const cwd = 'C:\\Users\\runner\\project'; const output = 'error in C:\\Users\\runner\\project\\src\\main.ts'; diff --git a/packages/tools/src/utils.ts b/packages/tools/src/utils.ts index 4a2856e46b..67d061d1a3 100644 --- a/packages/tools/src/utils.ts +++ b/packages/tools/src/utils.ts @@ -15,14 +15,16 @@ export function replaceUnstableOutput(output: string, cwd?: string) { if (cwd) { // On Windows, cwd may have mixed separators (from template literals like `${tmp}/name`) - // while output uses all-backslash paths (from path.resolve()). Replace the all-backslash - // form of each path token, with trailing separator first so the separator after the - // placeholder is normalized to forward slash. + // while output may use all-backslash OR all-forward-slash paths depending on the tool. + // Try all three forms: all-backslash, all-forward-slash, and original mixed. const replacePathToken = (rawPath: string, placeholder: string) => { if (process.platform === 'win32') { const backslash = rawPath.replaceAll('/', '\\'); output = output.replaceAll(backslash + '\\', placeholder + '/'); output = output.replaceAll(backslash, placeholder); + const forwardslash = rawPath.replaceAll('\\', '/'); + output = output.replaceAll(forwardslash + '/', placeholder + '/'); + output = output.replaceAll(forwardslash, placeholder); } output = output.replaceAll(rawPath, placeholder); }; @@ -40,6 +42,7 @@ export function replaceUnstableOutput(output: string, cwd?: string) { // Pattern: backslash between alphanumeric/dot/underscore/hyphen chars output = output.replaceAll(/([a-zA-Z0-9._-])\\([a-zA-Z0-9._-])/g, '$1/$2'); } + return ( output // semver version