From be6e9a69edcc4bc9e436a8e56189bbaf36d50ff7 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Wed, 14 Oct 2026 12:22:55 +0900 Subject: [PATCH] fix(cli): ignore pnpm node_modules/.modules.yaml as a task input Vite reads node_modules/.modules.yaml to find the pnpm virtual store when it resolves server options. Vite Task records that read as an input. pnpm rewrites the file on every install, because prunedAt changes, so every task misses its cache after a fresh install. Add a plugin that calls ignoreInput for the file in the config hook, before Vite reads it. --- .../__tests__/define-config-plugins.spec.ts | 71 ++++++++++++++++--- packages/cli/src/__tests__/index.spec.ts | 4 +- packages/cli/src/define-config.ts | 20 ++++++ 3 files changed, 86 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/__tests__/define-config-plugins.spec.ts b/packages/cli/src/__tests__/define-config-plugins.spec.ts index 39939a00a3..da5d112e97 100644 --- a/packages/cli/src/__tests__/define-config-plugins.spec.ts +++ b/packages/cli/src/__tests__/define-config-plugins.spec.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -29,6 +29,7 @@ vi.mock('@voidzero-dev/vite-task-client', () => taskClientMocks); const RESOLVER_PLUGIN_NAME = 'vite-plus:vitest-resolver'; const COVERAGE_GUARD_PLUGIN_NAME = 'vite-plus:coverage-version-guard'; const CACHE_PATHS_PLUGIN_NAME = 'vite-plus:vitest-cache-paths'; +const MODULES_YAML_PLUGIN_NAME = 'vite-plus:pnpm-modules-yaml'; function pluginName(p: unknown): string | undefined { if (p && typeof p === 'object' && 'name' in p && typeof p.name === 'string') { @@ -46,7 +47,8 @@ describe('defineConfig project plugin injection', () => { expect(pluginName(result.plugins[1])).toBe(AUTO_INLINE_PLUGIN_NAME); expect(pluginName(result.plugins[2])).toBe(COVERAGE_GUARD_PLUGIN_NAME); expect(pluginName(result.plugins[3])).toBe(CACHE_PATHS_PLUGIN_NAME); - expect(pluginName(result.plugins[4])).toBe('user-existing-root-plugin'); + expect(pluginName(result.plugins[4])).toBe(MODULES_YAML_PLUGIN_NAME); + expect(pluginName(result.plugins[5])).toBe('user-existing-root-plugin'); }); it('injects resolver + auto-inline plugins into an inline-object project entry, preserving existing plugins', () => { @@ -69,9 +71,10 @@ describe('defineConfig project plugin injection', () => { expect(pluginName(project.plugins[1])).toBe(AUTO_INLINE_PLUGIN_NAME); expect(pluginName(project.plugins[2])).toBe(COVERAGE_GUARD_PLUGIN_NAME); expect(pluginName(project.plugins[3])).toBe(CACHE_PATHS_PLUGIN_NAME); - expect(pluginName(project.plugins[4])).toBe('user-unit-project-plugin'); + expect(pluginName(project.plugins[4])).toBe(MODULES_YAML_PLUGIN_NAME); + expect(pluginName(project.plugins[5])).toBe('user-unit-project-plugin'); // Sanity: the existing plugin reference is preserved (clone shallow-copies the array). - expect(project.plugins[4]).toBe(existing); + expect(project.plugins[5]).toBe(existing); }); it('injects plugins into the return value of a function-shaped project entry', () => { @@ -96,7 +99,8 @@ describe('defineConfig project plugin injection', () => { expect(pluginName(resolved.plugins[1])).toBe(AUTO_INLINE_PLUGIN_NAME); expect(pluginName(resolved.plugins[2])).toBe(COVERAGE_GUARD_PLUGIN_NAME); expect(pluginName(resolved.plugins[3])).toBe(CACHE_PATHS_PLUGIN_NAME); - expect(pluginName(resolved.plugins[4])).toBe('user-fn-project-plugin'); + expect(pluginName(resolved.plugins[4])).toBe(MODULES_YAML_PLUGIN_NAME); + expect(pluginName(resolved.plugins[5])).toBe('user-fn-project-plugin'); }); it('passes string-glob project entries through unchanged', () => { @@ -123,11 +127,12 @@ describe('defineConfig project plugin injection', () => { const project = result.test.projects[0] as { plugins: unknown[]; test: { name: string } }; expect(project.test.name).toBe('no-plugins'); - expect(project.plugins).toHaveLength(4); + expect(project.plugins).toHaveLength(5); expect(pluginName(project.plugins[0])).toBe(RESOLVER_PLUGIN_NAME); expect(pluginName(project.plugins[1])).toBe(AUTO_INLINE_PLUGIN_NAME); expect(pluginName(project.plugins[2])).toBe(COVERAGE_GUARD_PLUGIN_NAME); expect(pluginName(project.plugins[3])).toBe(CACHE_PATHS_PLUGIN_NAME); + expect(pluginName(project.plugins[4])).toBe(MODULES_YAML_PLUGIN_NAME); }); it.each([undefined, true])('inherits root plugins with extends %s', (extendsRoot) => { @@ -160,6 +165,7 @@ describe('defineConfig project plugin injection', () => { AUTO_INLINE_PLUGIN_NAME, COVERAGE_GUARD_PLUGIN_NAME, CACHE_PATHS_PLUGIN_NAME, + MODULES_YAML_PLUGIN_NAME, ]); }); @@ -170,6 +176,7 @@ describe('defineConfig project plugin injection', () => { AUTO_INLINE_PLUGIN_NAME, COVERAGE_GUARD_PLUGIN_NAME, CACHE_PATHS_PLUGIN_NAME, + MODULES_YAML_PLUGIN_NAME, 'user', ]); }); @@ -180,9 +187,9 @@ describe('defineConfig project plugin injection', () => { projects: [{ extends: false, test: { name: 'child' } }, { test: { name: 'inherited' } }], }, }); - expect(config.plugins).toHaveLength(4); + expect(config.plugins).toHaveLength(5); const [independent, inherited] = config.test!.projects! as { plugins?: unknown[] }[]; - expect(independent.plugins).toHaveLength(4); + expect(independent.plugins).toHaveLength(5); expect(inherited.plugins).toBeUndefined(); }); }); @@ -583,6 +590,54 @@ describe('defineConfig vitest cache paths plugin', () => { }); }); +function runConfigHook(root: string | undefined) { + const result = defineConfig({ root }) as { plugins: unknown[] }; + const plugin = result.plugins.find( + (p): p is { config: (config: { root?: string }) => void } => + pluginName(p) === MODULES_YAML_PLUGIN_NAME, + ); + if (!plugin) { + throw new Error('pnpm modules.yaml plugin not found'); + } + plugin.config({ root }); +} + +describe('defineConfig pnpm modules.yaml plugin', () => { + afterEach(() => { + taskClientMocks.ignoreInput.mockClear(); + }); + + it('ignores the workspace node_modules/.modules.yaml before Vite reads it', () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'vp-modules-yaml-'))); + writeFileSync(join(root, 'package.json'), '{"name":"probe","private":true}\n'); + try { + runConfigHook(root); + expect(taskClientMocks.ignoreInput).toHaveBeenCalledTimes(1); + expect(taskClientMocks.ignoreInput).toHaveBeenCalledWith( + join(root, 'node_modules', '.modules.yaml'), + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('anchors at the workspace root when the Vite root is a package inside it', () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'vp-modules-yaml-ws-'))); + writeFileSync(join(root, 'package.json'), '{"name":"ws","private":true}\n'); + writeFileSync(join(root, 'pnpm-workspace.yaml'), 'packages:\n - pkgs/*\n'); + mkdirSync(join(root, 'pkgs', 'app'), { recursive: true }); + writeFileSync(join(root, 'pkgs', 'app', 'package.json'), '{"name":"app"}\n'); + try { + runConfigHook(join(root, 'pkgs', 'app')); + expect(taskClientMocks.ignoreInput).toHaveBeenCalledWith( + join(root, 'node_modules', '.modules.yaml'), + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + /** Extract the coverage-version-guard plugin from a fresh defineConfig. */ function getCoverageGuard(): { configureVitest: (ctx: unknown) => void } { const result = defineConfig({}) as { plugins: unknown[] }; diff --git a/packages/cli/src/__tests__/index.spec.ts b/packages/cli/src/__tests__/index.spec.ts index 3c7dbbcb3b..749c618632 100644 --- a/packages/cli/src/__tests__/index.spec.ts +++ b/packages/cli/src/__tests__/index.spec.ts @@ -132,6 +132,7 @@ const RESOLVER_PLUGIN_NAME = 'vite-plus:vitest-resolver'; const AUTO_INLINE_PLUGIN_NAME = 'vite-plus:auto-inline-matcher-deps'; const COVERAGE_GUARD_PLUGIN_NAME = 'vite-plus:coverage-version-guard'; const CACHE_PATHS_PLUGIN_NAME = 'vite-plus:vitest-cache-paths'; +const MODULES_YAML_PLUGIN_NAME = 'vite-plus:pnpm-modules-yaml'; const userPlugins = (plugins: unknown): unknown[] => { expect(Array.isArray(plugins)).toBe(true); const arr = plugins as unknown[]; @@ -139,7 +140,8 @@ const userPlugins = (plugins: unknown): unknown[] => { expect((arr[1] as { name?: string })?.name).toBe(AUTO_INLINE_PLUGIN_NAME); expect((arr[2] as { name?: string })?.name).toBe(COVERAGE_GUARD_PLUGIN_NAME); expect((arr[3] as { name?: string })?.name).toBe(CACHE_PATHS_PLUGIN_NAME); - return arr.slice(4); + expect((arr[4] as { name?: string })?.name).toBe(MODULES_YAML_PLUGIN_NAME); + return arr.slice(5); }; // lazyPlugins type compatibility tests — these verify at compile time that diff --git a/packages/cli/src/define-config.ts b/packages/cli/src/define-config.ts index 2056450591..56dc433ce2 100644 --- a/packages/cli/src/define-config.ts +++ b/packages/cli/src/define-config.ts @@ -830,6 +830,24 @@ function vitePlusVitestCachePathsPlugin(): PluginOption { } as PluginOption; } +/** + * Vite reads `/node_modules/.modules.yaml` while resolving the + * server options, to find the pnpm virtual store. pnpm rewrites that file on + * every install (`prunedAt` changes), so under `vp run` the tracked read makes + * every task miss its cache after a fresh install. Tell the runner the file is + * not an input. The `config` hook runs before Vite resolves the server + * options. The call is a no-op outside `vp run`. + */ +function vitePlusPnpmModulesYamlPlugin(): PluginOption { + return { + name: 'vite-plus:pnpm-modules-yaml', + config(config) { + const root = resolve(config.root ?? process.cwd()); + ignoreInput(join(searchForWorkspaceRoot(root), 'node_modules', '.modules.yaml')); + }, + }; +} + /** * Inject the vitest resolver plugin, the auto-inline matcher plugin, the * coverage version guard, and the cache-paths plugin into a single inline @@ -845,6 +863,7 @@ const VITE_PLUS_TEST_PLUGIN_NAMES: ReadonlySet = new Set([ 'vite-plus:auto-inline-matcher-deps', 'vite-plus:coverage-version-guard', 'vite-plus:vitest-cache-paths', + 'vite-plus:pnpm-modules-yaml', ]); function withoutVitePlusTestPlugins(plugin: PluginOption): PluginOption { @@ -874,6 +893,7 @@ function injectPluginIntoInlineConfig< vitePlusAutoInlineMatcherPlugin(), vitePlusCoverageVersionGuardPlugin(), vitePlusVitestCachePathsPlugin(), + vitePlusPnpmModulesYamlPlugin(), ...(config.plugins ?? []).filter(keepUserPlugin).map(withoutVitePlusTestPlugins), ], };