From 13bbd50a95c6e7f60e2a686619456f294213e016 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 24 Jun 2026 15:58:14 +0800 Subject: [PATCH 01/11] test(ci): add "Verify single dependency instances" to reproduce #1932 Under pnpm, `vp create vite:monorepo` resolves vite-plus / vite / vitest to 2 instances each (the workspace root and packages/utils auto-install an upstream vite to satisfy vitest's peer, since they have no direct vite for the override to bind). This step asserts a single instance via `vp why` and is expected to fail for the monorepo + pnpm matrix entry, reproducing the bug before the fix. Refs #1932 --- .github/workflows/test-vp-create.yml | 35 ++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.github/workflows/test-vp-create.yml b/.github/workflows/test-vp-create.yml index 29b2c3f31c..4e25c0b715 100644 --- a/.github/workflows/test-vp-create.yml +++ b/.github/workflows/test-vp-create.yml @@ -300,6 +300,41 @@ jobs: esac fi + - name: Verify single dependency instances (pnpm only) + if: matrix.package-manager == 'pnpm' + working-directory: ${{ runner.temp }}/test-project + run: | + # The `vite` override must dedupe vite-plus / vite / vitest to a single + # instance each. When a peer variation splits the graph (e.g. an upstream + # `vite` auto-installed to satisfy vitest's peer in a package without a + # direct `vite` dep), `vp why` reports multiple instances. Detection: + # - vite-plus / vitest: a split prints "Found 1 version, N instances of ". + # - vite: it is overridden to @voidzero-dev/vite-plus-core, so a clean tree + # only summarises that package; a standalone upstream copy adds a + # "Found version(s) of vite" line. + # Tracks voidzero-dev/vite-plus#1932 (currently expected to fail for monorepo). + fail=0 + check() { + pkg="$1"; pattern="$2" + out=$(vp why "$pkg" 2>&1) + found=$(echo "$out" | grep '^Found' || true) + echo "[$pkg]"; echo "$found" + if echo "$found" | grep -qE "$pattern"; then + echo "✗ $pkg is not a single instance (override did not dedupe under pnpm)" + fail=1 + else + echo "✓ $pkg single instance" + fi + } + check vite-plus 'instances of vite-plus' + check vitest 'instances of vitest' + check vite 'of vite$' + if [ "$fail" -ne 0 ]; then + echo "Expected vite-plus, vite, and vitest to each resolve to a single instance." + exit 1 + fi + echo "✓ vite-plus, vite, vitest are all single instances" + - name: Verify local tgz packages installed working-directory: ${{ runner.temp }}/test-project run: | From d351a468b3d1e9386f01e9179280b236b6895806 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 24 Jun 2026 16:12:12 +0800 Subject: [PATCH 02/11] test(ci): dump full `vp why` tree when an instance check fails Print the complete `vp why ` output (not just the summary lines) for any package that resolves to multiple instances, so the reproduction job shows the full dependency tree that explains the split. Refs #1932 --- .github/workflows/test-vp-create.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test-vp-create.yml b/.github/workflows/test-vp-create.yml index 4e25c0b715..e9b6ae63c9 100644 --- a/.github/workflows/test-vp-create.yml +++ b/.github/workflows/test-vp-create.yml @@ -321,6 +321,9 @@ jobs: echo "[$pkg]"; echo "$found" if echo "$found" | grep -qE "$pattern"; then echo "✗ $pkg is not a single instance (override did not dedupe under pnpm)" + echo "----- full \`vp why $pkg\` output -----" + echo "$out" + echo "---------------------------------------" fail=1 else echo "✓ $pkg single instance" From d8c3e3e68c9628535926aeb78afd3dc9659a6312 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 24 Jun 2026 16:28:00 +0800 Subject: [PATCH 03/11] fix(migrate): add a direct vite dep for pnpm vite-plus consumers Under pnpm, any importer that depends on vite-plus (which bundles vitest) needs a direct `vite` dep so the `vite` override binds vitest's required `vite` peer to @voidzero-dev/vite-plus-core. Without a direct edge, pnpm's autoInstallPeers installs a separate upstream vite to satisfy the peer, splitting vite-plus / vite / vitest into duplicate instances (the extra vite also lacks vite's vite-task-client integration, breaking the vp test cache). Mirror the override as a direct vite devDep in the workspace root (rewriteRootWorkspacePackageJson) and every package (rewritePackageJson), pnpm-only. npm/yarn/bun redirect the transitive/peer vite via root overrides/resolutions, so they are untouched. Closes #1932 --- .../__snapshots__/migrator.spec.ts.snap | 2 + .../src/migration/__tests__/migrator.spec.ts | 69 ++++++++++++++++--- packages/cli/src/migration/migrator.ts | 42 +++++++++++ 3 files changed, 105 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap b/packages/cli/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap index ce8b72597f..66f6192f1f 100644 --- a/packages/cli/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap +++ b/packages/cli/src/migration/__tests__/__snapshots__/migrator.spec.ts.snap @@ -17,6 +17,7 @@ exports[`rewritePackageJson > should rewrite devDependencies and dependencies on "foo": "1.0.0", }, "devDependencies": { + "vite": "catalog:", "vite-plus": "catalog:", }, } @@ -28,6 +29,7 @@ exports[`rewritePackageJson > should rewrite devDependencies and dependencies on "foo": "1.0.0", }, "devDependencies": { + "vite": "npm:@voidzero-dev/vite-plus-core@latest", "vite-plus": "latest", }, } diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index d240d78559..d6411aff59 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -202,6 +202,52 @@ describe('rewritePackageJson', () => { } }); + // #1932: under pnpm, any package that depends on vite-plus needs a direct + // `vite` so vitest's required `vite` peer binds to the override + // (@voidzero-dev/vite-plus-core); otherwise pnpm's autoInstallPeers installs a + // second upstream vite and splits vite-plus / vite / vitest into duplicates. + it('adds a direct `vite` devDep when a package depends on vite-plus under pnpm (#1932)', () => { + // monorepo sub-package -> catalog: (catalog.vite is written by rewriteCatalog) + const sub: { devDependencies: Record } = { + devDependencies: { 'vite-plus': 'catalog:' }, + }; + rewritePackageJson(sub, PackageManager.pnpm, true); + expect(sub.devDependencies.vite).toBe('catalog:'); + + // standalone (no catalog) -> mirror the override target directly + const standalone: { devDependencies: Record } = { + devDependencies: { 'vite-plus': 'latest' }, + }; + rewritePackageJson(standalone, PackageManager.pnpm); + expect(standalone.devDependencies.vite).toBe(VITE_PLUS_OVERRIDE_PACKAGES.vite); + }); + + it('does not add a direct `vite` for npm/yarn/bun (they dedupe via overrides/resolutions) (#1932)', () => { + for (const pm of [PackageManager.npm, PackageManager.yarn, PackageManager.bun]) { + const pkg: { devDependencies: Record } = { + devDependencies: { 'vite-plus': pm === PackageManager.npm ? '^0.1.20' : 'catalog:' }, + }; + rewritePackageJson(pkg, pm, true); + expect(pkg.devDependencies.vite).toBeUndefined(); + } + }); + + it('does not add `vite` for a pnpm package that does not depend on vite-plus (#1932)', () => { + const pkg: { devDependencies: Record } = { + devDependencies: { typescript: '^5' }, + }; + rewritePackageJson(pkg, PackageManager.pnpm, true); + expect(pkg.devDependencies.vite).toBeUndefined(); + }); + + it('keeps an existing direct `vite` instead of overwriting it under pnpm (#1932)', () => { + const pkg: { devDependencies: Record } = { + devDependencies: { 'vite-plus': 'catalog:', vite: 'catalog:vite7' }, + }; + rewritePackageJson(pkg, PackageManager.pnpm, true); + expect(pkg.devDependencies.vite).toBe('catalog:vite7'); + }); + it('preserves protocol-prefixed vite-plus specs (catalog:named, workspace:, link:, github:) in catalog-supporting monorepos', async () => { for (const existing of [ 'catalog:next', @@ -425,19 +471,26 @@ describe('rewritePackageJson', () => { expect(pkg.devDependencies).not.toHaveProperty('vite'); }); - it('does not inject a direct vite devDependency for non-npm provider projects', async () => { - // pnpm/yarn use symlink/PnP layouts that already expose the `vite` override - // to the provider subtree, so the npm-only direct-`vite` workaround must not - // fire for them. - const pkg = { + it('injects a direct vite devDependency for pnpm projects depending on vite-plus, but not yarn/bun (#1932)', async () => { + // pnpm needs a direct `vite` so vitest's `vite` peer binds to the override + // instead of pnpm auto-installing a separate upstream vite (#1932). yarn/bun + // redirect the transitive/peer vite via resolutions/overrides, so they do not + // get a direct `vite` here (the bun workspace root is handled separately). + const makePkg = () => ({ devDependencies: { '@vitest/browser-playwright': '^4.0.0', playwright: '^1.60.0', vitest: '^4.0.0', }, - }; - rewritePackageJson(pkg, PackageManager.pnpm); - expect(pkg.devDependencies).not.toHaveProperty('vite'); + }); + const pnpmPkg = makePkg(); + rewritePackageJson(pnpmPkg, PackageManager.pnpm); + expect(pnpmPkg.devDependencies).toHaveProperty('vite', VITE_PLUS_OVERRIDE_PACKAGES.vite); + for (const pm of [PackageManager.yarn, PackageManager.bun]) { + const pkg = makePkg(); + rewritePackageJson(pkg, pm); + expect(pkg.devDependencies).not.toHaveProperty('vite'); + } }); it('normalizes a pre-existing direct vite dep to the override target for an npm provider project', async () => { diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index ef6e6c94f1..7b55400d14 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -2985,6 +2985,22 @@ function rewriteRootWorkspacePackageJson( : 'catalog:', }; } + // #1932: under pnpm the root also depends on vite-plus (-> vitest), so it + // needs a direct `vite` for the override to bind vitest's peer; otherwise + // pnpm auto-installs a separate upstream vite and splits the graph into + // duplicate instances. Mirror the override as a direct devDep, like the bun + // branch above. pnpm-only (npm/yarn/bun dedupe via overrides/resolutions). + if ( + packageManager === PackageManager.pnpm && + !pkg.dependencies?.vite && + !pkg.devDependencies?.vite && + !pkg.optionalDependencies?.vite + ) { + pkg.devDependencies = { + ...pkg.devDependencies, + vite: getCatalogDependencySpec(undefined, VITE_PLUS_OVERRIDE_PACKAGES.vite, true), + }; + } return pkg; }); @@ -4077,6 +4093,32 @@ export function rewritePackageJson( [VITE_PLUS_NAME]: canonicalVitePlusSpec, }; } + // #1932: under pnpm, any package that depends on `vite-plus` (which bundles + // `vitest`) needs a DIRECT `vite` devDep so the `vite` override binds vitest's + // required `vite` peer to @voidzero-dev/vite-plus-core. Without a direct edge, + // pnpm's `autoInstallPeers` fabricates a separate upstream `vite` to satisfy the + // peer, splitting vite-plus / vite / vitest into duplicate instances (a 2nd vite + // also misses vite's `@voidzero-dev/vite-task-client` integration, breaking the + // `vp test` cache). npm/yarn/bun redirect the transitive/peer vite via root + // overrides/resolutions (and drop the aliased vite), so this is pnpm-only — + // mirroring the bun root-package fix in `rewriteRootWorkspacePackageJson`. + if (packageManager === PackageManager.pnpm && VITE_PLUS_OVERRIDE_PACKAGES.vite) { + const dependsOnVitePlus = + !!pkg.dependencies?.[VITE_PLUS_NAME] || !!pkg.devDependencies?.[VITE_PLUS_NAME]; + const viteAlreadyDirect = + pkg.dependencies?.vite ?? pkg.devDependencies?.vite ?? pkg.optionalDependencies?.vite; + if (dependsOnVitePlus && !viteAlreadyDirect) { + pkg.devDependencies = { + ...pkg.devDependencies, + vite: getCatalogDependencySpec(undefined, VITE_PLUS_OVERRIDE_PACKAGES.vite, supportCatalog, { + dependencyField: 'devDependencies', + dependencyName: 'vite', + packageManager, + catalogDependencyResolver, + }), + }; + } + } // Add `vitest` as a direct devDependency when: // - a remaining dependency likely peer-depends on vitest (e.g. // vitest-browser-svelte), OR From 3578da8aff301e935bfe4c99c44799543943232a Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 24 Jun 2026 16:33:10 +0800 Subject: [PATCH 04/11] ci: drop pnpm minimumReleaseAge band-aid now the dedupe fix landed The direct-`vite` dedupe fix makes the bundled vitest resolve `vite` to @voidzero-dev/vite-plus-core (which carries the vite-task-client integration) regardless of which upstream version the age gate would pick, so the PNPM_CONFIG_MINIMUM_RELEASE_AGE=0 workaround is no longer needed. The "Verify single dependency instances" step is now a passing regression guard rather than a known-failing reproduction. Refs #1932 --- .github/workflows/test-vp-create.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/test-vp-create.yml b/.github/workflows/test-vp-create.yml index e9b6ae63c9..dc379337c9 100644 --- a/.github/workflows/test-vp-create.yml +++ b/.github/workflows/test-vp-create.yml @@ -195,13 +195,6 @@ jobs: # `YN0016 ... are quarantined`. The migrate tool is version-pinned to the # bundled oxlint, so disable the gate for this test (no-op for npm/pnpm/bun). YARN_NPM_MINIMAL_AGE_GATE: '0' - # pnpm 11's default `minimumReleaseAge` (~24h) makes the bundled vitest's - # auto-installed `vite` peer resolve to the previous upstream release, - # which can predate vite's `@voidzero-dev/vite-task-client` integration and - # surface a false `vp test` cache miss in "Verify cache". Force 0 so the - # latest vite (with the integration) is used. pnpm-only (no-op elsewhere). - # Temporary band-aid; real fix tracked in voidzero-dev/vite-plus#1932. - PNPM_CONFIG_MINIMUM_RELEASE_AGE: '0' steps: - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 @@ -312,7 +305,7 @@ jobs: # - vite: it is overridden to @voidzero-dev/vite-plus-core, so a clean tree # only summarises that package; a standalone upstream copy adds a # "Found version(s) of vite" line. - # Tracks voidzero-dev/vite-plus#1932 (currently expected to fail for monorepo). + # Regression guard for voidzero-dev/vite-plus#1932 (the pnpm dedupe fix). fail=0 check() { pkg="$1"; pattern="$2" From 1384e6e698448acb5b0748694e26f625bea1d3a6 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 24 Jun 2026 20:26:10 +0800 Subject: [PATCH 05/11] test: format migrator and regenerate snapshots for pnpm vite dedupe Apply oxfmt to the new migrator blocks, hoist the test pkg literal out of a factory to satisfy oxlint, and regenerate the global migration snapshots that now include the direct `vite` devDep for pnpm vite-plus consumers. Refs #1932 --- .../snap.txt | 3 ++- .../migration-baseurl-tsconfig/snap.txt | 3 ++- .../migration-monorepo-pnpm/snap.txt | 3 ++- .../snap.txt | 1 + .../snap.txt | 6 +++-- .../snap.txt | 3 ++- .../migration-oxlintrc-jsonc/snap.txt | 3 ++- .../snap.txt | 3 ++- .../new-vite-monorepo/snap.txt | 3 +++ .../src/migration/__tests__/migrator.spec.ts | 26 +++++++++---------- packages/cli/src/migration/migrator.ts | 17 +++++++----- 11 files changed, 44 insertions(+), 27 deletions(-) diff --git a/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt b/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt index 1192b20cdd..5a1b69b963 100644 --- a/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt @@ -40,7 +40,8 @@ export default defineConfig({ > cat package.json # check package.json { "devDependencies": { - "vite-plus": "catalog:" + "vite-plus": "catalog:", + "vite": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt b/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt index 5d31e92cc2..b4fea46459 100644 --- a/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt +++ b/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt @@ -43,7 +43,8 @@ export default defineConfig({ > cat package.json # check package.json { "devDependencies": { - "vite-plus": "catalog:" + "vite-plus": "catalog:", + "vite": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt index 4181032dc4..089a1f76ee 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt @@ -158,7 +158,8 @@ minimumReleaseAgeExclude: "lint": "vp lint --fix" }, "devDependencies": { - "vite-plus": "catalog:" + "vite-plus": "catalog:", + "vite": "catalog:" } } diff --git a/packages/cli/snap-tests-global/migration-monorepo-root-vitest-adjacent/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-root-vitest-adjacent/snap.txt index a4e42ec1f2..0580c6ca9c 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-root-vitest-adjacent/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-root-vitest-adjacent/snap.txt @@ -13,6 +13,7 @@ "devDependencies": { "vitest-browser-svelte": "^2.1.0", "vite-plus": "catalog:", + "vite": "catalog:", "vitest": "catalog:" }, "devEngines": { diff --git a/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt index 558eda8de8..aabd807529 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt @@ -30,7 +30,8 @@ export default defineConfig({ { "name": "migration-monorepo-skip-vite-peer-dependency", "devDependencies": { - "vite-plus": "catalog:" + "vite-plus": "catalog:", + "vite": "catalog:" }, "devEngines": { "packageManager": { @@ -51,6 +52,7 @@ export default defineConfig({ "vite": "^6.0.0" }, "devDependencies": { - "vite-plus": "catalog:" + "vite-plus": "catalog:", + "vite": "catalog:" } } diff --git a/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt b/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt index d1718df00c..0c9e0dea0e 100644 --- a/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt +++ b/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt @@ -38,7 +38,8 @@ export default defineConfig({ > cat package.json # check package.json { "devDependencies": { - "vite-plus": "catalog:" + "vite-plus": "catalog:", + "vite": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt b/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt index c96dc92a64..e4659a65f8 100644 --- a/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt +++ b/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt @@ -40,7 +40,8 @@ export default defineConfig({ > cat package.json # check package.json { "devDependencies": { - "vite-plus": "catalog:" + "vite-plus": "catalog:", + "vite": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt b/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt index 29b077788e..53805829e1 100644 --- a/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt +++ b/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt @@ -33,7 +33,8 @@ export default defineConfig({ "vite": "^6.0.0" }, "devDependencies": { - "vite-plus": "catalog:" + "vite-plus": "catalog:", + "vite": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt b/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt index 484912d7a9..4dc309f2fe 100644 --- a/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt +++ b/packages/cli/snap-tests-global/new-vite-monorepo/snap.txt @@ -21,6 +21,7 @@ vite.config.ts "prepare": "vp config" }, "devDependencies": { + "vite": "catalog:", "vite-plus": "catalog:" }, "devEngines": { @@ -162,6 +163,7 @@ website "@typescript/native-preview": "7.0.0-dev.20260509.2", "bumpp": "^11.1.0", "typescript": "^6.0.3", + "vite": "catalog:", "vite-plus": "catalog:" } } @@ -250,6 +252,7 @@ vite.config.ts "prepare": "vp config" }, "devDependencies": { + "vite": "catalog:", "vite-plus": "catalog:" }, "devEngines": { diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index d6411aff59..82ab82d2c6 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -476,20 +476,20 @@ describe('rewritePackageJson', () => { // instead of pnpm auto-installing a separate upstream vite (#1932). yarn/bun // redirect the transitive/peer vite via resolutions/overrides, so they do not // get a direct `vite` here (the bun workspace root is handled separately). - const makePkg = () => ({ - devDependencies: { - '@vitest/browser-playwright': '^4.0.0', - playwright: '^1.60.0', - vitest: '^4.0.0', - }, - }); - const pnpmPkg = makePkg(); - rewritePackageJson(pnpmPkg, PackageManager.pnpm); - expect(pnpmPkg.devDependencies).toHaveProperty('vite', VITE_PLUS_OVERRIDE_PACKAGES.vite); - for (const pm of [PackageManager.yarn, PackageManager.bun]) { - const pkg = makePkg(); + for (const pm of [PackageManager.pnpm, PackageManager.yarn, PackageManager.bun]) { + const pkg: { devDependencies: Record } = { + devDependencies: { + '@vitest/browser-playwright': '^4.0.0', + playwright: '^1.60.0', + vitest: '^4.0.0', + }, + }; rewritePackageJson(pkg, pm); - expect(pkg.devDependencies).not.toHaveProperty('vite'); + if (pm === PackageManager.pnpm) { + expect(pkg.devDependencies).toHaveProperty('vite', VITE_PLUS_OVERRIDE_PACKAGES.vite); + } else { + expect(pkg.devDependencies).not.toHaveProperty('vite'); + } } }); diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 7b55400d14..b31e39583f 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -4110,12 +4110,17 @@ export function rewritePackageJson( if (dependsOnVitePlus && !viteAlreadyDirect) { pkg.devDependencies = { ...pkg.devDependencies, - vite: getCatalogDependencySpec(undefined, VITE_PLUS_OVERRIDE_PACKAGES.vite, supportCatalog, { - dependencyField: 'devDependencies', - dependencyName: 'vite', - packageManager, - catalogDependencyResolver, - }), + vite: getCatalogDependencySpec( + undefined, + VITE_PLUS_OVERRIDE_PACKAGES.vite, + supportCatalog, + { + dependencyField: 'devDependencies', + dependencyName: 'vite', + packageManager, + catalogDependencyResolver, + }, + ), }; } } From 816d39bb6c8f8d3c3d35a874211b6c62a91d7de0 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 24 Jun 2026 21:07:49 +0800 Subject: [PATCH 06/11] fix(migrate): address code-review findings on the pnpm vite dedupe - Extract `ensureDirectViteForPnpm` and reuse it for the root, per-package, and standalone migration paths (was duplicated, and the standalone path was missing entirely, so a pnpm project adopting vite-plus without a prior vite still reproduced the duplicate-instance bug). - Skip injection when `vite` is declared only as a peerDependency, so a vite plugin's peer contract (e.g. `vite ^6`) is preserved instead of getting a conflicting concrete devDep (reverts the two skip-vite-peer snapshots). - Guard against a custom VP_OVERRIDE_PACKAGES without a `vite` key, which crashed the root branch with a TypeError. - Treat any declared vite spec (including an empty string) as already-direct. - Drop an em-dash from a new comment. Refs #1932 --- .../migration-lintstagedrc-json/snap.txt | 3 +- .../snap.txt | 3 +- .../snap.txt | 3 +- .../migration-rewrite-declare-module/snap.txt | 3 +- .../snap.txt | 3 +- .../migration-subpath/snap.txt | 3 +- .../snap-tests/create-org-bundled/snap.txt | 1 + .../src/migration/__tests__/migrator.spec.ts | 24 ++++ packages/cli/src/migration/migrator.ts | 115 +++++++++++------- 9 files changed, 103 insertions(+), 55 deletions(-) diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt index af8f1dd1f5..b4d11295d7 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt @@ -83,7 +83,8 @@ Documentation: https://viteplus.dev/guide/migrate { "name": "migration-lintstagedrc", "devDependencies": { - "vite-plus": "catalog:" + "vite-plus": "catalog:", + "vite": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt index 5d9403d2b3..87bc353942 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt @@ -30,7 +30,8 @@ export default { "devDependencies": { "husky": "^9.1.7", "lint-staged": "^16.2.6", - "vite-plus": "catalog:" + "vite-plus": "catalog:", + "vite": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt index aabd807529..91201a27bd 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt @@ -52,7 +52,6 @@ export default defineConfig({ "vite": "^6.0.0" }, "devDependencies": { - "vite-plus": "catalog:", - "vite": "catalog:" + "vite-plus": "catalog:" } } diff --git a/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt b/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt index e816e2f39a..cfe0866acd 100644 --- a/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt +++ b/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt @@ -38,7 +38,8 @@ declare module 'vitest/config' { { "name": "migration-rewrite-declare-module", "devDependencies": { - "vite-plus": "catalog:" + "vite-plus": "catalog:", + "vite": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt b/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt index 53805829e1..29b077788e 100644 --- a/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt +++ b/packages/cli/snap-tests-global/migration-skip-vite-peer-dependency/snap.txt @@ -33,8 +33,7 @@ export default defineConfig({ "vite": "^6.0.0" }, "devDependencies": { - "vite-plus": "catalog:", - "vite": "catalog:" + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-subpath/snap.txt b/packages/cli/snap-tests-global/migration-subpath/snap.txt index c327651108..110b95f19e 100644 --- a/packages/cli/snap-tests-global/migration-subpath/snap.txt +++ b/packages/cli/snap-tests-global/migration-subpath/snap.txt @@ -19,7 +19,8 @@ ] }, "devDependencies": { - "vite-plus": "catalog:" + "vite-plus": "catalog:", + "vite": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests/create-org-bundled/snap.txt b/packages/cli/snap-tests/create-org-bundled/snap.txt index 3f64ead59a..823a373320 100644 --- a/packages/cli/snap-tests/create-org-bundled/snap.txt +++ b/packages/cli/snap-tests/create-org-bundled/snap.txt @@ -12,6 +12,7 @@ "prepare": "vp config" }, "devDependencies": { + "vite": "catalog:", "vite-plus": "catalog:" }, "devEngines": { diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 82ab82d2c6..c4d87dc015 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -248,6 +248,30 @@ describe('rewritePackageJson', () => { expect(pkg.devDependencies.vite).toBe('catalog:vite7'); }); + it('does not inject a direct vite when vite is only a peerDependency under pnpm (#1932)', () => { + // A vite plugin that pins `vite` as a peer must keep its own contract; + // injecting vite-plus-core as a concrete devDep would conflict with it. + const pkg: { + devDependencies: Record; + peerDependencies: Record; + } = { + devDependencies: { 'vite-plus': 'catalog:' }, + peerDependencies: { vite: '^6.0.0' }, + }; + rewritePackageJson(pkg, PackageManager.pnpm, true); + expect(pkg.devDependencies.vite).toBeUndefined(); + expect(pkg.peerDependencies.vite).toBe('^6.0.0'); + }); + + it('does not add a second vite when an empty-string vite spec is already declared under pnpm (#1932)', () => { + const pkg: { dependencies: Record; devDependencies: Record } = { + dependencies: { vite: '' }, + devDependencies: { 'vite-plus': 'catalog:' }, + }; + rewritePackageJson(pkg, PackageManager.pnpm, true); + expect(pkg.devDependencies.vite).toBeUndefined(); + }); + it('preserves protocol-prefixed vite-plus specs (catalog:named, workspace:, link:, github:) in catalog-supporting monorepos', async () => { for (const existing of [ 'catalog:next', diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index b31e39583f..5c5e425207 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1629,6 +1629,14 @@ export function rewriteStandaloneProject( [VITE_PLUS_NAME]: version, }; } + // #1932: vite-plus may be injected above (after rewritePackageJson ran), so + // ensure the direct `vite` here too under pnpm (see ensureDirectViteForPnpm). + ensureDirectViteForPnpm( + pkg, + packageManager, + usePnpmWorkspaceYaml && packageManager !== PackageManager.npm, + catalogDependencyResolver, + ); return pkg; }); @@ -2559,6 +2567,60 @@ function getCatalogDependencySpec( return currentValue?.startsWith('catalog:') ? currentValue : 'catalog:'; } +/** + * #1932: under pnpm, an importer that depends on `vite-plus` (which bundles + * `vitest`) needs a DIRECT `vite` devDep so the `vite` override binds vitest's + * required `vite` peer to @voidzero-dev/vite-plus-core. Without a direct edge, + * pnpm's `autoInstallPeers` fabricates a separate upstream `vite` to satisfy the + * peer, splitting vite-plus / vite / vitest into duplicate instances (the extra + * vite also lacks vite's `@voidzero-dev/vite-task-client` integration, breaking + * the `vp test` cache). npm/yarn/bun redirect transitive/peer vite via root + * overrides/resolutions (and drop the aliased vite), so this is pnpm-only, + * mirroring the bun root-package branch in `rewriteRootWorkspacePackageJson`. + * + * A package that already declares `vite` in ANY dependency field, including + * `peerDependencies` (e.g. a vite plugin pinning `vite ^6`), is left untouched + * so its existing version contract is preserved. Call this AFTER `vite-plus` + * has been ensured in the package, so the dependency check sees it. + */ +function ensureDirectViteForPnpm( + pkg: { + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; + }, + packageManager: PackageManager, + supportCatalog: boolean, + catalogDependencyResolver?: CatalogDependencyResolver, +): boolean { + const viteOverride = VITE_PLUS_OVERRIDE_PACKAGES.vite; + if (packageManager !== PackageManager.pnpm || !viteOverride) { + return false; + } + const dependsOnVitePlus = + pkg.dependencies?.[VITE_PLUS_NAME] !== undefined || + pkg.devDependencies?.[VITE_PLUS_NAME] !== undefined; + const viteAlreadyDirect = + pkg.dependencies?.vite !== undefined || + pkg.devDependencies?.vite !== undefined || + pkg.optionalDependencies?.vite !== undefined || + pkg.peerDependencies?.vite !== undefined; + if (!dependsOnVitePlus || viteAlreadyDirect) { + return false; + } + pkg.devDependencies = { + ...pkg.devDependencies, + vite: getCatalogDependencySpec(undefined, viteOverride, supportCatalog, { + dependencyField: 'devDependencies', + dependencyName: 'vite', + packageManager, + catalogDependencyResolver, + }), + }; + return true; +} + function isVitePlusOverrideSpec(value: string): boolean { return ( Object.values(VITE_PLUS_OVERRIDE_PACKAGES).includes(value) || @@ -2985,22 +3047,9 @@ function rewriteRootWorkspacePackageJson( : 'catalog:', }; } - // #1932: under pnpm the root also depends on vite-plus (-> vitest), so it - // needs a direct `vite` for the override to bind vitest's peer; otherwise - // pnpm auto-installs a separate upstream vite and splits the graph into - // duplicate instances. Mirror the override as a direct devDep, like the bun - // branch above. pnpm-only (npm/yarn/bun dedupe via overrides/resolutions). - if ( - packageManager === PackageManager.pnpm && - !pkg.dependencies?.vite && - !pkg.devDependencies?.vite && - !pkg.optionalDependencies?.vite - ) { - pkg.devDependencies = { - ...pkg.devDependencies, - vite: getCatalogDependencySpec(undefined, VITE_PLUS_OVERRIDE_PACKAGES.vite, true), - }; - } + // #1932: the root depends on vite-plus too, so under pnpm it needs a direct + // `vite` for the override to bind vitest's peer (see ensureDirectViteForPnpm). + ensureDirectViteForPnpm(pkg, packageManager, true, catalogDependencyResolver); return pkg; }); @@ -4093,37 +4142,9 @@ export function rewritePackageJson( [VITE_PLUS_NAME]: canonicalVitePlusSpec, }; } - // #1932: under pnpm, any package that depends on `vite-plus` (which bundles - // `vitest`) needs a DIRECT `vite` devDep so the `vite` override binds vitest's - // required `vite` peer to @voidzero-dev/vite-plus-core. Without a direct edge, - // pnpm's `autoInstallPeers` fabricates a separate upstream `vite` to satisfy the - // peer, splitting vite-plus / vite / vitest into duplicate instances (a 2nd vite - // also misses vite's `@voidzero-dev/vite-task-client` integration, breaking the - // `vp test` cache). npm/yarn/bun redirect the transitive/peer vite via root - // overrides/resolutions (and drop the aliased vite), so this is pnpm-only — - // mirroring the bun root-package fix in `rewriteRootWorkspacePackageJson`. - if (packageManager === PackageManager.pnpm && VITE_PLUS_OVERRIDE_PACKAGES.vite) { - const dependsOnVitePlus = - !!pkg.dependencies?.[VITE_PLUS_NAME] || !!pkg.devDependencies?.[VITE_PLUS_NAME]; - const viteAlreadyDirect = - pkg.dependencies?.vite ?? pkg.devDependencies?.vite ?? pkg.optionalDependencies?.vite; - if (dependsOnVitePlus && !viteAlreadyDirect) { - pkg.devDependencies = { - ...pkg.devDependencies, - vite: getCatalogDependencySpec( - undefined, - VITE_PLUS_OVERRIDE_PACKAGES.vite, - supportCatalog, - { - dependencyField: 'devDependencies', - dependencyName: 'vite', - packageManager, - catalogDependencyResolver, - }, - ), - }; - } - } + // #1932: under pnpm, a package that depends on vite-plus needs a direct `vite` + // so the override binds vitest's peer (see ensureDirectViteForPnpm). + ensureDirectViteForPnpm(pkg, packageManager, supportCatalog, catalogDependencyResolver); // Add `vitest` as a direct devDependency when: // - a remaining dependency likely peer-depends on vitest (e.g. // vitest-browser-svelte), OR From 878762d4557553d467eed3e32b6b65dbb3c860b8 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 24 Jun 2026 21:13:41 +0800 Subject: [PATCH 07/11] refactor(migrate): simplify ensureDirectViteForPnpm Drop the inert getCatalogDependencySpec options (dependencyName / packageManager / catalogDependencyResolver) - they only affect an existing value or a peerDependencies field, neither of which applies to the fresh devDependencies entry this helper writes - and the now-unused catalogDependencyResolver parameter. Add the `vite` key in place via `??=` to match the sibling npm branch instead of rebuilding the object. No behavior change. --- packages/cli/src/migration/migrator.ts | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 5c5e425207..209531c3c5 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1635,7 +1635,6 @@ export function rewriteStandaloneProject( pkg, packageManager, usePnpmWorkspaceYaml && packageManager !== PackageManager.npm, - catalogDependencyResolver, ); return pkg; }); @@ -2592,7 +2591,6 @@ function ensureDirectViteForPnpm( }, packageManager: PackageManager, supportCatalog: boolean, - catalogDependencyResolver?: CatalogDependencyResolver, ): boolean { const viteOverride = VITE_PLUS_OVERRIDE_PACKAGES.vite; if (packageManager !== PackageManager.pnpm || !viteOverride) { @@ -2609,15 +2607,12 @@ function ensureDirectViteForPnpm( if (!dependsOnVitePlus || viteAlreadyDirect) { return false; } - pkg.devDependencies = { - ...pkg.devDependencies, - vite: getCatalogDependencySpec(undefined, viteOverride, supportCatalog, { - dependencyField: 'devDependencies', - dependencyName: 'vite', - packageManager, - catalogDependencyResolver, - }), - }; + // The catalog-vs-alias choice is driven entirely by supportCatalog and the + // (file:/npm:) override spec; the extra getCatalogDependencySpec options only + // matter for an existing value or a peerDependencies field, neither of which + // applies here (we only reach this for a fresh devDependencies entry). + pkg.devDependencies ??= {}; + pkg.devDependencies.vite = getCatalogDependencySpec(undefined, viteOverride, supportCatalog); return true; } @@ -3049,7 +3044,7 @@ function rewriteRootWorkspacePackageJson( } // #1932: the root depends on vite-plus too, so under pnpm it needs a direct // `vite` for the override to bind vitest's peer (see ensureDirectViteForPnpm). - ensureDirectViteForPnpm(pkg, packageManager, true, catalogDependencyResolver); + ensureDirectViteForPnpm(pkg, packageManager, true); return pkg; }); @@ -4144,7 +4139,7 @@ export function rewritePackageJson( } // #1932: under pnpm, a package that depends on vite-plus needs a direct `vite` // so the override binds vitest's peer (see ensureDirectViteForPnpm). - ensureDirectViteForPnpm(pkg, packageManager, supportCatalog, catalogDependencyResolver); + ensureDirectViteForPnpm(pkg, packageManager, supportCatalog); // Add `vitest` as a direct devDependency when: // - a remaining dependency likely peer-depends on vitest (e.g. // vitest-browser-svelte), OR From cf1127ec0313ba069961f5b66d33c4658c72a209 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 24 Jun 2026 21:18:56 +0800 Subject: [PATCH 08/11] docs(migrate): de-duplicate the #1932 vite-dedupe comments The rationale lives in the ensureDirectViteForPnpm JSDoc; drop the repeated explanation (and #1932 tag) from the three call sites, keeping only the one non-obvious note about the standalone path needing a second pass. --- packages/cli/src/migration/migrator.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 209531c3c5..4bb57cbc67 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1629,8 +1629,8 @@ export function rewriteStandaloneProject( [VITE_PLUS_NAME]: version, }; } - // #1932: vite-plus may be injected above (after rewritePackageJson ran), so - // ensure the direct `vite` here too under pnpm (see ensureDirectViteForPnpm). + // This caller injects vite-plus after rewritePackageJson returned, so the + // direct-`vite` pass must run here too. ensureDirectViteForPnpm( pkg, packageManager, @@ -3042,8 +3042,6 @@ function rewriteRootWorkspacePackageJson( : 'catalog:', }; } - // #1932: the root depends on vite-plus too, so under pnpm it needs a direct - // `vite` for the override to bind vitest's peer (see ensureDirectViteForPnpm). ensureDirectViteForPnpm(pkg, packageManager, true); return pkg; }); @@ -4137,8 +4135,6 @@ export function rewritePackageJson( [VITE_PLUS_NAME]: canonicalVitePlusSpec, }; } - // #1932: under pnpm, a package that depends on vite-plus needs a direct `vite` - // so the override binds vitest's peer (see ensureDirectViteForPnpm). ensureDirectViteForPnpm(pkg, packageManager, supportCatalog); // Add `vitest` as a direct devDependency when: // - a remaining dependency likely peer-depends on vitest (e.g. From a938f4f260e676597a3a054c1693960b562a47f8 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 24 Jun 2026 21:23:19 +0800 Subject: [PATCH 09/11] test(migrate): group the pnpm vite-dedupe cases under one describe Wrap the six rewritePackageJson #1932 cases in a `describe('pnpm direct-vite dedupe (#1932)')` block and drop the per-test `(#1932)` tags; also drop the redundant tag from the provider-context case. The issue is now referenced once (the describe name) instead of seven times. --- .../src/migration/__tests__/migrator.spec.ts | 133 +++++++++--------- 1 file changed, 69 insertions(+), 64 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index c4d87dc015..9dbb43951a 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -202,74 +202,79 @@ describe('rewritePackageJson', () => { } }); - // #1932: under pnpm, any package that depends on vite-plus needs a direct - // `vite` so vitest's required `vite` peer binds to the override - // (@voidzero-dev/vite-plus-core); otherwise pnpm's autoInstallPeers installs a - // second upstream vite and splits vite-plus / vite / vitest into duplicates. - it('adds a direct `vite` devDep when a package depends on vite-plus under pnpm (#1932)', () => { - // monorepo sub-package -> catalog: (catalog.vite is written by rewriteCatalog) - const sub: { devDependencies: Record } = { - devDependencies: { 'vite-plus': 'catalog:' }, - }; - rewritePackageJson(sub, PackageManager.pnpm, true); - expect(sub.devDependencies.vite).toBe('catalog:'); + // Under pnpm, a package that depends on vite-plus needs a direct `vite` so + // vitest's required `vite` peer binds to the override (@voidzero-dev/vite-plus-core); + // otherwise pnpm's autoInstallPeers installs a second upstream vite and splits + // vite-plus / vite / vitest into duplicate instances. + describe('pnpm direct-vite dedupe (#1932)', () => { + it('adds a direct `vite` devDep when a package depends on vite-plus under pnpm', () => { + // monorepo sub-package -> catalog: (catalog.vite is written by rewriteCatalog) + const sub: { devDependencies: Record } = { + devDependencies: { 'vite-plus': 'catalog:' }, + }; + rewritePackageJson(sub, PackageManager.pnpm, true); + expect(sub.devDependencies.vite).toBe('catalog:'); - // standalone (no catalog) -> mirror the override target directly - const standalone: { devDependencies: Record } = { - devDependencies: { 'vite-plus': 'latest' }, - }; - rewritePackageJson(standalone, PackageManager.pnpm); - expect(standalone.devDependencies.vite).toBe(VITE_PLUS_OVERRIDE_PACKAGES.vite); - }); + // standalone (no catalog) -> mirror the override target directly + const standalone: { devDependencies: Record } = { + devDependencies: { 'vite-plus': 'latest' }, + }; + rewritePackageJson(standalone, PackageManager.pnpm); + expect(standalone.devDependencies.vite).toBe(VITE_PLUS_OVERRIDE_PACKAGES.vite); + }); + + it('does not add a direct `vite` for npm/yarn/bun (they dedupe via overrides/resolutions)', () => { + for (const pm of [PackageManager.npm, PackageManager.yarn, PackageManager.bun]) { + const pkg: { devDependencies: Record } = { + devDependencies: { 'vite-plus': pm === PackageManager.npm ? '^0.1.20' : 'catalog:' }, + }; + rewritePackageJson(pkg, pm, true); + expect(pkg.devDependencies.vite).toBeUndefined(); + } + }); - it('does not add a direct `vite` for npm/yarn/bun (they dedupe via overrides/resolutions) (#1932)', () => { - for (const pm of [PackageManager.npm, PackageManager.yarn, PackageManager.bun]) { + it('does not add `vite` for a pnpm package that does not depend on vite-plus', () => { const pkg: { devDependencies: Record } = { - devDependencies: { 'vite-plus': pm === PackageManager.npm ? '^0.1.20' : 'catalog:' }, + devDependencies: { typescript: '^5' }, }; - rewritePackageJson(pkg, pm, true); + rewritePackageJson(pkg, PackageManager.pnpm, true); expect(pkg.devDependencies.vite).toBeUndefined(); - } - }); - - it('does not add `vite` for a pnpm package that does not depend on vite-plus (#1932)', () => { - const pkg: { devDependencies: Record } = { - devDependencies: { typescript: '^5' }, - }; - rewritePackageJson(pkg, PackageManager.pnpm, true); - expect(pkg.devDependencies.vite).toBeUndefined(); - }); + }); - it('keeps an existing direct `vite` instead of overwriting it under pnpm (#1932)', () => { - const pkg: { devDependencies: Record } = { - devDependencies: { 'vite-plus': 'catalog:', vite: 'catalog:vite7' }, - }; - rewritePackageJson(pkg, PackageManager.pnpm, true); - expect(pkg.devDependencies.vite).toBe('catalog:vite7'); - }); + it('keeps an existing direct `vite` instead of overwriting it under pnpm', () => { + const pkg: { devDependencies: Record } = { + devDependencies: { 'vite-plus': 'catalog:', vite: 'catalog:vite7' }, + }; + rewritePackageJson(pkg, PackageManager.pnpm, true); + expect(pkg.devDependencies.vite).toBe('catalog:vite7'); + }); - it('does not inject a direct vite when vite is only a peerDependency under pnpm (#1932)', () => { - // A vite plugin that pins `vite` as a peer must keep its own contract; - // injecting vite-plus-core as a concrete devDep would conflict with it. - const pkg: { - devDependencies: Record; - peerDependencies: Record; - } = { - devDependencies: { 'vite-plus': 'catalog:' }, - peerDependencies: { vite: '^6.0.0' }, - }; - rewritePackageJson(pkg, PackageManager.pnpm, true); - expect(pkg.devDependencies.vite).toBeUndefined(); - expect(pkg.peerDependencies.vite).toBe('^6.0.0'); - }); + it('does not inject a direct vite when vite is only a peerDependency under pnpm', () => { + // A vite plugin that pins `vite` as a peer must keep its own contract; + // injecting vite-plus-core as a concrete devDep would conflict with it. + const pkg: { + devDependencies: Record; + peerDependencies: Record; + } = { + devDependencies: { 'vite-plus': 'catalog:' }, + peerDependencies: { vite: '^6.0.0' }, + }; + rewritePackageJson(pkg, PackageManager.pnpm, true); + expect(pkg.devDependencies.vite).toBeUndefined(); + expect(pkg.peerDependencies.vite).toBe('^6.0.0'); + }); - it('does not add a second vite when an empty-string vite spec is already declared under pnpm (#1932)', () => { - const pkg: { dependencies: Record; devDependencies: Record } = { - dependencies: { vite: '' }, - devDependencies: { 'vite-plus': 'catalog:' }, - }; - rewritePackageJson(pkg, PackageManager.pnpm, true); - expect(pkg.devDependencies.vite).toBeUndefined(); + it('does not add a second vite when an empty-string vite spec is already declared under pnpm', () => { + const pkg: { + dependencies: Record; + devDependencies: Record; + } = { + dependencies: { vite: '' }, + devDependencies: { 'vite-plus': 'catalog:' }, + }; + rewritePackageJson(pkg, PackageManager.pnpm, true); + expect(pkg.devDependencies.vite).toBeUndefined(); + }); }); it('preserves protocol-prefixed vite-plus specs (catalog:named, workspace:, link:, github:) in catalog-supporting monorepos', async () => { @@ -495,11 +500,11 @@ describe('rewritePackageJson', () => { expect(pkg.devDependencies).not.toHaveProperty('vite'); }); - it('injects a direct vite devDependency for pnpm projects depending on vite-plus, but not yarn/bun (#1932)', async () => { + it('injects a direct vite devDependency for pnpm projects depending on vite-plus, but not yarn/bun', async () => { // pnpm needs a direct `vite` so vitest's `vite` peer binds to the override - // instead of pnpm auto-installing a separate upstream vite (#1932). yarn/bun - // redirect the transitive/peer vite via resolutions/overrides, so they do not - // get a direct `vite` here (the bun workspace root is handled separately). + // instead of pnpm auto-installing a separate upstream vite. yarn/bun redirect + // the transitive/peer vite via resolutions/overrides, so they do not get a + // direct `vite` here (the bun workspace root is handled separately). for (const pm of [PackageManager.pnpm, PackageManager.yarn, PackageManager.bun]) { const pkg: { devDependencies: Record } = { devDependencies: { From 4d4efe11b39a0bec6287b7e32839e4bb9b3b3a19 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 25 Jun 2026 00:54:13 +0800 Subject: [PATCH 10/11] ci: check dependency instances recursively across workspace packages Use `vp why -r` so the guard inspects every workspace package, not just the root importer, catching a duplicate confined to a sub-package. Addresses the Codex review comment. --- .github/workflows/test-vp-create.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-vp-create.yml b/.github/workflows/test-vp-create.yml index dc379337c9..2a2973cc30 100644 --- a/.github/workflows/test-vp-create.yml +++ b/.github/workflows/test-vp-create.yml @@ -306,15 +306,18 @@ jobs: # only summarises that package; a standalone upstream copy adds a # "Found version(s) of vite" line. # Regression guard for voidzero-dev/vite-plus#1932 (the pnpm dedupe fix). + # `-r` checks every workspace package, not just the root importer, so a + # duplicate confined to a sub-package (apps/website, packages/utils) is + # still caught. fail=0 check() { pkg="$1"; pattern="$2" - out=$(vp why "$pkg" 2>&1) + out=$(vp why -r "$pkg" 2>&1) found=$(echo "$out" | grep '^Found' || true) echo "[$pkg]"; echo "$found" if echo "$found" | grep -qE "$pattern"; then echo "✗ $pkg is not a single instance (override did not dedupe under pnpm)" - echo "----- full \`vp why $pkg\` output -----" + echo "----- full \`vp why -r $pkg\` output -----" echo "$out" echo "---------------------------------------" fail=1 From f73fdbe6ca89dd110a4281ff011f7c3633a2b34d Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 25 Jun 2026 00:54:48 +0800 Subject: [PATCH 11/11] fix(migrate): insert the injected vite in sorted order oxfmt sorts package.json dependencies, but the #1932 helper appended `vite` last. `vp create` re-formats afterward so it was fine, but `vp migrate` has no format pass, so a migrated project failed a follow-up `vp check` (caught by the vp-config E2E). Insert `vite` in sorted position instead; regenerate the affected migration snapshots. --- .../migration-auto-create-vite-config/snap.txt | 4 ++-- .../migration-baseurl-tsconfig/snap.txt | 4 ++-- .../migration-lintstagedrc-json/snap.txt | 4 ++-- .../migration-lintstagedrc-not-support/snap.txt | 4 ++-- .../snap-tests-global/migration-monorepo-pnpm/snap.txt | 4 ++-- .../migration-monorepo-root-vitest-adjacent/snap.txt | 2 +- .../snap.txt | 4 ++-- .../migration-oxlintrc-json-with-comments/snap.txt | 4 ++-- .../migration-oxlintrc-jsonc/snap.txt | 4 ++-- .../migration-rewrite-declare-module/snap.txt | 4 ++-- .../cli/snap-tests-global/migration-subpath/snap.txt | 4 ++-- packages/cli/src/migration/__tests__/migrator.spec.ts | 2 ++ packages/cli/src/migration/migrator.ts | 10 ++++++++-- 13 files changed, 31 insertions(+), 23 deletions(-) diff --git a/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt b/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt index 5a1b69b963..e8971d018f 100644 --- a/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt +++ b/packages/cli/snap-tests-global/migration-auto-create-vite-config/snap.txt @@ -40,8 +40,8 @@ export default defineConfig({ > cat package.json # check package.json { "devDependencies": { - "vite-plus": "catalog:", - "vite": "catalog:" + "vite": "catalog:", + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt b/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt index b4fea46459..6b40797742 100644 --- a/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt +++ b/packages/cli/snap-tests-global/migration-baseurl-tsconfig/snap.txt @@ -43,8 +43,8 @@ export default defineConfig({ > cat package.json # check package.json { "devDependencies": { - "vite-plus": "catalog:", - "vite": "catalog:" + "vite": "catalog:", + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt index b4d11295d7..f7d3924f9c 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-json/snap.txt @@ -83,8 +83,8 @@ Documentation: https://viteplus.dev/guide/migrate { "name": "migration-lintstagedrc", "devDependencies": { - "vite-plus": "catalog:", - "vite": "catalog:" + "vite": "catalog:", + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt b/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt index 87bc353942..6e94aa1508 100644 --- a/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt +++ b/packages/cli/snap-tests-global/migration-lintstagedrc-not-support/snap.txt @@ -30,8 +30,8 @@ export default { "devDependencies": { "husky": "^9.1.7", "lint-staged": "^16.2.6", - "vite-plus": "catalog:", - "vite": "catalog:" + "vite": "catalog:", + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt index 089a1f76ee..0b8b6f890d 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-pnpm/snap.txt @@ -158,8 +158,8 @@ minimumReleaseAgeExclude: "lint": "vp lint --fix" }, "devDependencies": { - "vite-plus": "catalog:", - "vite": "catalog:" + "vite": "catalog:", + "vite-plus": "catalog:" } } diff --git a/packages/cli/snap-tests-global/migration-monorepo-root-vitest-adjacent/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-root-vitest-adjacent/snap.txt index 0580c6ca9c..43bdcbee56 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-root-vitest-adjacent/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-root-vitest-adjacent/snap.txt @@ -11,9 +11,9 @@ "prepare": "vp config" }, "devDependencies": { + "vite": "catalog:", "vitest-browser-svelte": "^2.1.0", "vite-plus": "catalog:", - "vite": "catalog:", "vitest": "catalog:" }, "devEngines": { diff --git a/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt b/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt index 91201a27bd..30754ecc6c 100644 --- a/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt +++ b/packages/cli/snap-tests-global/migration-monorepo-skip-vite-peer-dependency/snap.txt @@ -30,8 +30,8 @@ export default defineConfig({ { "name": "migration-monorepo-skip-vite-peer-dependency", "devDependencies": { - "vite-plus": "catalog:", - "vite": "catalog:" + "vite": "catalog:", + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt b/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt index 0c9e0dea0e..5603db72d3 100644 --- a/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt +++ b/packages/cli/snap-tests-global/migration-oxlintrc-json-with-comments/snap.txt @@ -38,8 +38,8 @@ export default defineConfig({ > cat package.json # check package.json { "devDependencies": { - "vite-plus": "catalog:", - "vite": "catalog:" + "vite": "catalog:", + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt b/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt index e4659a65f8..7eb27b65ed 100644 --- a/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt +++ b/packages/cli/snap-tests-global/migration-oxlintrc-jsonc/snap.txt @@ -40,8 +40,8 @@ export default defineConfig({ > cat package.json # check package.json { "devDependencies": { - "vite-plus": "catalog:", - "vite": "catalog:" + "vite": "catalog:", + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt b/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt index cfe0866acd..052b0d5b4f 100644 --- a/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt +++ b/packages/cli/snap-tests-global/migration-rewrite-declare-module/snap.txt @@ -38,8 +38,8 @@ declare module 'vitest/config' { { "name": "migration-rewrite-declare-module", "devDependencies": { - "vite-plus": "catalog:", - "vite": "catalog:" + "vite": "catalog:", + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/snap-tests-global/migration-subpath/snap.txt b/packages/cli/snap-tests-global/migration-subpath/snap.txt index 110b95f19e..d8e7263702 100644 --- a/packages/cli/snap-tests-global/migration-subpath/snap.txt +++ b/packages/cli/snap-tests-global/migration-subpath/snap.txt @@ -19,8 +19,8 @@ ] }, "devDependencies": { - "vite-plus": "catalog:", - "vite": "catalog:" + "vite": "catalog:", + "vite-plus": "catalog:" }, "devEngines": { "packageManager": { diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 9dbb43951a..cae77d6282 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -214,6 +214,8 @@ describe('rewritePackageJson', () => { }; rewritePackageJson(sub, PackageManager.pnpm, true); expect(sub.devDependencies.vite).toBe('catalog:'); + // inserted in sorted position (oxfmt sorts package.json), not appended + expect(Object.keys(sub.devDependencies)).toEqual(['vite', 'vite-plus']); // standalone (no catalog) -> mirror the override target directly const standalone: { devDependencies: Record } = { diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 4bb57cbc67..8d41d2344e 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -2611,8 +2611,14 @@ function ensureDirectViteForPnpm( // (file:/npm:) override spec; the extra getCatalogDependencySpec options only // matter for an existing value or a peerDependencies field, neither of which // applies here (we only reach this for a fresh devDependencies entry). - pkg.devDependencies ??= {}; - pkg.devDependencies.vite = getCatalogDependencySpec(undefined, viteOverride, supportCatalog); + const viteSpec = getCatalogDependencySpec(undefined, viteOverride, supportCatalog); + // Insert `vite` in sorted position rather than appending it: oxfmt sorts + // package.json dependencies and `vp migrate` has no later format pass, so an + // out-of-order key would fail a follow-up `vp check`. + const entries: [string, string][] = Object.entries(pkg.devDependencies ?? {}); + const insertAt = entries.findIndex(([name]) => name > 'vite'); + entries.splice(insertAt === -1 ? entries.length : insertAt, 0, ['vite', viteSpec]); + pkg.devDependencies = Object.fromEntries(entries); return true; }