Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/guide/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
9 changes: 6 additions & 3 deletions packages/cli/bin/oxfmt
Original file line number Diff line number Diff line change
Expand Up @@ -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);
9 changes: 6 additions & 3 deletions packages/cli/bin/oxlint
Original file line number Diff line number Diff line change
Expand Up @@ -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);
8 changes: 1 addition & 7 deletions packages/cli/binding/src/cli/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,7 @@ impl SubcommandResolver {
resolved_vite_config: Option<&ResolvedUniversalViteConfig>,
envs: &Arc<FxHashMap<Arc<OsStr>, Arc<OsStr>>>,
) -> anyhow::Result<ResolvedSubcommand> {
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(
Expand Down
17 changes: 0 additions & 17 deletions packages/cli/binding/src/cli/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/snap-tests/vite-plugins-exec-build/build.mjs
Original file line number Diff line number Diff line change
@@ -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' });
8 changes: 8 additions & 0 deletions packages/cli/snap-tests/vite-plugins-exec-build/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<!doctype html>
<html>
<body>
<script type="module">
console.log('hello');
</script>
</body>
</html>
8 changes: 8 additions & 0 deletions packages/cli/snap-tests/vite-plugins-exec-build/my-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function myPlugin() {
return {
name: 'my-exec-build-plugin',
transformIndexHtml(html: string) {
return html.replace('</body>', '<!-- exec-build-plugin-injected --></body>');
},
};
}
4 changes: 4 additions & 0 deletions packages/cli/snap-tests/vite-plugins-exec-build/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "vite-plugins-exec-build-test",
"private": true
}
3 changes: 3 additions & 0 deletions packages/cli/snap-tests/vite-plugins-exec-build/snap.txt
Original file line number Diff line number Diff line change
@@ -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
<!-- exec-build-plugin-injected --></body>
9 changes: 9 additions & 0 deletions packages/cli/snap-tests/vite-plugins-exec-build/steps.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
Original file line number Diff line number Diff line change
@@ -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()];
}),
});
Original file line number Diff line number Diff line change
@@ -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' });
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<!doctype html>
<html>
<body>
<script type="module">
console.log('hello');
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function myPlugin() {
return {
name: 'my-run-verbatim-plugin',
transformIndexHtml(html: string) {
return html.replace('</body>', '<!-- run-verbatim-plugin-injected --></body>');
},
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "vite-plugins-run-verbatim-build-test",
"private": true,
"scripts": {
"build": "node build.mjs"
}
}
Original file line number Diff line number Diff line change
@@ -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
<!-- run-verbatim-plugin-injected --></body>
Original file line number Diff line number Diff line change
@@ -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"
]
}
Original file line number Diff line number Diff line change
@@ -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()];
}),
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
throw new Error('Plugins should not be loaded during vp format');

export default function heavyPlugin() {
return { name: 'heavy-plugin' };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "vite-plugins-skip-on-format-test",
"private": true
}
3 changes: 3 additions & 0 deletions packages/cli/snap-tests/vite-plugins-skip-on-format/snap.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
> vp format src/ # the `format` alias should not load plugins (heavy-plugin.ts throws if imported)
Finished in <variable>ms on 1 files using <variable> threads.
No config found, using defaults. Please add a config file or try `vp fmt --init` if needed.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const foo = 'bar';
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"commands": [
"vp format src/ # the `format` alias should not load plugins (heavy-plugin.ts throws if imported)"
]
}
Original file line number Diff line number Diff line change
@@ -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()];
}),
});
100 changes: 65 additions & 35 deletions packages/cli/src/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest';

import { withConfigMetadataResolution } from '../define-config.ts';
import {
configDefaults,
coverageConfigDefaults,
Expand All @@ -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;
}
});

Expand All @@ -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<typeof lazyPlugins>;
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<void>((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];
Expand All @@ -74,16 +108,15 @@ 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' }];
});
expect(result).toBeUndefined();
});

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);
Expand All @@ -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' }]),
Expand All @@ -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 () => {
Expand All @@ -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' }]),
Expand All @@ -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(() => [
{
Expand Down
Loading
Loading