diff --git a/docs/guide/static-deploy.md b/docs/guide/static-deploy.md index a5ee49af48988c..ca076061a51aea 100644 --- a/docs/guide/static-deploy.md +++ b/docs/guide/static-deploy.md @@ -162,7 +162,7 @@ After your project has been imported and deployed, all subsequent pushes to bran 3. Vercel will detect that you are using Vite and will enable the correct settings for your deployment. 4. Your application is deployed! (e.g. [vite-vue-template.vercel.app](https://vite-vue-template.vercel.app/)) -After your project has been imported and deployed, all subsequent pushes to branches will generate [Preview Deployments](https://vercel.com/docs/concepts/deployments/environments#preview), and all changes made to the Production Branch (commonly “main”) will result in a [Production Deployment](https://vercel.com/docs/concepts/deployments/environments#production). +After your project has been imported and deployed, all subsequent pushes to branches will generate [Preview Deployments](https://vercel.com/docs/deployments/environments#preview-environment-pre-production), and all changes made to the Production Branch (commonly “main”) will result in a [Production Deployment](https://vercel.com/docs/deployments/environments#production-environment). Learn more about Vercel’s [Git Integration](https://vercel.com/docs/concepts/git). diff --git a/packages/plugin-legacy/src/index.ts b/packages/plugin-legacy/src/index.ts index 4a4af8d9aebc41..8628d4253a462e 100644 --- a/packages/plugin-legacy/src/index.ts +++ b/packages/plugin-legacy/src/index.ts @@ -190,9 +190,12 @@ const outputOptionsForLegacyChunks = function resolveLegacyOutputMinify( minify: BuildOptions['minify'], supportsOxc: boolean | undefined, + target?: BuildOptions['target'], ): Rollup.OutputOptions['minify'] { const usesOxc = supportsOxc && (minify === 'oxc' || minify === true) - return usesOxc ? true : false + if (!usesOxc) return false + if (target === undefined || target === false) return true + return { compress: { target } } } function resolveLegacyBuildMinify( @@ -437,6 +440,8 @@ function viteLegacyPlugin(options: Options = {}): Plugin[] { options.externalSystemJS, false, supportsLegacyOxcMinification, + // Don't use newer syntax for legacy polyfill chunks + 'es2015', ) } }, @@ -944,8 +949,10 @@ async function buildPolyfillChunk( excludeSystemJS?: boolean, prependModenChunkLegacyGuard?: boolean, supportsLegacyOxcMinification?: boolean, + overrideMinifyCompressTarget?: BuildOptions['target'], ) { - const { assetsDir, sourcemap } = buildOptions + const { assetsDir, sourcemap, target } = buildOptions + const minifyCompressTarget = overrideMinifyCompressTarget ?? target const minify = resolveLegacyBuildMinify( buildOptions.minify, supportsLegacyOxcMinification, @@ -977,6 +984,7 @@ async function buildPolyfillChunk( minify: resolveLegacyOutputMinify( buildOptions.minify, supportsLegacyOxcMinification, + minifyCompressTarget, ), }, }, diff --git a/packages/vite/src/client/overlay.ts b/packages/vite/src/client/overlay.ts index 67f22594cf8f1d..b04c47acfcac07 100644 --- a/packages/vite/src/client/overlay.ts +++ b/packages/vite/src/client/overlay.ts @@ -108,7 +108,7 @@ pre.frame { .message { line-height: 1.3; - font-weight: 600; + font-weight: 400; white-space: pre-wrap; } diff --git a/packages/vite/src/module-runner/sourcemap/interceptor.ts b/packages/vite/src/module-runner/sourcemap/interceptor.ts index 35967218e20ac0..42456bcecbd602 100644 --- a/packages/vite/src/module-runner/sourcemap/interceptor.ts +++ b/packages/vite/src/module-runner/sourcemap/interceptor.ts @@ -1,6 +1,6 @@ import type { OriginalMapping } from '@jridgewell/trace-mapping' import type { ModuleRunner } from '../runner' -import { posixDirname, posixResolve } from '../utils' +import { decodeBase64, posixDirname, posixResolve } from '../utils' import type { EvaluatedModules } from '../evaluatedModules' import { slash } from '../../shared/utils' import { DecodedMap, getOriginalPosition } from './decoder' @@ -154,7 +154,7 @@ function retrieveSourceMap(source: string) { if (reSourceMap.test(sourceMappingURL)) { // Support source map URL as a data url const rawData = sourceMappingURL.slice(sourceMappingURL.indexOf(',') + 1) - sourceMapData = Buffer.from(rawData, 'base64').toString() + sourceMapData = decodeBase64(rawData) sourceMappingURL = source } else { // Support source map URLs relative to the source URL diff --git a/packages/vite/src/module-runner/utils.ts b/packages/vite/src/module-runner/utils.ts index b03648d09f55d1..accf117f060f6d 100644 --- a/packages/vite/src/module-runner/utils.ts +++ b/packages/vite/src/module-runner/utils.ts @@ -4,8 +4,13 @@ import { isWindows } from '../shared/utils' const textDecoder = new TextDecoder() export const decodeBase64: (base64: string) => string = (() => { - if (typeof Buffer === 'function' && typeof Buffer.from === 'function') { - return (base64: string) => Buffer.from(base64, 'base64').toString('utf-8') + // Capture the binding at module load: realms may legitimately delete + // `globalThis.Buffer` afterwards (e.g. browser-parity tests), and this + // decoder runs lazily while preparing stack traces + const capturedBuffer = typeof Buffer === 'function' ? Buffer : undefined + if (capturedBuffer && typeof capturedBuffer.from === 'function') { + return (base64: string) => + capturedBuffer.from(base64, 'base64').toString('utf-8') } return (base64: string) => diff --git a/packages/vite/src/node/build.ts b/packages/vite/src/node/build.ts index 092b5175060377..2f5ff5fcbcb8d5 100644 --- a/packages/vite/src/node/build.ts +++ b/packages/vite/src/node/build.ts @@ -163,7 +163,7 @@ export interface BuildEnvironmentOptions { /** * Override CSS minification specifically instead of defaulting to `build.minify`, * so you can configure minification for JS and CSS separately. - * @default 'lightningcss' + * @default 'lightningcss', but false if build.minify is disabled for client build */ cssMinify?: boolean | 'lightningcss' | 'esbuild' /** diff --git a/packages/vite/src/node/plugins/importAnalysis.ts b/packages/vite/src/node/plugins/importAnalysis.ts index 91422ff338622b..4d87a841d80251 100644 --- a/packages/vite/src/node/plugins/importAnalysis.ts +++ b/packages/vite/src/node/plugins/importAnalysis.ts @@ -42,6 +42,7 @@ import { isDefined, isExternalUrl, isFilePathESM, + isFilePathFormatExplicit, isInNodeModules, isJSRequest, joinUrlSegments, @@ -459,6 +460,17 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { _isNodeModeResult ??= isFilePathESM(importer, config.packageCache) return _isNodeModeResult } + let _isNodeModeForDynamicImportResult = config.legacy + ?.inconsistentCjsInterop + ? false + : undefined + const isNodeModeForDynamicImport = () => { + _isNodeModeForDynamicImportResult ??= isFilePathFormatExplicit( + importer, + config.packageCache, + ) + return _isNodeModeForDynamicImportResult + } await Promise.all( imports.map(async (importSpecifier, index) => { @@ -626,7 +638,9 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { url, index, importer, - isNodeMode(), + isDynamicImport + ? isNodeModeForDynamicImport() + : isNodeMode(), config, ) rewriteDone = true @@ -948,12 +962,11 @@ export function interopNamedImports( } = importSpecifier const exp = source.slice(expStart, expEnd) if (dynamicIndex > -1) { - const inconsistentCjsInterop = !!config.legacy?.inconsistentCjsInterop // rewrite `import('package')` to expose the default directly str.overwrite( expStart, expEnd, - `import('${rewrittenUrl}').then(m => (${interopHelperStr})(m.default, ${inconsistentCjsInterop ? 0 : 1}))` + + `import('${rewrittenUrl}').then(m => (${interopHelperStr})(m.default, ${+isNodeMode}))` + getLineBreaks(exp), { contentOnly: true }, ) diff --git a/packages/vite/src/node/server/bundledDev.ts b/packages/vite/src/node/server/bundledDev.ts index fd56bc16d3f555..e7413dd75a5c2f 100644 --- a/packages/vite/src/node/server/bundledDev.ts +++ b/packages/vite/src/node/server/bundledDev.ts @@ -64,6 +64,7 @@ export class MemoryFiles { export class BundledDev { private _devEngine!: DevEngine private initialBuildCompleted = false + private _closed = false private clients = new Clients() private invalidateCalledModules = new Map< NormalizedHotChannelClient, @@ -98,6 +99,7 @@ export class BundledDev { } async listen(): Promise { + this._closed = false debug?.('INITIAL: setup bundle options') const rolldownOptions = await this.getRolldownOptions() // NOTE: only single outputOptions is supported here @@ -206,6 +208,7 @@ export class BundledDev { }, ) this.waitForInitialBuildFinish().then(() => { + if (this._closed) return debug?.('INITIAL: build done') this.environment.hot.send({ type: 'full-reload', path: '*' }) this.initialBuildCompleted = true @@ -213,11 +216,16 @@ export class BundledDev { } private async waitForInitialBuildFinish(): Promise { + if (this._closed) return await this.devEngine.ensureCurrentBuildFinish() + if (this._closed) return + let state = await this.devEngine.getBundleState() while (this.memoryFiles.size === 0 && !state.lastBuildErrored) { await setTimeout(10) + if (this._closed) return await this.devEngine.ensureCurrentBuildFinish() + if (this._closed) return state = await this.devEngine.getBundleState() } } @@ -322,6 +330,7 @@ export class BundledDev { } async close(): Promise { + this._closed = true this.memoryFiles.clear() await this._devEngine?.close() this.initialBuildCompleted = false diff --git a/packages/vite/src/node/server/environment.ts b/packages/vite/src/node/server/environment.ts index cdb104acb9b55e..d0cbe94c91c920 100644 --- a/packages/vite/src/node/server/environment.ts +++ b/packages/vite/src/node/server/environment.ts @@ -329,8 +329,7 @@ export class DevEnvironment extends BaseEnvironment { this._crawlEndFinder.cancel() await Promise.allSettled([ - this.pluginContainer.close(), - this.bundledDev?.close(), + this.bundledDev ? this.bundledDev.close() : this.pluginContainer.close(), this.depsOptimizer?.close(), // WebSocketServer is independent of HotChannel and should not be closed on environment close isWebSocketServer in this.hot ? Promise.resolve() : this.hot.close(), diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts index 107c0b7ffaa9f7..1fa91fb49f0a1d 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts @@ -4,6 +4,7 @@ import { describe, expect } from 'vitest' import type { ViteDevServer } from '../../..' import type { ModuleRunnerContext } from '../../../../module-runner' import { ESModulesEvaluator } from '../../../../module-runner' +import { SOURCEMAPPING_URL } from '../../../../shared/constants' import { createFixtureEditor, createModuleRunnerTester, @@ -156,6 +157,29 @@ describe('module runner initialization', async () => { ] `) }) + + it('should not crash when preparing a stack trace while globalThis.Buffer is absent', async ({ + runner, + server, + }) => { + const mod = await runner.import('/fixtures/throws-error-method.ts') + const methodError = await getError(() => mod.throwError()) + + // Realms may legitimately remove `Buffer` after modules were loaded + // (e.g. browser-parity tests). Preparing a stack in that window must not + // depend on the global still existing. + const bufferBackup = globalThis.Buffer + Reflect.deleteProperty(globalThis, 'Buffer') + try { + // `.stack` access lazily invokes `prepareStackTrace`, which decodes the + // inline source map of the runner module + expect(serializeStack(server, methodError)).toBe( + ' at Module.throwError (/fixtures/throws-error-method.ts:6:9)', + ) + } finally { + globalThis.Buffer = bufferBackup + } + }) }) describe('module runner with node:vm executor', async () => { @@ -193,3 +217,68 @@ describe('module runner with node:vm executor', async () => { ).not.toThrow() }) }) + +describe('module runner interceptor with inline data: source map', async () => { + const syntheticFile = '/virtual-bufferless/inline-map.js' + // Minimal map pointing every generated position at line 1, column 0 of + // "inline-map-original.ts" + const syntheticSourceMap = { + version: 3, + sources: ['inline-map-original.ts'], + names: [], + mappings: 'AAAA', + } + // The comment token is composed from SOURCEMAPPING_URL so that this spec + // file itself never contains it (see shared/constants.ts) + const syntheticFileContent = + `throw new Error('example')\n` + + `//# ${SOURCEMAPPING_URL}=data:application/json;base64,${btoa( + JSON.stringify(syntheticSourceMap), + )}\n` + + class Evaluator extends ESModulesEvaluator { + async runInlinedModule(_: ModuleRunnerContext, __: string) { + const initModule = runInThisContext( + '() => { throw new Error("example") }', + { filename: syntheticFile }, + ) + + initModule() + } + } + + const it = await createModuleRunnerTester( + {}, + { + sourcemapInterceptor: { + // Serves a file that is not part of the runner module graph and + // carries an inline base64 source map, so mapping goes through + // `retrieveSourceMap` instead of the runner graph + retrieveFile(id) { + if (id === syntheticFile) { + return syntheticFileContent + } + }, + }, + evaluator: new Evaluator(), + }, + ) + + it('should not crash when decoding an inline source map while globalThis.Buffer is absent', async ({ + runner, + }) => { + const error: Error = await runner + .import('/fixtures/a.ts') + .catch((err) => err) + + const bufferBackup = globalThis.Buffer + Reflect.deleteProperty(globalThis, 'Buffer') + try { + expect(error.stack).toContain( + '/virtual-bufferless/inline-map-original.ts:1:1', + ) + } finally { + globalThis.Buffer = bufferBackup + } + }) +}) diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index cb25fe28b40d5b..d23f9bee76d7f0 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -441,6 +441,26 @@ export function isFilePathESM( } } +/** + * Whether the file's module format is explicitly determined as ESM or CJS by + * its extension or the nearest `package.json` `"type"` field, as opposed to + * being ambiguous. + */ +export function isFilePathFormatExplicit( + filePath: string, + packageCache?: PackageCache, +): boolean { + if (/\.[mc][jt]s$/.test(filePath)) { + return true + } + try { + const pkg = findNearestPackageData(path.dirname(filePath), packageCache) + return pkg?.data.type === 'module' || pkg?.data.type === 'commonjs' + } catch { + return false + } +} + export const splitRE: RegExp = /\r?\n/g const range: number = 2 diff --git a/playground/hmr-full-bundle-mode/__tests__/build-hooks.spec.ts b/playground/hmr-full-bundle-mode/__tests__/build-hooks.spec.ts new file mode 100644 index 00000000000000..2dc39046f73d71 --- /dev/null +++ b/playground/hmr-full-bundle-mode/__tests__/build-hooks.spec.ts @@ -0,0 +1,51 @@ +import path from 'node:path' +import { type Plugin, type ViteDevServer, createServer } from 'vite' +import { afterEach, describe, expect, test } from 'vitest' +import { isServe } from '~utils' + +let server: ViteDevServer | undefined + +afterEach(async () => { + await server?.close() + server = undefined +}) + +// In full bundle mode the build is driven by Rolldown's dev engine, which is the +// one that invokes plugins' `buildStart`/`buildEnd` hooks. Regression test for +// `buildEnd` being called twice because both `bundledDev.close()` and +// `pluginContainer.close()` fired it on server close. +describe.runIf(isServe)('full bundle mode build hooks', () => { + test('buildStart and buildEnd are each called only once', async () => { + let buildStartCount = 0 + let buildEndCount = 0 + const countPlugin: Plugin = { + name: 'count-build-hooks', + buildStart() { + buildStartCount++ + }, + buildEnd() { + buildEndCount++ + }, + } + + server = await createServer({ + root: path.resolve(import.meta.dirname, '..'), + configFile: false, + logLevel: 'silent', + experimental: { bundledDev: true }, + plugins: [countPlugin], + }) + await server.listen() + + // the initial full-bundle build runs on listen and fires buildStart + buildEnd once + await expect.poll(() => buildEndCount, { timeout: 10000 }).toBe(1) + expect(buildStartCount).toBe(1) + + await server.close() + server = undefined + + // closing must not fire buildStart/buildEnd again + expect(buildStartCount).toBe(1) + expect(buildEndCount).toBe(1) + }) +}) diff --git a/playground/legacy/__tests__/modern-target/legacy-modern-target.spec.ts b/playground/legacy/__tests__/modern-target/legacy-modern-target.spec.ts new file mode 100644 index 00000000000000..526ce7199ab523 --- /dev/null +++ b/playground/legacy/__tests__/modern-target/legacy-modern-target.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'vitest' +import { findAssetFile, isBuild, page, viteTestUrl } from '~utils' + +test('should load and execute the JS file', async () => { + await page.goto(viteTestUrl + '/modern-target.html') + await expect.poll(() => page.textContent('#app')).toMatch('at: 3') +}) + +describe.runIf(isBuild)('build', () => { + test('modern polyfill chunk respects modernTargets', () => { + const polyfill = findAssetFile(/polyfills-[-\w]+\.js$/, 'modern-target') + expect(polyfill).toBeTruthy() + // `modernTargets` includes Chrome 64, which does not support optional catch binding. + // `try {} catch (e) {}` must not be collapsed to `try {} catch {}`. + expect(polyfill).toMatch(/catch\s*\(/) + expect(polyfill).not.toMatch(/catch\s*\{/) + }) +}) diff --git a/playground/legacy/modern-target.html b/playground/legacy/modern-target.html new file mode 100644 index 00000000000000..ad91e2bc4b2154 --- /dev/null +++ b/playground/legacy/modern-target.html @@ -0,0 +1,11 @@ + + + + + legacy modern-target + + +
+ + + diff --git a/playground/legacy/modern-target.js b/playground/legacy/modern-target.js new file mode 100644 index 00000000000000..fb4e420f27c4ad --- /dev/null +++ b/playground/legacy/modern-target.js @@ -0,0 +1 @@ +document.querySelector('#app').textContent = `at: ${[1, 2, 3].at(-1)}` diff --git a/playground/legacy/vite.config-modern-target.js b/playground/legacy/vite.config-modern-target.js new file mode 100644 index 00000000000000..a3607a3c9d0785 --- /dev/null +++ b/playground/legacy/vite.config-modern-target.js @@ -0,0 +1,24 @@ +import path from 'node:path' +import legacy from '@vitejs/plugin-legacy' +import { defineConfig } from 'vite' + +export default defineConfig(({ isPreview }) => ({ + base: !isPreview ? './' : '/modern-target/', + plugins: [ + legacy({ + modernPolyfills: ['es.array.at'], + // a browser without optional catch binding + modernTargets: ['chrome >= 64'], + renderLegacyChunks: false, + }), + ], + + build: { + outDir: 'dist/modern-target', + rolldownOptions: { + input: { + index: path.resolve(import.meta.dirname, 'modern-target.html'), + }, + }, + }, +})) diff --git a/playground/optimize-deps/__tests__/optimize-deps.spec.ts b/playground/optimize-deps/__tests__/optimize-deps.spec.ts index e4bd47ca18be24..a4693d02eeecc6 100644 --- a/playground/optimize-deps/__tests__/optimize-deps.spec.ts +++ b/playground/optimize-deps/__tests__/optimize-deps.spec.ts @@ -79,6 +79,18 @@ test('dynamic default import from cjs with es-module-flag (cjs-dynamic-dep-cjs-w .toBe('ok') }) +test('dynamic import from format-ambiguous importer respects __esModule flag (cjs-dynamic-importer-ambiguous)', async () => { + await expect + .poll(() => page.textContent('.cjs-dynamic-importer-ambiguous')) + .toBe('ok') +}) + +test('dynamic import from explicit cjs importer uses node interop (cjs-dynamic-importer-cjs)', async () => { + await expect + .poll(() => page.textContent('.cjs-dynamic-importer-cjs')) + .toBe('ok') +}) + test('dedupe', async () => { await expect.poll(() => page.textContent('.dedupe button')).toBe('count is 0') await page.click('.dedupe button') diff --git a/playground/optimize-deps/cjs-dynamic-importer-ambiguous/index.js b/playground/optimize-deps/cjs-dynamic-importer-ambiguous/index.js new file mode 100644 index 00000000000000..0a0a1ca3dea49c --- /dev/null +++ b/playground/optimize-deps/cjs-dynamic-importer-ambiguous/index.js @@ -0,0 +1,9 @@ +// test dynamic import to a cjs dep with __esModule flag from an importer +// whose module format is ambiguous (nearest package.json has no "type" field). +// the __esModule flag should be respected: `default` is `module.exports.default` +import('@vitejs/test-dep-cjs-compiled-from-esm').then((ns) => { + const ok = typeof ns.default === 'function' && ns.default() === 'foo' + document.querySelector('.cjs-dynamic-importer-ambiguous').textContent = ok + ? 'ok' + : 'fail' +}) diff --git a/playground/optimize-deps/cjs-dynamic-importer-ambiguous/package.json b/playground/optimize-deps/cjs-dynamic-importer-ambiguous/package.json new file mode 100644 index 00000000000000..05d31145ecde8c --- /dev/null +++ b/playground/optimize-deps/cjs-dynamic-importer-ambiguous/package.json @@ -0,0 +1,6 @@ +{ + "name": "@vitejs/test-optimize-deps-cjs-dynamic-importer-ambiguous", + "private": true, + "version": "0.0.0", + "//": "intentionally no \"type\" field: the importer's module format is ambiguous" +} diff --git a/playground/optimize-deps/cjs-dynamic-importer-cjs/index.js b/playground/optimize-deps/cjs-dynamic-importer-cjs/index.js new file mode 100644 index 00000000000000..827b9c7b537b31 --- /dev/null +++ b/playground/optimize-deps/cjs-dynamic-importer-cjs/index.js @@ -0,0 +1,10 @@ +// test dynamic import to a cjs dep with __esModule flag from an importer +// explicitly marked as CJS (nearest package.json has "type": "commonjs"). +// Node interop semantics apply: `default` is the whole `module.exports` +import('@vitejs/test-dep-cjs-compiled-from-esm').then((ns) => { + const ok = + typeof ns.default.default === 'function' && ns.default.default() === 'foo' + document.querySelector('.cjs-dynamic-importer-cjs').textContent = ok + ? 'ok' + : 'fail' +}) diff --git a/playground/optimize-deps/cjs-dynamic-importer-cjs/package.json b/playground/optimize-deps/cjs-dynamic-importer-cjs/package.json new file mode 100644 index 00000000000000..41e81f5d1f2d16 --- /dev/null +++ b/playground/optimize-deps/cjs-dynamic-importer-cjs/package.json @@ -0,0 +1,6 @@ +{ + "name": "@vitejs/test-optimize-deps-cjs-dynamic-importer-cjs", + "private": true, + "version": "0.0.0", + "type": "commonjs" +} diff --git a/playground/optimize-deps/index.html b/playground/optimize-deps/index.html index 54b14c8f5fe7a8..8ffe1abd73339b 100644 --- a/playground/optimize-deps/index.html +++ b/playground/optimize-deps/index.html @@ -30,6 +30,14 @@

CommonJS dynamic import default (dep-cjs-with-es-module-flag)

+

CommonJS dynamic import from format-ambiguous importer

+
+

CommonJS dynamic import from explicit CJS importer (package.json type)

+
+ + + +

Dedupe (dep in linked & optimized package)

diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d461269cb8ebdb..c8b0c9c130568e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1212,6 +1212,10 @@ importers: playground/optimize-deps/added-in-entries: {} + playground/optimize-deps/cjs-dynamic-importer-ambiguous: {} + + playground/optimize-deps/cjs-dynamic-importer-cjs: {} + playground/optimize-deps/dep-alias-using-absolute-path: dependencies: '@vitejs/test-dep-lodash':