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('
+
+
+