From 55b18acd54004588c8a090462b458e444369ba76 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 21 Oct 2026 13:02:48 -0400 Subject: [PATCH 1/5] fix(migrate): warn on esbuild-specific config that fails under Vite 8 Vite 8 no longer bundles esbuild, so a project migrated from Vite <= 7 with build.minify: 'esbuild' fails its next vp build on the deprecated transformWithEsbuild path (esbuild is no longer a vite dependency), and direct transformWithEsbuild usage has no vite-plus equivalent. The error names neither the config file nor the option, and migrate gives no heads-up. Add warnEsbuildConfigInViteConfig and call it wherever migrate finalizes a project's vite config (standalone, monorepo root + packages, per-project rewrite, and the existing-vite+ finalize pass). It emits one migration warning per detected pattern, pointing at the oxc replacements (minify: true, transformWithOxc). Like the rest of migrate it does not rewrite config semantics. Closes #3176 --- .../src/migration/__tests__/migrator.spec.ts | 98 +++++++++++++++++++ .../migration/migrator/core-finalization.ts | 2 + .../src/migration/migrator/orchestrators.ts | 5 + .../cli/src/migration/migrator/vite-config.ts | 33 +++++++ 4 files changed, 138 insertions(+) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index c1bcb66131..84e859235b 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -67,6 +67,7 @@ const { pnpmSupportsWorkspaceSettings, pnpmOverrideKey, setPackageManager, + warnEsbuildConfigInViteConfig, } = await import('../migrator.js'); // pnpm's managed override keys are range-qualified so they never rewrite an @@ -9542,3 +9543,100 @@ describe('collectMigrationSetupPlan non-interactive editor conflicts', () => { expect(plan.editorConflictDecisions.get('workspace.xml')).toBe('skip'); }); }); + +describe('warnEsbuildConfigInViteConfig', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-esbuild-config-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('warns when build.minify is set to "esbuild"', () => { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + build: { minify: 'esbuild' }, +}); +`, + ); + const report = createMigrationReport(); + + warnEsbuildConfigInViteConfig(tmpDir, report); + + expect(report.warnings).toHaveLength(1); + expect(report.warnings[0]).toContain('vite.config.ts'); + expect(report.warnings[0]).toContain('minify: true'); + }); + + it('warns on direct transformWithEsbuild usage', () => { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { transformWithEsbuild } from 'vite'; + +export default { + plugins: [ + { + name: 'esbuild-transform', + transform(code, id) { + return transformWithEsbuild(code, id); + }, + }, + ], +}; +`, + ); + const report = createMigrationReport(); + + warnEsbuildConfigInViteConfig(tmpDir, report); + + expect(report.warnings).toHaveLength(1); + expect(report.warnings[0]).toContain('transformWithOxc'); + }); + + it('warns separately when both esbuild patterns are present', () => { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { transformWithEsbuild } from 'vite'; + +export default { + build: { minify: 'esbuild' }, +}; +`, + ); + const report = createMigrationReport(); + + warnEsbuildConfigInViteConfig(tmpDir, report); + + expect(report.warnings).toHaveLength(2); + }); + + it('does not warn for oxc-compatible configs', () => { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + build: { minify: true }, +}); +`, + ); + const report = createMigrationReport(); + + warnEsbuildConfigInViteConfig(tmpDir, report); + + expect(report.warnings).toHaveLength(0); + }); + + it('does nothing when no vite config exists', () => { + const report = createMigrationReport(); + + expect(() => warnEsbuildConfigInViteConfig(tmpDir, report)).not.toThrow(); + expect(report.warnings).toHaveLength(0); + }); +}); diff --git a/packages/cli/src/migration/migrator/core-finalization.ts b/packages/cli/src/migration/migrator/core-finalization.ts index d57f2b556a..5873b594aa 100644 --- a/packages/cli/src/migration/migrator/core-finalization.ts +++ b/packages/cli/src/migration/migrator/core-finalization.ts @@ -12,6 +12,7 @@ import { migrateTaskCacheConfigInViteConfig, rewriteAllImports, rewriteTsconfigTypes, + warnEsbuildConfigInViteConfig, } from '../migrator.ts'; import { type MigrationReport } from '../report.ts'; @@ -189,6 +190,7 @@ export function finalizeCoreMigrationForExistingVitePlus( result.taskCacheConfig = migrateTaskCacheConfigInViteConfig(projectPath, silent, report, result.taskCacheWarnings) || result.taskCacheConfig; + warnEsbuildConfigInViteConfig(projectPath, report); } return result; diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts index dc4a2c5550..8eff841528 100644 --- a/packages/cli/src/migration/migrator/orchestrators.ts +++ b/packages/cli/src/migration/migrator/orchestrators.ts @@ -57,6 +57,7 @@ import { workspaceUsesVitestDirectly, workspaceUsesWebdriverio, workspaceUsesWebdriverioProvider, + warnEsbuildConfigInViteConfig, wrapLazyPluginsInViteConfig, } from '../migrator.ts'; import { type MigrationReport } from '../report.ts'; @@ -345,6 +346,7 @@ export function rewriteStandaloneProject( dropDeadOxlintPluginsDependency(projectPath, workspaceInfo.packages, originalOxlintDependencies); wrapLazyPluginsInViteConfig(projectPath, silent, report); migrateTaskCacheConfigInViteConfig(projectPath, silent, report); + warnEsbuildConfigInViteConfig(projectPath, report); } /** @@ -510,10 +512,12 @@ export function rewriteMonorepo( ); wrapLazyPluginsInViteConfig(workspaceInfo.rootDir, silent, report); migrateTaskCacheConfigInViteConfig(workspaceInfo.rootDir, silent, report); + warnEsbuildConfigInViteConfig(workspaceInfo.rootDir, report); for (const pkg of workspaceInfo.packages) { const projectPath = path.join(workspaceInfo.rootDir, pkg.path); wrapLazyPluginsInViteConfig(projectPath, silent, report); migrateTaskCacheConfigInViteConfig(projectPath, silent, report); + warnEsbuildConfigInViteConfig(projectPath, report); } } @@ -641,5 +645,6 @@ export function rewriteMonorepoProject( if (!deferLazyPluginWrapping) { wrapLazyPluginsInViteConfig(projectPath, silent, report); migrateTaskCacheConfigInViteConfig(projectPath, silent, report); + warnEsbuildConfigInViteConfig(projectPath, report); } } diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index 96e6e6d46f..3ae20a66fd 100644 --- a/packages/cli/src/migration/migrator/vite-config.ts +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -886,6 +886,39 @@ export function migrateTaskCacheConfigInViteConfig( return true; } +/** + * Vite 8 no longer bundles esbuild, so esbuild-specific config now fails + * at build time instead of silently working: `build.minify: 'esbuild'` + * routes to the deprecated `transformWithEsbuild`, which requires a + * separate esbuild install, and direct `transformWithEsbuild` usage has + * no vite-plus equivalent. migrate only rewrites the imports vite-plus + * owns (config semantics stay user-owned), so surface these as warnings + * pointing at the oxc replacements. + */ +export function warnEsbuildConfigInViteConfig( + projectPath: string, + report?: MigrationReport, +): void { + const configs = detectConfigs(projectPath); + if (!configs.viteConfig) { + return; + } + const viteConfigPath = path.join(projectPath, configs.viteConfig); + const source = fs.readFileSync(viteConfigPath, 'utf8'); + if (/\bminify\s*:\s*(['"])esbuild\1/.test(source)) { + warnMigration( + `${displayRelative(viteConfigPath)}: \`build.minify: "esbuild"\` no longer works because Vite 8 no longer bundles esbuild. Set \`minify: true\` (oxc minifier) or remove the option.`, + report, + ); + } + if (/\btransformWithEsbuild\b/.test(source)) { + warnMigration( + `${displayRelative(viteConfigPath)}: \`transformWithEsbuild\` requires a separate esbuild install under Vite 8. Migrate to \`transformWithOxc\` instead.`, + report, + ); + } +} + /** * Rewrite imports in all TypeScript/JavaScript files under a directory * This rewrites vite/vitest imports to @voidzero-dev/vite-plus From f9be853b3aad8cf3103789bb8860e43fc79eb01f Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 21 Oct 2026 14:36:52 -0400 Subject: [PATCH 2/5] refactor(migrate): rewrite static esbuild minify config, AST-driven Review feedback on #3178: extend the diagnostic into a targeted config migration, use AST analysis instead of a regex, and acknowledge that Vite 8 still supports esbuild when it is installed separately. - build.minify: 'esbuild' inside a default-exported config is rewritten in place to minify: true (the oxc minifier) via span replacement: comments and unrelated plugin options are untouched, quoted keys ('minify') are handled, and the switch is reported through the success log plus a new esbuildMinifyMigrationCount summary counter. - Dynamic minify values, minify options outside the build object, and transformWithEsbuild usage keep their code and get a diagnostic that presents both options: install esbuild to keep the behavior, or migrate to oxc. - Detection walks the serialized oxc AST (analyzeMigrationSource) and covers defineConfig() calls, env-config arrays, TS wrappers, and quoted keys; raw-text scanning is now only a diagnostic fallback and skips comment ranges. - Warning wording no longer implies esbuild support is gone, and the rewrite prefers minify: true over removing the property (removal can disable minification for SSR builds). --- .../src/migration/__tests__/migrator.spec.ts | 117 ++++++++-- packages/cli/src/migration/bin.ts | 3 +- .../migration/migrator/core-finalization.ts | 4 +- .../src/migration/migrator/orchestrators.ts | 10 +- .../cli/src/migration/migrator/vite-config.ts | 203 ++++++++++++++++-- packages/cli/src/migration/report.ts | 2 + 6 files changed, 299 insertions(+), 40 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 84e859235b..619dd01480 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -52,6 +52,7 @@ const { injectLintTypeCheckDefaults, ensureSvelteRuneGlobals, mergeViteConfigFiles, + migrateEsbuildConfigInViteConfig, rewriteEslintPackageJson, collectInstalledPackageNames, sanitizeMigratedOxlintConfig, @@ -67,7 +68,6 @@ const { pnpmSupportsWorkspaceSettings, pnpmOverrideKey, setPackageManager, - warnEsbuildConfigInViteConfig, } = await import('../migrator.js'); // pnpm's managed override keys are range-qualified so they never rewrite an @@ -9544,7 +9544,7 @@ describe('collectMigrationSetupPlan non-interactive editor conflicts', () => { }); }); -describe('warnEsbuildConfigInViteConfig', () => { +describe('migrateEsbuildConfigInViteConfig', () => { let tmpDir: string; beforeEach(() => { @@ -9555,26 +9555,102 @@ describe('warnEsbuildConfigInViteConfig', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - it('warns when build.minify is set to "esbuild"', () => { + it('rewrites static build.minify esbuild to true and preserves comments', () => { fs.writeFileSync( path.join(tmpDir, 'vite.config.ts'), `import { defineConfig } from 'vite-plus'; export default defineConfig({ - build: { minify: 'esbuild' }, + build: { + /* keep me */ + minify: 'esbuild', + sourcemap: true, + }, +}); +`, + ); + const report = createMigrationReport(); + + const rewritten = migrateEsbuildConfigInViteConfig(tmpDir, true, report); + + expect(rewritten).toBe(true); + const after = fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8'); + expect(after).toContain('minify: true'); + expect(after).toContain('/* keep me */'); + expect(after).not.toContain('esbuild'); + expect(report.esbuildMinifyMigrationCount).toBe(1); + expect(report.warnings).toHaveLength(0); + }); + + it('rewrites quoted minify keys', () => { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `export default { + build: { + 'minify': 'esbuild', + }, +}; +`, + ); + const report = createMigrationReport(); + + migrateEsbuildConfigInViteConfig(tmpDir, true, report); + + const after = fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8'); + expect(after).toContain(`'minify': true`); + expect(report.esbuildMinifyMigrationCount).toBe(1); + }); + + it('leaves unrelated minify options alone but diagnoses them', () => { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { somePlugin } from 'some-plugin'; + +export default defineConfig({ + plugins: [somePlugin({ minify: 'esbuild' })], }); `, ); const report = createMigrationReport(); - warnEsbuildConfigInViteConfig(tmpDir, report); + migrateEsbuildConfigInViteConfig(tmpDir, true, report); + const after = fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8'); + expect(after).toBe(`import { somePlugin } from 'some-plugin'; + +export default defineConfig({ + plugins: [somePlugin({ minify: 'esbuild' })], +}); +`); + expect(report.esbuildMinifyMigrationCount).toBe(0); + expect(report.warnings).toHaveLength(1); + expect(report.warnings[0]).toContain('esbuild'); + }); + + it('diagnoses dynamic minify values without rewriting them', () => { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { defineConfig } from 'vite-plus'; + +const useEsbuild = () => false; + +export default defineConfig({ + build: { minify: useEsbuild() ? 'esbuild' : true }, +}); +`, + ); + const report = createMigrationReport(); + + migrateEsbuildConfigInViteConfig(tmpDir, true, report); + + const after = fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8'); + expect(after).toContain(`'esbuild' : true`); + expect(report.esbuildMinifyMigrationCount).toBe(0); expect(report.warnings).toHaveLength(1); - expect(report.warnings[0]).toContain('vite.config.ts'); - expect(report.warnings[0]).toContain('minify: true'); + expect(report.warnings[0]).toContain('installing `esbuild` separately'); }); - it('warns on direct transformWithEsbuild usage', () => { + it('diagnoses transformWithEsbuild usage', () => { fs.writeFileSync( path.join(tmpDir, 'vite.config.ts'), `import { transformWithEsbuild } from 'vite'; @@ -9593,27 +9669,30 @@ export default { ); const report = createMigrationReport(); - warnEsbuildConfigInViteConfig(tmpDir, report); + migrateEsbuildConfigInViteConfig(tmpDir, true, report); + expect(report.esbuildMinifyMigrationCount).toBe(0); expect(report.warnings).toHaveLength(1); expect(report.warnings[0]).toContain('transformWithOxc'); }); - it('warns separately when both esbuild patterns are present', () => { + it('ignores minify esbuild mentions inside comments', () => { fs.writeFileSync( path.join(tmpDir, 'vite.config.ts'), - `import { transformWithEsbuild } from 'vite'; + `import { defineConfig } from 'vite-plus'; -export default { - build: { minify: 'esbuild' }, -}; +// The old config used minify: "esbuild" before we migrated. +export default defineConfig({ + build: { minify: true }, +}); `, ); const report = createMigrationReport(); - warnEsbuildConfigInViteConfig(tmpDir, report); + migrateEsbuildConfigInViteConfig(tmpDir, true, report); - expect(report.warnings).toHaveLength(2); + expect(report.esbuildMinifyMigrationCount).toBe(0); + expect(report.warnings).toHaveLength(0); }); it('does not warn for oxc-compatible configs', () => { @@ -9628,15 +9707,17 @@ export default defineConfig({ ); const report = createMigrationReport(); - warnEsbuildConfigInViteConfig(tmpDir, report); + migrateEsbuildConfigInViteConfig(tmpDir, true, report); + expect(report.esbuildMinifyMigrationCount).toBe(0); expect(report.warnings).toHaveLength(0); }); it('does nothing when no vite config exists', () => { const report = createMigrationReport(); - expect(() => warnEsbuildConfigInViteConfig(tmpDir, report)).not.toThrow(); + expect(() => migrateEsbuildConfigInViteConfig(tmpDir, true, report)).not.toThrow(); + expect(report.esbuildMinifyMigrationCount).toBe(0); expect(report.warnings).toHaveLength(0); }); }); diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index d374bb2b52..7590524bfe 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -505,7 +505,8 @@ function showMigrationSummary(options: { report.removedConfigCount + report.tsdownImportCount + report.wrappedPluginConfigCount + - report.migratedTaskCacheConfigCount; + report.migratedTaskCacheConfigCount + + report.esbuildMinifyMigrationCount; log( `${styleText('magenta', '◇')} ${updatedExistingVitePlus ? 'Updated' : 'Migrated'} ${accent(projectLabel)} to Vite+ ${VITE_PLUS_VERSION}`, diff --git a/packages/cli/src/migration/migrator/core-finalization.ts b/packages/cli/src/migration/migrator/core-finalization.ts index 5873b594aa..e6e8db92d1 100644 --- a/packages/cli/src/migration/migrator/core-finalization.ts +++ b/packages/cli/src/migration/migrator/core-finalization.ts @@ -12,7 +12,7 @@ import { migrateTaskCacheConfigInViteConfig, rewriteAllImports, rewriteTsconfigTypes, - warnEsbuildConfigInViteConfig, + migrateEsbuildConfigInViteConfig, } from '../migrator.ts'; import { type MigrationReport } from '../report.ts'; @@ -190,7 +190,7 @@ export function finalizeCoreMigrationForExistingVitePlus( result.taskCacheConfig = migrateTaskCacheConfigInViteConfig(projectPath, silent, report, result.taskCacheWarnings) || result.taskCacheConfig; - warnEsbuildConfigInViteConfig(projectPath, report); + migrateEsbuildConfigInViteConfig(projectPath, silent, report); } return result; diff --git a/packages/cli/src/migration/migrator/orchestrators.ts b/packages/cli/src/migration/migrator/orchestrators.ts index 8eff841528..50e1fa0b74 100644 --- a/packages/cli/src/migration/migrator/orchestrators.ts +++ b/packages/cli/src/migration/migrator/orchestrators.ts @@ -57,7 +57,7 @@ import { workspaceUsesVitestDirectly, workspaceUsesWebdriverio, workspaceUsesWebdriverioProvider, - warnEsbuildConfigInViteConfig, + migrateEsbuildConfigInViteConfig, wrapLazyPluginsInViteConfig, } from '../migrator.ts'; import { type MigrationReport } from '../report.ts'; @@ -346,7 +346,7 @@ export function rewriteStandaloneProject( dropDeadOxlintPluginsDependency(projectPath, workspaceInfo.packages, originalOxlintDependencies); wrapLazyPluginsInViteConfig(projectPath, silent, report); migrateTaskCacheConfigInViteConfig(projectPath, silent, report); - warnEsbuildConfigInViteConfig(projectPath, report); + migrateEsbuildConfigInViteConfig(projectPath, silent, report); } /** @@ -512,12 +512,12 @@ export function rewriteMonorepo( ); wrapLazyPluginsInViteConfig(workspaceInfo.rootDir, silent, report); migrateTaskCacheConfigInViteConfig(workspaceInfo.rootDir, silent, report); - warnEsbuildConfigInViteConfig(workspaceInfo.rootDir, report); + migrateEsbuildConfigInViteConfig(workspaceInfo.rootDir, silent, report); for (const pkg of workspaceInfo.packages) { const projectPath = path.join(workspaceInfo.rootDir, pkg.path); wrapLazyPluginsInViteConfig(projectPath, silent, report); migrateTaskCacheConfigInViteConfig(projectPath, silent, report); - warnEsbuildConfigInViteConfig(projectPath, report); + migrateEsbuildConfigInViteConfig(projectPath, silent, report); } } @@ -645,6 +645,6 @@ export function rewriteMonorepoProject( if (!deferLazyPluginWrapping) { wrapLazyPluginsInViteConfig(projectPath, silent, report); migrateTaskCacheConfigInViteConfig(projectPath, silent, report); - warnEsbuildConfigInViteConfig(projectPath, report); + migrateEsbuildConfigInViteConfig(projectPath, silent, report); } } diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index 3ae20a66fd..1a10b4317b 100644 --- a/packages/cli/src/migration/migrator/vite-config.ts +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -887,36 +887,211 @@ export function migrateTaskCacheConfigInViteConfig( } /** - * Vite 8 no longer bundles esbuild, so esbuild-specific config now fails - * at build time instead of silently working: `build.minify: 'esbuild'` - * routes to the deprecated `transformWithEsbuild`, which requires a - * separate esbuild install, and direct `transformWithEsbuild` usage has - * no vite-plus equivalent. migrate only rewrites the imports vite-plus - * owns (config semantics stay user-owned), so surface these as warnings - * pointing at the oxc replacements. + * esbuild-specific config under Vite 8: the bundled esbuild is gone, so + * `build.minify: 'esbuild'` routes to a deprecated `transformWithEsbuild` + * path that requires a separate esbuild install. + * + * Static case — `minify: 'esbuild'` inside the `build` object of a + * default-exported config — is rewritten in place to `minify: true` (the + * oxc minifier) via span replacement, which preserves all comments and + * leaves unrelated plugin options untouched. There is no esbuild-specific + * behavior to preserve in that shape, and rewriting (rather than removing + * the property) keeps minification enabled for SSR builds. + * + * Everything else — dynamic minify values, `minify` options outside the + * build object, and `transformWithEsbuild` usage that may need argument + * changes — keeps its code and gets a diagnostic. Vite 8 still supports + * esbuild-specific config when esbuild is installed separately, so + * retaining it is a valid choice and the diagnostics say so. */ -export function warnEsbuildConfigInViteConfig( +export function migrateEsbuildConfigInViteConfig( projectPath: string, + silent = false, report?: MigrationReport, -): void { +): boolean { const configs = detectConfigs(projectPath); if (!configs.viteConfig) { - return; + return false; } const viteConfigPath = path.join(projectPath, configs.viteConfig); const source = fs.readFileSync(viteConfigPath, 'utf8'); - if (/\bminify\s*:\s*(['"])esbuild\1/.test(source)) { + const { ast, comments } = JSON.parse(analyzeMigrationSource(viteConfigPath, source)) as { + ast: { node: Program }; + comments: Array<{ start: number; end: number }>; + }; + + const minifyValueSpans: Array = []; + let hasDynamicMinify = false; + let referencesTransformWithEsbuild = false; + for (const statement of ast.node.body) { + if (statement.type !== 'ExportDefaultDeclaration') { + continue; + } + for (const configObject of staticConfigObjects(statement.declaration)) { + const buildProp = configObject.properties?.find((prop) => propertyName(prop) === 'build'); + if (!buildProp || buildProp.value.type !== 'ObjectExpression') { + continue; + } + // Guarded by the ObjectExpression check above. + const buildObject = buildProp.value as OxcObjectNode; + const minifyProp = buildObject.properties?.find((prop) => propertyName(prop) === 'minify'); + if (!minifyProp) { + continue; + } + const { value } = minifyProp; + if (value.type === 'Literal' && value.value === 'esbuild') { + minifyValueSpans.push({ start: value.start, end: value.end }); + } else if (value.type !== 'Literal') { + // Dynamic (non-literal) values can't be migrated blind. + hasDynamicMinify = true; + } + // Static non-esbuild literals (`true`, `false`, `'terser'`, ...) are + // valid user choices and stay untouched. + } + } + for (const node of walkAstNodes(ast.node)) { + if (node.type === 'Identifier' && node.name === 'transformWithEsbuild') { + referencesTransformWithEsbuild = true; + } + } + + // Raw occurrences outside comments that the static walk did not account + // for (spread configs, helper-built objects, non-default exports) are + // surfaced as diagnostics — never rewritten blind. + const rawOccurrences = [...source.matchAll(/\bminify\s*:\s*(['"])esbuild\1/g)].filter( + (match) => + !comments.some((comment) => match.index >= comment.start && match.index < comment.end), + ).length; + const hasUnaccountedMinify = rawOccurrences > minifyValueSpans.length; + + if (minifyValueSpans.length > 0) { + let content = source; + for (const span of minifyValueSpans.toSorted((first, second) => second.start - first.start)) { + content = content.slice(0, span.start) + 'true' + content.slice(span.end); + } + fs.writeFileSync(viteConfigPath, content); + if (report) { + report.esbuildMinifyMigrationCount += minifyValueSpans.length; + } + if (!silent) { + prompts.log.success( + `✔ Switched \`build.minify\` from \`esbuild\` to the oxc minifier (\`minify: true\`) in ${displayRelative(viteConfigPath)}`, + ); + } + } + if (hasDynamicMinify || hasUnaccountedMinify) { warnMigration( - `${displayRelative(viteConfigPath)}: \`build.minify: "esbuild"\` no longer works because Vite 8 no longer bundles esbuild. Set \`minify: true\` (oxc minifier) or remove the option.`, + `${displayRelative(viteConfigPath)}: \`minify: "esbuild"\` requires installing \`esbuild\` separately under Vite 8. If you rely on esbuild minification, install it and keep the config; otherwise prefer \`minify: true\` (the oxc minifier).`, report, ); } - if (/\btransformWithEsbuild\b/.test(source)) { + if (referencesTransformWithEsbuild) { warnMigration( - `${displayRelative(viteConfigPath)}: \`transformWithEsbuild\` requires a separate esbuild install under Vite 8. Migrate to \`transformWithOxc\` instead.`, + `${displayRelative(viteConfigPath)}: \`transformWithEsbuild\` requires installing \`esbuild\` separately under Vite 8. Migrate to \`transformWithOxc\` if you do not need exact esbuild behavior, or install \`esbuild\` to keep the current behavior.`, report, ); } + return minifyValueSpans.length > 0; +} + +interface SpanLike { + start: number; + end: number; +} + +// Minimal shapes for the serialized oxc AST returned by +// analyzeMigrationSource. Unknown fields stay behind the index signature; +// specific members are declared where this module relies on them. +interface OxcSpan { + start: number; + end: number; +} + +interface OxcNode extends OxcSpan { + type: string; + [key: string]: unknown; +} + +interface OxcProperty { + key?: OxcNode; + value: OxcNode; +} + +interface OxcObjectNode extends OxcNode { + properties?: OxcProperty[]; +} + +// Key name for object properties: bare identifiers and quoted strings both +// count (`minify:` and `'minify':` are the same option). +function propertyName(prop: OxcProperty): string | undefined { + const { key } = prop; + if (!key) { + return undefined; + } + if (key.type === 'Identifier' && typeof key.name === 'string') { + return key.name; + } + if (key.type === 'Literal' && typeof key.value === 'string') { + return key.value; + } + return undefined; +} + +// Unwrap a default-exported declaration into the config objects it +// statically exposes: the object itself, defineConfig({...}) and +// defineConfig([{...}, ...]) arguments, and TS wrappers (as / satisfies). +function* staticConfigObjects(node: unknown): Generator { + if (Array.isArray(node)) { + for (const item of node) { + yield* staticConfigObjects(item); + } + return; + } + if (!node || typeof node !== 'object') { + return; + } + const n = node as OxcNode; + switch (n.type) { + case 'ObjectExpression': + yield n; + return; + case 'CallExpression': + case 'NewExpression': + yield* staticConfigObjects(n.arguments); + return; + case 'ArrayExpression': + yield* staticConfigObjects(n.elements); + return; + case 'TSAsExpression': + case 'TSSatisfiesExpression': + case 'TSNonNullExpression': + case 'ParenthesizedExpression': + yield* staticConfigObjects(n.expression); + return; + default: + return; + } +} + +// Every node in the serialized oxc AST, for name-level scans. +function* walkAstNodes(node: unknown): Generator { + if (Array.isArray(node)) { + for (const item of node) { + yield* walkAstNodes(item); + } + return; + } + if (!node || typeof node !== 'object') { + return; + } + const n = node as OxcNode; + yield n; + for (const [key, value] of Object.entries(n)) { + if (key === 'start' || key === 'end' || key === 'type') { + continue; + } + yield* walkAstNodes(value); + } } /** diff --git a/packages/cli/src/migration/report.ts b/packages/cli/src/migration/report.ts index eebf554c9f..31c2546c2e 100644 --- a/packages/cli/src/migration/report.ts +++ b/packages/cli/src/migration/report.ts @@ -19,6 +19,7 @@ export interface MigrationReport { tsdownImportCount: number; wrappedPluginConfigCount: number; migratedTaskCacheConfigCount: number; + esbuildMinifyMigrationCount: number; rewrittenImportFileCount: number; preservedUpstreamVitestImportFileCount: number; rewrittenImportErrors: Array<{ path: string; message: string }>; @@ -45,6 +46,7 @@ export function createMigrationReport(): MigrationReport { tsdownImportCount: 0, wrappedPluginConfigCount: 0, migratedTaskCacheConfigCount: 0, + esbuildMinifyMigrationCount: 0, rewrittenImportFileCount: 0, preservedUpstreamVitestImportFileCount: 0, rewrittenImportErrors: [], From 7d418492dc06e8c45d579cb57d46a404f40e2da2 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 21 Oct 2026 14:47:16 -0400 Subject: [PATCH 3/5] style(migrate): match house conventions in the esbuild migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit pass over the two esbuild-migration commits: - finalizeCoreMigrationForExistingVitePlus tracks every migration's outcome in CoreMigrationFinalizationResult; the esbuild migration now does too (esbuildMinifyConfig flag), and the deep-equal result expectations in migrator.spec.ts are updated accordingly. - Helper placement: this file declares helpers before their consumers; the oxc AST helpers sat after migrateEsbuildConfigInViteConfig — moved above it. - Dropped the duplicate SpanLike interface (OxcSpan already describes the same shape). - Condensed the migrateEsbuildConfigInViteConfig JSDoc to the length used by sibling migrations. --- .../src/migration/__tests__/migrator.spec.ts | 5 + .../migration/migrator/core-finalization.ts | 5 +- .../cli/src/migration/migrator/vite-config.ts | 212 ++++++++---------- 3 files changed, 108 insertions(+), 114 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 619dd01480..acd9cf61e0 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -8665,6 +8665,7 @@ describe('existing Vite+ core migration finalization', () => { imports: true, tsdownConfig: false, taskCacheConfig: false, + esbuildMinifyConfig: false, taskCacheWarnings: [], }); @@ -8744,6 +8745,7 @@ export default defineConfig({ imports: true, tsdownConfig: true, taskCacheConfig: false, + esbuildMinifyConfig: false, taskCacheWarnings: [], }); expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf8')).toContain( @@ -8763,6 +8765,7 @@ export default defineConfig({ imports: false, tsdownConfig: false, taskCacheConfig: false, + esbuildMinifyConfig: false, taskCacheWarnings: [], }); }); @@ -8802,6 +8805,7 @@ export default defineConfig({ entry: 'src/index.ts' }); imports: true, tsdownConfig: false, taskCacheConfig: false, + esbuildMinifyConfig: false, taskCacheWarnings: [], }); expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf8')).toBe(originalViteConfig); @@ -8866,6 +8870,7 @@ export default defineConfig({ expect(finalizeCoreMigrationForExistingVitePlus(workspaceInfo, true, report)).toMatchObject({ taskCacheConfig: false, + esbuildMinifyConfig: false, taskCacheWarnings: [], }); expect(report.migratedTaskCacheConfigCount).toBe(2); diff --git a/packages/cli/src/migration/migrator/core-finalization.ts b/packages/cli/src/migration/migrator/core-finalization.ts index e6e8db92d1..3d92e917ae 100644 --- a/packages/cli/src/migration/migrator/core-finalization.ts +++ b/packages/cli/src/migration/migrator/core-finalization.ts @@ -87,6 +87,7 @@ export type CoreMigrationFinalizationResult = { imports: boolean; tsdownConfig: boolean; taskCacheConfig: boolean; + esbuildMinifyConfig: boolean; // Kept out of the report so that review items alone do not make an // up-to-date project run the rest of the migration. taskCacheWarnings: string[]; @@ -157,6 +158,7 @@ export function finalizeCoreMigrationForExistingVitePlus( imports: false, tsdownConfig: false, taskCacheConfig: false, + esbuildMinifyConfig: false, taskCacheWarnings: [], }; @@ -190,7 +192,8 @@ export function finalizeCoreMigrationForExistingVitePlus( result.taskCacheConfig = migrateTaskCacheConfigInViteConfig(projectPath, silent, report, result.taskCacheWarnings) || result.taskCacheConfig; - migrateEsbuildConfigInViteConfig(projectPath, silent, report); + result.esbuildMinifyConfig = + migrateEsbuildConfigInViteConfig(projectPath, silent, report) || result.esbuildMinifyConfig; } return result; diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index 1a10b4317b..240c83654e 100644 --- a/packages/cli/src/migration/migrator/vite-config.ts +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -886,119 +886,6 @@ export function migrateTaskCacheConfigInViteConfig( return true; } -/** - * esbuild-specific config under Vite 8: the bundled esbuild is gone, so - * `build.minify: 'esbuild'` routes to a deprecated `transformWithEsbuild` - * path that requires a separate esbuild install. - * - * Static case — `minify: 'esbuild'` inside the `build` object of a - * default-exported config — is rewritten in place to `minify: true` (the - * oxc minifier) via span replacement, which preserves all comments and - * leaves unrelated plugin options untouched. There is no esbuild-specific - * behavior to preserve in that shape, and rewriting (rather than removing - * the property) keeps minification enabled for SSR builds. - * - * Everything else — dynamic minify values, `minify` options outside the - * build object, and `transformWithEsbuild` usage that may need argument - * changes — keeps its code and gets a diagnostic. Vite 8 still supports - * esbuild-specific config when esbuild is installed separately, so - * retaining it is a valid choice and the diagnostics say so. - */ -export function migrateEsbuildConfigInViteConfig( - projectPath: string, - silent = false, - report?: MigrationReport, -): boolean { - const configs = detectConfigs(projectPath); - if (!configs.viteConfig) { - return false; - } - const viteConfigPath = path.join(projectPath, configs.viteConfig); - const source = fs.readFileSync(viteConfigPath, 'utf8'); - const { ast, comments } = JSON.parse(analyzeMigrationSource(viteConfigPath, source)) as { - ast: { node: Program }; - comments: Array<{ start: number; end: number }>; - }; - - const minifyValueSpans: Array = []; - let hasDynamicMinify = false; - let referencesTransformWithEsbuild = false; - for (const statement of ast.node.body) { - if (statement.type !== 'ExportDefaultDeclaration') { - continue; - } - for (const configObject of staticConfigObjects(statement.declaration)) { - const buildProp = configObject.properties?.find((prop) => propertyName(prop) === 'build'); - if (!buildProp || buildProp.value.type !== 'ObjectExpression') { - continue; - } - // Guarded by the ObjectExpression check above. - const buildObject = buildProp.value as OxcObjectNode; - const minifyProp = buildObject.properties?.find((prop) => propertyName(prop) === 'minify'); - if (!minifyProp) { - continue; - } - const { value } = minifyProp; - if (value.type === 'Literal' && value.value === 'esbuild') { - minifyValueSpans.push({ start: value.start, end: value.end }); - } else if (value.type !== 'Literal') { - // Dynamic (non-literal) values can't be migrated blind. - hasDynamicMinify = true; - } - // Static non-esbuild literals (`true`, `false`, `'terser'`, ...) are - // valid user choices and stay untouched. - } - } - for (const node of walkAstNodes(ast.node)) { - if (node.type === 'Identifier' && node.name === 'transformWithEsbuild') { - referencesTransformWithEsbuild = true; - } - } - - // Raw occurrences outside comments that the static walk did not account - // for (spread configs, helper-built objects, non-default exports) are - // surfaced as diagnostics — never rewritten blind. - const rawOccurrences = [...source.matchAll(/\bminify\s*:\s*(['"])esbuild\1/g)].filter( - (match) => - !comments.some((comment) => match.index >= comment.start && match.index < comment.end), - ).length; - const hasUnaccountedMinify = rawOccurrences > minifyValueSpans.length; - - if (minifyValueSpans.length > 0) { - let content = source; - for (const span of minifyValueSpans.toSorted((first, second) => second.start - first.start)) { - content = content.slice(0, span.start) + 'true' + content.slice(span.end); - } - fs.writeFileSync(viteConfigPath, content); - if (report) { - report.esbuildMinifyMigrationCount += minifyValueSpans.length; - } - if (!silent) { - prompts.log.success( - `✔ Switched \`build.minify\` from \`esbuild\` to the oxc minifier (\`minify: true\`) in ${displayRelative(viteConfigPath)}`, - ); - } - } - if (hasDynamicMinify || hasUnaccountedMinify) { - warnMigration( - `${displayRelative(viteConfigPath)}: \`minify: "esbuild"\` requires installing \`esbuild\` separately under Vite 8. If you rely on esbuild minification, install it and keep the config; otherwise prefer \`minify: true\` (the oxc minifier).`, - report, - ); - } - if (referencesTransformWithEsbuild) { - warnMigration( - `${displayRelative(viteConfigPath)}: \`transformWithEsbuild\` requires installing \`esbuild\` separately under Vite 8. Migrate to \`transformWithOxc\` if you do not need exact esbuild behavior, or install \`esbuild\` to keep the current behavior.`, - report, - ); - } - return minifyValueSpans.length > 0; -} - -interface SpanLike { - start: number; - end: number; -} - // Minimal shapes for the serialized oxc AST returned by // analyzeMigrationSource. Unknown fields stay behind the index signature; // specific members are declared where this module relies on them. @@ -1094,6 +981,105 @@ function* walkAstNodes(node: unknown): Generator { } } +/** + * esbuild-specific config fails under Vite 8 unless esbuild is installed + * separately. The static case — `build.minify: 'esbuild'` in a + * default-exported config — is rewritten in place to `minify: true` (the + * oxc minifier); span replacement keeps comments and unrelated options + * intact, and the switch is reported. Dynamic values, `minify` outside + * `build`, and `transformWithEsbuild` usage keep their code and get a + * diagnostic covering both the keep-esbuild and migrate-to-oxc paths. + */ +export function migrateEsbuildConfigInViteConfig( + projectPath: string, + silent = false, + report?: MigrationReport, +): boolean { + const configs = detectConfigs(projectPath); + if (!configs.viteConfig) { + return false; + } + const viteConfigPath = path.join(projectPath, configs.viteConfig); + const source = fs.readFileSync(viteConfigPath, 'utf8'); + const { ast, comments } = JSON.parse(analyzeMigrationSource(viteConfigPath, source)) as { + ast: { node: Program }; + comments: Array<{ start: number; end: number }>; + }; + + const minifyValueSpans: Array = []; + let hasDynamicMinify = false; + let referencesTransformWithEsbuild = false; + for (const statement of ast.node.body) { + if (statement.type !== 'ExportDefaultDeclaration') { + continue; + } + for (const configObject of staticConfigObjects(statement.declaration)) { + const buildProp = configObject.properties?.find((prop) => propertyName(prop) === 'build'); + if (!buildProp || buildProp.value.type !== 'ObjectExpression') { + continue; + } + // Guarded by the ObjectExpression check above. + const buildObject = buildProp.value as OxcObjectNode; + const minifyProp = buildObject.properties?.find((prop) => propertyName(prop) === 'minify'); + if (!minifyProp) { + continue; + } + const { value } = minifyProp; + if (value.type === 'Literal' && value.value === 'esbuild') { + minifyValueSpans.push({ start: value.start, end: value.end }); + } else if (value.type !== 'Literal') { + // Dynamic (non-literal) values can't be migrated blind. + hasDynamicMinify = true; + } + // Static non-esbuild literals (`true`, `false`, `'terser'`, ...) are + // valid user choices and stay untouched. + } + } + for (const node of walkAstNodes(ast.node)) { + if (node.type === 'Identifier' && node.name === 'transformWithEsbuild') { + referencesTransformWithEsbuild = true; + } + } + + // Raw occurrences outside comments that the static walk did not account + // for (spread configs, helper-built objects, non-default exports) are + // surfaced as diagnostics — never rewritten blind. + const rawOccurrences = [...source.matchAll(/\bminify\s*:\s*(['"])esbuild\1/g)].filter( + (match) => + !comments.some((comment) => match.index >= comment.start && match.index < comment.end), + ).length; + const hasUnaccountedMinify = rawOccurrences > minifyValueSpans.length; + + if (minifyValueSpans.length > 0) { + let content = source; + for (const span of minifyValueSpans.toSorted((first, second) => second.start - first.start)) { + content = content.slice(0, span.start) + 'true' + content.slice(span.end); + } + fs.writeFileSync(viteConfigPath, content); + if (report) { + report.esbuildMinifyMigrationCount += minifyValueSpans.length; + } + if (!silent) { + prompts.log.success( + `✔ Switched \`build.minify\` from \`esbuild\` to the oxc minifier (\`minify: true\`) in ${displayRelative(viteConfigPath)}`, + ); + } + } + if (hasDynamicMinify || hasUnaccountedMinify) { + warnMigration( + `${displayRelative(viteConfigPath)}: \`minify: "esbuild"\` requires installing \`esbuild\` separately under Vite 8. If you rely on esbuild minification, install it and keep the config; otherwise prefer \`minify: true\` (the oxc minifier).`, + report, + ); + } + if (referencesTransformWithEsbuild) { + warnMigration( + `${displayRelative(viteConfigPath)}: \`transformWithEsbuild\` requires installing \`esbuild\` separately under Vite 8. Migrate to \`transformWithOxc\` if you do not need exact esbuild behavior, or install \`esbuild\` to keep the current behavior.`, + report, + ); + } + return minifyValueSpans.length > 0; +} + /** * Rewrite imports in all TypeScript/JavaScript files under a directory * This rewrites vite/vitest imports to @voidzero-dev/vite-plus From e7ed78e04de97918b27e77dffd3a09cb902d08e8 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 23 Oct 2026 11:05:38 -0400 Subject: [PATCH 4/5] fix(migrate): mark the esbuild minify rewrite as a completed migration finalizeCoreMigrationForExistingVitePlus records the build.minify 'esbuild' -> true switch in esbuildMinifyConfig, but the didMigrate predicate never read it. On an otherwise up-to-date existing Vite+ project the static rewrite emits no warnings, so the 'already using Vite+' fast exit swallowed the change: the summary, the formatting pass, and any mention of the switch were skipped even though vite.config was rewritten on disk. Include the flag in the predicate so the rewrite surfaces like every other config update. Covered by a new CLI snapshot fixture that reintroduces minify: 'esbuild' on a converged project (fails without the fix) and a unit test asserting the flag and its convergence. --- .../package.json | 7 +++ .../snapshots.toml | 12 +++++ ...ation_existing_vite_plus_esbuild_minify.md | 35 ++++++++++++++ .../vite.config.ts | 8 ++++ .../src/migration/__tests__/migrator.spec.ts | 48 +++++++++++++++++++ packages/cli/src/migration/bin.ts | 3 +- 6 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/package.json create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/snapshots.toml create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/snapshots/migration_existing_vite_plus_esbuild_minify.md create mode 100644 crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/vite.config.ts diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/package.json new file mode 100644 index 0000000000..e74d50f79f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/package.json @@ -0,0 +1,7 @@ +{ + "name": "migration-existing-vite-plus-esbuild-minify", + "packageManager": "pnpm@12.11.2", + "devDependencies": { + "vite-plus": "latest" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/snapshots.toml new file mode 100644 index 0000000000..eca66ecb79 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/snapshots.toml @@ -0,0 +1,12 @@ +[[case]] +name = "migration_existing_vite_plus_esbuild_minify" +vp = "global" +skip-platforms = ["windows"] +local-registry = true +unset-env = ["VP_SKIP_INSTALL"] +steps = [ + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], snapshot = false, comment = "Converge the project to a fully migrated Vite+ project; this pass rewrites the import and switches the minifier." }, + { argv = ["vpt", "replace-file-content", "vite.config.ts", "minify: true", "minify: 'esbuild'"], snapshot = false, comment = "Reintroduce the Vite 7-era esbuild minify as the project's ONLY pending change." }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "The static minify rewrite emits no warnings, so it must mark the migration as done instead of hitting the already-using-Vite+ fast exit." }, + { argv = ["vpt", "grep-file", "vite.config.ts", "minify: true"], comment = "The rewrite is applied to vite.config." }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/snapshots/migration_existing_vite_plus_esbuild_minify.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/snapshots/migration_existing_vite_plus_esbuild_minify.md new file mode 100644 index 0000000000..2ca49198c5 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/snapshots/migration_existing_vite_plus_esbuild_minify.md @@ -0,0 +1,35 @@ +# migration_existing_vite_plus_esbuild_minify + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +Converge the project to a fully migrated Vite+ project; this pass rewrites the import and switches the minifier. + + +## `vpt replace-file-content vite.config.ts 'minify: true' 'minify: '\''esbuild'\'''` + +Reintroduce the Vite 7-era esbuild minify as the project's ONLY pending change. + + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +The static minify rewrite emits no warnings, so it must mark the migration as done instead of hitting the already-using-Vite+ fast exit. + +``` +VITE+ - The Unified Toolchain for the Web + +● Formatting code... +● Code formatted +◇ Updated . to Vite+ +• Node pnpm +• Dependencies: + vite → +• 1 config update applied +``` + +## `vpt grep-file vite.config.ts 'minify: true'` + +The rewrite is applied to vite.config. + +``` +vite.config.ts: found "minify: true" +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/vite.config.ts new file mode 100644 index 0000000000..f21e144550 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_existing_vite_plus_esbuild_minify/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + minify: 'esbuild', + sourcemap: true, + }, +}); diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index acd9cf61e0..20c219bb33 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -8912,6 +8912,54 @@ export default defineConfig({ ); expect(result.taskCacheWarnings[0]).toContain('/config/run#cache'); }); + + it('reports the esbuild minify switch as a migration on an otherwise up-to-date project', () => { + // The rewrite is the project's ONLY pending change and the static case + // emits no warnings. `didMigrate` must therefore see this flag, or the + // "already using Vite+" fast exit would swallow the vite.config rewrite + // (no summary, no formatting pass). + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'test', devDependencies: { 'vite-plus': 'latest' } }, null, 2), + ); + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + build: { + minify: 'esbuild', + sourcemap: true, + }, +}); +`, + ); + const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm); + + expect(finalizeCoreMigrationForExistingVitePlus(workspaceInfo, true)).toEqual({ + dependencies: false, + scripts: false, + tsconfigTypes: false, + imports: false, + tsdownConfig: false, + taskCacheConfig: false, + esbuildMinifyConfig: true, + taskCacheWarnings: [], + }); + expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf8')).toContain('minify: true'); + + // Converged: a second run finds nothing left to migrate. + expect(finalizeCoreMigrationForExistingVitePlus(workspaceInfo, true)).toEqual({ + dependencies: false, + scripts: false, + tsconfigTypes: false, + imports: false, + tsdownConfig: false, + taskCacheConfig: false, + esbuildMinifyConfig: false, + taskCacheWarnings: [], + }); + }); }); describe('rewriteStandaloneProject — task cache settings', () => { diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index 9cf49cb150..3dda3090bf 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -1183,7 +1183,8 @@ async function main() { coreMigrationResult.tsconfigTypes || coreMigrationResult.imports || coreMigrationResult.tsdownConfig || - coreMigrationResult.taskCacheConfig + coreMigrationResult.taskCacheConfig || + coreMigrationResult.esbuildMinifyConfig ) { didMigrate = true; } From 86d871f39d4f8b790b64f0330bacb54237d38928 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 23 Oct 2026 11:05:47 -0400 Subject: [PATCH 5/5] fix(migrate): walk defineConfig callbacks and skip computed keys in the esbuild config check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two static-analysis gaps in migrateEsbuildConfigInViteConfig: - staticConfigObjects dropped function bodies, so a production-only esbuild selection inside a callback — defineConfig(({ mode }) => ({ build: { minify: mode === 'legacy' ? 'esbuild' : true } }) — produced neither a rewrite nor a warning, and the Vite 8 build failed silently. Config factories now expose the objects built by their direct returns; expression bodies directly, block bodies via top-level return statements. Returns nested in conditionals stay with the raw-occurrence fallback diagnostics. - propertyName matched computed identifier keys, so const build = 'plugin' + export default { [build]: { minify: 'esbuild' } } was misread as Vite's build option and the unrelated string was rewritten to true. Computed identifiers no longer match; computed string literals (['build']) still do, since they are the same key. The raw-occurrence diagnostic still covers everything the static walk does not account for, so both gaps now warn instead of staying silent. --- .../src/migration/__tests__/migrator.spec.ts | 136 ++++++++++++++++++ .../cli/src/migration/migrator/vite-config.ts | 49 ++++++- 2 files changed, 178 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts index 20c219bb33..d9921d6d22 100644 --- a/packages/cli/src/migration/__tests__/migrator.spec.ts +++ b/packages/cli/src/migration/__tests__/migrator.spec.ts @@ -9680,6 +9680,142 @@ export default defineConfig({ expect(report.warnings[0]).toContain('esbuild'); }); + it('rewrites minify inside a defineConfig callback expression body', () => { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { defineConfig } from 'vite-plus'; + +export default defineConfig((config) => ({ + build: { + minify: 'esbuild', + sourcemap: true, + }, +})); +`, + ); + const report = createMigrationReport(); + + const rewritten = migrateEsbuildConfigInViteConfig(tmpDir, true, report); + + expect(rewritten).toBe(true); + const after = fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8'); + expect(after).toContain('minify: true'); + expect(report.esbuildMinifyMigrationCount).toBe(1); + expect(report.warnings).toHaveLength(0); + }); + + it('rewrites direct return statements in callback block bodies', () => { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { defineConfig } from 'vite-plus'; + +export default defineConfig(() => { + return { build: { minify: 'esbuild' } }; +}); +`, + ); + const report = createMigrationReport(); + + const rewritten = migrateEsbuildConfigInViteConfig(tmpDir, true, report); + + expect(rewritten).toBe(true); + expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8')).toContain('minify: true'); + expect(report.esbuildMinifyMigrationCount).toBe(1); + }); + + it('rewrites direct returns of classic function callbacks', () => { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { defineConfig } from 'vite-plus'; + +export default defineConfig(function (config) { + return { build: { minify: 'esbuild' } }; +}); +`, + ); + const report = createMigrationReport(); + + const rewritten = migrateEsbuildConfigInViteConfig(tmpDir, true, report); + + expect(rewritten).toBe(true); + expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8')).toContain('minify: true'); + expect(report.esbuildMinifyMigrationCount).toBe(1); + }); + + it('diagnoses a dynamic minify selection in a callback instead of rewriting it', () => { + // Production-only esbuild selection: no span is collectable, but the + // dynamic walk must still flag the config so the Vite 8 failure is not + // silent. + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `import { defineConfig } from 'vite-plus'; + +export default defineConfig(({ mode }) => ({ + build: { + minify: mode === 'legacy' ? 'esbuild' : true, + }, +})); +`, + ); + const report = createMigrationReport(); + + const rewritten = migrateEsbuildConfigInViteConfig(tmpDir, true, report); + + expect(rewritten).toBe(false); + expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8')).toContain( + `mode === 'legacy' ? 'esbuild' : true`, + ); + expect(report.esbuildMinifyMigrationCount).toBe(0); + expect(report.warnings).toHaveLength(1); + expect(report.warnings[0]).toContain('esbuild'); + }); + + it('ignores computed identifier keys that collide with option names', () => { + // `[build]` is an unrelated plugin option, not Vite's `build` key; the + // rewrite must not fire, but the raw occurrence is still diagnosed. + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `const build = 'plugin'; + +export default { + [build]: { + minify: 'esbuild', + }, +}; +`, + ); + const report = createMigrationReport(); + + const rewritten = migrateEsbuildConfigInViteConfig(tmpDir, true, report); + + expect(rewritten).toBe(false); + expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8')).toContain( + "minify: 'esbuild'", + ); + expect(report.esbuildMinifyMigrationCount).toBe(0); + expect(report.warnings).toHaveLength(1); + expect(report.warnings[0]).toContain('esbuild'); + }); + + it('treats computed string-literal keys as option names', () => { + fs.writeFileSync( + path.join(tmpDir, 'vite.config.ts'), + `export default { + ['build']: { + minify: 'esbuild', + }, +}; +`, + ); + const report = createMigrationReport(); + + const rewritten = migrateEsbuildConfigInViteConfig(tmpDir, true, report); + + expect(rewritten).toBe(true); + expect(fs.readFileSync(path.join(tmpDir, 'vite.config.ts'), 'utf-8')).toContain('minify: true'); + expect(report.esbuildMinifyMigrationCount).toBe(1); + }); + it('diagnoses dynamic minify values without rewriting them', () => { fs.writeFileSync( path.join(tmpDir, 'vite.config.ts'), diff --git a/packages/cli/src/migration/migrator/vite-config.ts b/packages/cli/src/migration/migrator/vite-config.ts index 240c83654e..749ff0f59f 100644 --- a/packages/cli/src/migration/migrator/vite-config.ts +++ b/packages/cli/src/migration/migrator/vite-config.ts @@ -902,6 +902,7 @@ interface OxcNode extends OxcSpan { interface OxcProperty { key?: OxcNode; value: OxcNode; + computed?: boolean; } interface OxcObjectNode extends OxcNode { @@ -909,13 +910,16 @@ interface OxcObjectNode extends OxcNode { } // Key name for object properties: bare identifiers and quoted strings both -// count (`minify:` and `'minify':` are the same option). +// count (`minify:` and `'minify':` are the same option), and so do computed +// string literals (`['minify']`). Computed identifiers must not: a bound +// name that happens to equal an option name (`const build = 'plugin'` + +// `[build]: {...}`) is not that option. function propertyName(prop: OxcProperty): string | undefined { const { key } = prop; if (!key) { return undefined; } - if (key.type === 'Identifier' && typeof key.name === 'string') { + if (key.type === 'Identifier' && typeof key.name === 'string' && !prop.computed) { return key.name; } if (key.type === 'Literal' && typeof key.value === 'string') { @@ -926,7 +930,9 @@ function propertyName(prop: OxcProperty): string | undefined { // Unwrap a default-exported declaration into the config objects it // statically exposes: the object itself, defineConfig({...}) and -// defineConfig([{...}, ...]) arguments, and TS wrappers (as / satisfies). +// defineConfig([{...}, ...]) arguments, TS wrappers (as / satisfies), and +// the objects that config factories (`defineConfig(() => ({...}))` and +// function equivalents) build in their direct returns. function* staticConfigObjects(node: unknown): Generator { if (Array.isArray(node)) { for (const item of node) { @@ -955,11 +961,37 @@ function* staticConfigObjects(node: unknown): Generator { case 'ParenthesizedExpression': yield* staticConfigObjects(n.expression); return; + case 'ArrowFunctionExpression': + case 'FunctionExpression': + case 'FunctionDeclaration': + yield* functionConfigObjects(n); + return; default: return; } } +// Config objects a function exposes: an expression body IS the return +// value; a block body contributes only its DIRECT return statements. +// Returns nested in conditionals stay with the raw-occurrence fallback +// diagnostics instead of being rewritten blind. +function* functionConfigObjects(fn: OxcNode): Generator { + const body = fn.body as OxcNode | undefined; + if (!body) { + return; + } + if (body.type !== 'BlockStatement') { + yield* staticConfigObjects(body); + return; + } + for (const statement of (body.body as unknown[] | undefined) ?? []) { + const node = statement as OxcNode; + if (node.type === 'ReturnStatement' && node.argument) { + yield* staticConfigObjects(node.argument); + } + } +} + // Every node in the serialized oxc AST, for name-level scans. function* walkAstNodes(node: unknown): Generator { if (Array.isArray(node)) { @@ -984,11 +1016,14 @@ function* walkAstNodes(node: unknown): Generator { /** * esbuild-specific config fails under Vite 8 unless esbuild is installed * separately. The static case — `build.minify: 'esbuild'` in a - * default-exported config — is rewritten in place to `minify: true` (the + * default-exported config, including objects directly returned from a + * `defineConfig` callback — is rewritten in place to `minify: true` (the * oxc minifier); span replacement keeps comments and unrelated options - * intact, and the switch is reported. Dynamic values, `minify` outside - * `build`, and `transformWithEsbuild` usage keep their code and get a - * diagnostic covering both the keep-esbuild and migrate-to-oxc paths. + * intact, and the switch is reported. Dynamic values (e.g. a ternary + * picking `'esbuild'` per mode), `minify` outside `build`, computed + * identifier keys, and `transformWithEsbuild` usage keep their code and + * get a diagnostic covering both the keep-esbuild and migrate-to-oxc + * paths. */ export function migrateEsbuildConfigInViteConfig( projectPath: string,