diff --git a/.github/workflows/test-vp-create.yml b/.github/workflows/test-vp-create.yml index 29b2c3f31c..2a2973cc30 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 @@ -300,6 +293,47 @@ 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. + # 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 -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 -r $pkg\` output -----" + echo "$out" + echo "---------------------------------------" + 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: | 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..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,6 +40,7 @@ export default defineConfig({ > cat package.json # check package.json { "devDependencies": { + "vite": "catalog:", "vite-plus": "catalog:" }, "devEngines": { 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..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,6 +43,7 @@ export default defineConfig({ > cat package.json # check package.json { "devDependencies": { + "vite": "catalog:", "vite-plus": "catalog:" }, "devEngines": { 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..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,6 +83,7 @@ Documentation: https://viteplus.dev/guide/migrate { "name": "migration-lintstagedrc", "devDependencies": { + "vite": "catalog:", "vite-plus": "catalog:" }, "devEngines": { 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..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,6 +30,7 @@ export default { "devDependencies": { "husky": "^9.1.7", "lint-staged": "^16.2.6", + "vite": "catalog:", "vite-plus": "catalog:" }, "devEngines": { 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..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,6 +158,7 @@ minimumReleaseAgeExclude: "lint": "vp lint --fix" }, "devDependencies": { + "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 a4e42ec1f2..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,6 +11,7 @@ "prepare": "vp config" }, "devDependencies": { + "vite": "catalog:", "vitest-browser-svelte": "^2.1.0", "vite-plus": "catalog:", "vitest": "catalog:" 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..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,6 +30,7 @@ export default defineConfig({ { "name": "migration-monorepo-skip-vite-peer-dependency", "devDependencies": { + "vite": "catalog:", "vite-plus": "catalog:" }, "devEngines": { 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..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,6 +38,7 @@ export default defineConfig({ > cat package.json # check package.json { "devDependencies": { + "vite": "catalog:", "vite-plus": "catalog:" }, "devEngines": { 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..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,6 +40,7 @@ export default defineConfig({ > cat package.json # check package.json { "devDependencies": { + "vite": "catalog:", "vite-plus": "catalog:" }, "devEngines": { 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..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,6 +38,7 @@ declare module 'vitest/config' { { "name": "migration-rewrite-declare-module", "devDependencies": { + "vite": "catalog:", "vite-plus": "catalog:" }, "devEngines": { diff --git a/packages/cli/snap-tests-global/migration-subpath/snap.txt b/packages/cli/snap-tests-global/migration-subpath/snap.txt index c327651108..d8e7263702 100644 --- a/packages/cli/snap-tests-global/migration-subpath/snap.txt +++ b/packages/cli/snap-tests-global/migration-subpath/snap.txt @@ -19,6 +19,7 @@ ] }, "devDependencies": { + "vite": "catalog:", "vite-plus": "catalog:" }, "devEngines": { 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/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__/__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..cae77d6282 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -202,6 +202,83 @@ describe('rewritePackageJson', () => { } }); + // 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:'); + // 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 } = { + 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 `vite` for a pnpm package that does not depend on vite-plus', () => { + 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', () => { + 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', () => { + // 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', () => { + 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', @@ -425,19 +502,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 = { - 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'); + 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. 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: { + '@vitest/browser-playwright': '^4.0.0', + playwright: '^1.60.0', + vitest: '^4.0.0', + }, + }; + rewritePackageJson(pkg, pm); + if (pm === PackageManager.pnpm) { + expect(pkg.devDependencies).toHaveProperty('vite', VITE_PLUS_OVERRIDE_PACKAGES.vite); + } else { + 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..8d41d2344e 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -1629,6 +1629,13 @@ export function rewriteStandaloneProject( [VITE_PLUS_NAME]: version, }; } + // This caller injects vite-plus after rewritePackageJson returned, so the + // direct-`vite` pass must run here too. + ensureDirectViteForPnpm( + pkg, + packageManager, + usePnpmWorkspaceYaml && packageManager !== PackageManager.npm, + ); return pkg; }); @@ -2559,6 +2566,62 @@ 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, +): 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; + } + // 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). + 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; +} + function isVitePlusOverrideSpec(value: string): boolean { return ( Object.values(VITE_PLUS_OVERRIDE_PACKAGES).includes(value) || @@ -2985,6 +3048,7 @@ function rewriteRootWorkspacePackageJson( : 'catalog:', }; } + ensureDirectViteForPnpm(pkg, packageManager, true); return pkg; }); @@ -4077,6 +4141,7 @@ export function rewritePackageJson( [VITE_PLUS_NAME]: canonicalVitePlusSpec, }; } + 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