diff --git a/docs/config/shared-options.md b/docs/config/shared-options.md index 2e825609b96862..cbf0abe12acc6d 100644 --- a/docs/config/shared-options.md +++ b/docs/config/shared-options.md @@ -37,17 +37,18 @@ See [Env Variables and Modes](/guide/env-and-mode) for more details. Define global constant replacements. Entries will be defined as globals during dev and statically replaced during build. -- String values will be used as raw expressions, so if defining a string constant, **it needs to be explicitly quoted** (e.g. with `JSON.stringify`). +Vite uses [esbuild defines](https://esbuild.github.io/api/#define) to perform replacements, so value expressions must be a string that contains a JSON-serializable value (null, boolean, number, string, array, or object) or a single identifier. For non-string values, Vite will automatically convert it to a string with `JSON.stringify`. -- To be consistent with [esbuild behavior](https://esbuild.github.io/api/#define), expressions must either be a JSON object (null, boolean, number, string, array, or object) or a single identifier. - -- Replacements are performed only when the match isn't surrounded by other letters, numbers, `_` or `$`. - -::: warning -Because it's implemented as straightforward text replacements without any syntax analysis, we recommend using `define` for CONSTANTS only. +**Example:** -For example, `process.env.FOO` and `__APP_VERSION__` are good fits. But `process` or `global` should not be put into this option. Variables can be shimmed or polyfilled instead. -::: +```js +export default defineConfig({ + define: { + __APP_VERSION__: JSON.stringify('v1.0.0'), + __API_URL__: 'window.__backend_api_url', + }, +}) +``` ::: tip NOTE For TypeScript users, make sure to add the type declarations in the `env.d.ts` or `vite-env.d.ts` file to get type checks and Intellisense. @@ -61,20 +62,6 @@ declare const __APP_VERSION__: string ::: -::: tip NOTE -Since dev and build implement `define` differently, we should avoid some use cases to avoid inconsistency. - -Example: - -```js -const obj = { - __NAME__, // Don't define object shorthand property names - __KEY__: value, // Don't define object key -} -``` - -::: - ## plugins - **Type:** `(Plugin | Plugin[] | Promise)[]` diff --git a/docs/guide/migration.md b/docs/guide/migration.md index 667cbe3fbff6b0..181415d3c92102 100644 --- a/docs/guide/migration.md +++ b/docs/guide/migration.md @@ -32,6 +32,42 @@ For other projects, there are a few general approaches: See the [troubleshooting guide](/guide/troubleshooting.html#vite-cjs-node-api-deprecated) for more information. +## Rework `define` and `import.meta.env.*` replacement strategy + +In Vite 4, the `define` and `import.meta.env.*` features use different replacement strategies in dev and build: + +- In dev, both features are injected as global variables to `globalThis` and `import.meta` respectively. +- In build, both features are statically replaced with a regex. + +This results in a dev and build inconsistency when trying to access the variables, and sometimes even caused failed builds. For example: + +```js +// vite.config.js +export default defineConfig({ + define: { + __APP_VERSION__: JSON.stringify('1.0.0'), + }, +}) +``` + +```js +const data = { __APP_VERSION__ } +// dev: { __APP_VERSION__: "1.0.0" } ✅ +// build: { "1.0.0" } ❌ + +const docs = 'I like import.meta.env.MODE' +// dev: "I like import.meta.env.MODE" ✅ +// build: "I like "production"" ❌ +``` + +Vite 5 fixes this by using `esbuild` to handle the replacements in builds, aligning with the dev behaviour. + +This change should not affect most setups, as it's already documented that `define` values should follow esbuild's syntax: + +> To be consistent with esbuild behavior, expressions must either be a JSON object (null, boolean, number, string, array, or object) or a single identifier. + +However, if you prefer to keep statically replacing values directly, you can use [`@rollup/plugin-replace`](https://github.com/rollup/plugins/tree/master/packages/replace). + ## General Changes ### SSR externalized modules value now matches production diff --git a/packages/vite/src/node/__tests__/plugins/define.spec.ts b/packages/vite/src/node/__tests__/plugins/define.spec.ts index 56fa62a596a458..de9d4b36b742c6 100644 --- a/packages/vite/src/node/__tests__/plugins/define.spec.ts +++ b/packages/vite/src/node/__tests__/plugins/define.spec.ts @@ -7,12 +7,14 @@ async function createDefinePluginTransform( build = true, ssr = false, ) { - const config = await resolveConfig({ define }, build ? 'build' : 'serve') + const config = await resolveConfig( + { configFile: false, define }, + build ? 'build' : 'serve', + ) const instance = definePlugin(config) return async (code: string) => { - const result = await (instance.transform as any).call({}, code, 'foo.ts', { - ssr, - }) + // @ts-expect-error transform should exist + const result = await instance.transform.call({}, code, 'foo.ts', { ssr }) return result?.code || result } } @@ -23,20 +25,17 @@ describe('definePlugin', () => { __APP_VERSION__: JSON.stringify('1.0'), }) expect(await transform('const version = __APP_VERSION__ ;')).toBe( - 'const version = "1.0" ;', + 'const version = "1.0";\n', ) expect(await transform('const version = __APP_VERSION__;')).toBe( - 'const version = "1.0";', + 'const version = "1.0";\n', ) }) test('replaces import.meta.env.SSR with false', async () => { const transform = await createDefinePluginTransform() - expect(await transform('const isSSR = import.meta.env.SSR ;')).toBe( - 'const isSSR = false ;', - ) expect(await transform('const isSSR = import.meta.env.SSR;')).toBe( - 'const isSSR = false;', + 'const isSSR = false;\n', ) }) @@ -44,14 +43,14 @@ describe('definePlugin', () => { // assert that the default behavior is to replace import.meta.hot with undefined const transform = await createDefinePluginTransform() expect(await transform('const hot = import.meta.hot;')).toBe( - 'const hot = undefined;', + 'const hot = void 0;\n', ) // assert that we can specify a user define to preserve import.meta.hot const overrideTransform = await createDefinePluginTransform({ 'import.meta.hot': 'import.meta.hot', }) expect(await overrideTransform('const hot = import.meta.hot;')).toBe( - 'const hot = import.meta.hot;', + undefined, ) }) }) diff --git a/packages/vite/src/node/plugins/clientInjections.ts b/packages/vite/src/node/plugins/clientInjections.ts index 2a773542aef58c..c66f3877eca822 100644 --- a/packages/vite/src/node/plugins/clientInjections.ts +++ b/packages/vite/src/node/plugins/clientInjections.ts @@ -3,9 +3,7 @@ import type { Plugin } from '../plugin' import type { ResolvedConfig } from '../config' import { CLIENT_ENTRY, ENV_ENTRY } from '../constants' import { isObject, normalizePath, resolveHostname } from '../utils' - -const process_env_NODE_ENV_RE = - /(\bglobal(This)?\.)?\bprocess\.env\.NODE_ENV\b/g +import { replaceDefine, serializeDefine } from './define' // ids in transform are normalized to unix style const normalizedClientEntry = normalizePath(CLIENT_ENTRY) @@ -53,7 +51,14 @@ export function clientInjectionsPlugin(config: ResolvedConfig): Plugin { hmrBase = path.posix.join(hmrBase, hmrConfig.path) } - const serializedDefines = serializeDefine(config.define || {}) + const userDefine: Record = {} + for (const key in config.define) { + // import.meta.env.* is handled in `importAnalysis` plugin + if (!key.startsWith('import.meta.env.')) { + userDefine[key] = config.define[key] + } + } + const serializedDefines = serializeDefine(userDefine) const modeReplacement = escapeReplacement(config.mode) const baseReplacement = escapeReplacement(devBase) @@ -84,17 +89,25 @@ export function clientInjectionsPlugin(config: ResolvedConfig): Plugin { .replace(`__HMR_CONFIG_NAME__`, hmrConfigNameReplacement) } }, - transform(code, id, options) { + async transform(code, id, options) { if (id === normalizedClientEntry || id === normalizedEnvEntry) { return injectConfigValues(code) } else if (!options?.ssr && code.includes('process.env.NODE_ENV')) { // replace process.env.NODE_ENV instead of defining a global // for it to avoid shimming a `process` object during dev, // avoiding inconsistencies between dev and build - return code.replace( - process_env_NODE_ENV_RE, + const nodeEnv = config.define?.['process.env.NODE_ENV'] || - JSON.stringify(process.env.NODE_ENV || config.mode), + JSON.stringify(process.env.NODE_ENV || config.mode) + return await replaceDefine( + code, + id, + { + 'process.env.NODE_ENV': nodeEnv, + 'global.process.env.NODE_ENV': nodeEnv, + 'globalThis.process.env.NODE_ENV': nodeEnv, + }, + config, ) } }, @@ -105,14 +118,3 @@ function escapeReplacement(value: string | number | boolean | null) { const jsonValue = JSON.stringify(value) return () => jsonValue } - -function serializeDefine(define: Record): string { - let res = `{` - for (const key in define) { - const val = define[key] - res += `${JSON.stringify(key)}: ${ - typeof val === 'string' ? `(${val})` : JSON.stringify(val) - }, ` - } - return res + `}` -} diff --git a/packages/vite/src/node/plugins/define.ts b/packages/vite/src/node/plugins/define.ts index c5150ab395e6cb..36b0df05a9b55a 100644 --- a/packages/vite/src/node/plugins/define.ts +++ b/packages/vite/src/node/plugins/define.ts @@ -1,12 +1,11 @@ -import MagicString from 'magic-string' +import { transform } from 'esbuild' import type { ResolvedConfig } from '../config' import type { Plugin } from '../plugin' -import { escapeRegex, transformStableResult } from '../utils' +import { escapeRegex, getHash } from '../utils' import { isCSSRequest } from './css' import { isHTMLRequest } from './html' const nonJsRe = /\.json(?:$|\?)/ -const metaEnvRe = /import\.meta\.env\.(.+)/ const isNonJsRequest = (request: string): boolean => nonJsRe.test(request) export function definePlugin(config: ResolvedConfig): Plugin { @@ -19,9 +18,9 @@ export function definePlugin(config: ResolvedConfig): Plugin { if (!isBuildLib) { const nodeEnv = process.env.NODE_ENV || config.mode Object.assign(processEnv, { - 'process.env.': `({}).`, - 'global.process.env.': `({}).`, - 'globalThis.process.env.': `({}).`, + 'process.env': `{}`, + 'global.process.env': `{}`, + 'globalThis.process.env': `{}`, }) Object.assign(processNodeEnv, { 'process.env.NODE_ENV': JSON.stringify(nodeEnv), @@ -31,86 +30,76 @@ export function definePlugin(config: ResolvedConfig): Plugin { }) } - const userDefine: Record = {} - const userDefineEnv: Record = {} - for (const key in config.define) { - const val = config.define[key] - userDefine[key] = typeof val === 'string' ? val : JSON.stringify(val) - - // make sure `import.meta.env` object has user define properties - if (isBuild) { - const match = key.match(metaEnvRe) - if (match) { - userDefineEnv[match[1]] = `__vite__define__${key}__define__vite__` - } - } - } - // during dev, import.meta properties are handled by importAnalysis plugin. const importMetaKeys: Record = {} + const importMetaEnvKeys: Record = {} const importMetaFallbackKeys: Record = {} if (isBuild) { - // set here to allow override with config.define importMetaKeys['import.meta.hot'] = `undefined` for (const key in config.env) { - importMetaKeys[`import.meta.env.${key}`] = JSON.stringify(config.env[key]) + const val = JSON.stringify(config.env[key]) + importMetaKeys[`import.meta.env.${key}`] = val + importMetaEnvKeys[key] = val } - Object.assign(importMetaFallbackKeys, { - 'import.meta.env.': `({}).`, - 'import.meta.env': JSON.stringify({ - ...config.env, - SSR: '__vite__ssr__', - ...userDefineEnv, - }).replace( - /"__vite__define__(.+?)__define__vite__"/g, - (_, key) => userDefine[key], - ), - }) + // these will be set to a proper value in `generatePattern` + importMetaKeys['import.meta.env.SSR'] = `undefined` + importMetaFallbackKeys['import.meta.env'] = `undefined` } - function getImportMetaKeys(ssr: boolean): Record { - if (!isBuild) return {} - return { - ...importMetaKeys, - 'import.meta.env.SSR': ssr + '', + const userDefine: Record = {} + const userDefineEnv: Record = {} + for (const key in config.define) { + // user can define keys with the same values to declare that some keys + // should not be replaced. in this case, we delete references of the key + // so they aren't replaced in the first place. + const val = config.define[key] + if (key === val) { + delete processNodeEnv[key] + delete importMetaKeys[key] + continue } - } - function getImportMetaFallbackKeys(ssr: boolean): Record { - if (!isBuild) return {} - return { - ...importMetaFallbackKeys, - 'import.meta.env': importMetaFallbackKeys['import.meta.env'].replace( - '"__vite__ssr__"', - ssr + '', - ), + userDefine[key] = handleDefineValue(config.define[key]) + + // make sure `import.meta.env` object has user define properties + if (isBuild && key.startsWith('import.meta.env.')) { + userDefineEnv[key.slice(16)] = config.define[key] } } - function generatePattern( - ssr: boolean, - ): [Record, RegExp | null] { + function generatePattern(ssr: boolean) { const replaceProcessEnv = !ssr || config.ssr?.target === 'webworker' - const replacements: Record = { + const define: Record = { ...(replaceProcessEnv ? processNodeEnv : {}), - ...getImportMetaKeys(ssr), + ...importMetaKeys, ...userDefine, - ...getImportMetaFallbackKeys(ssr), + ...importMetaFallbackKeys, ...(replaceProcessEnv ? processEnv : {}), } + // Additional define fixes based on `ssr` value if (isBuild && !replaceProcessEnv) { - replacements['__vite_process_env_NODE_ENV'] = 'process.env.NODE_ENV' + define['__vite_process_env_NODE_ENV'] = 'process.env.NODE_ENV' + } + if ('import.meta.env.SSR' in define) { + define['import.meta.env.SSR'] = ssr + '' + } + if ('import.meta.env' in define) { + define['import.meta.env'] = serializeDefine({ + ...importMetaEnvKeys, + SSR: ssr + '', + ...userDefineEnv, + }) } - const replacementsKeys = Object.keys(replacements) - const pattern = replacementsKeys.length + const defineKeys = Object.keys(define) + const pattern = defineKeys.length ? new RegExp( // Mustn't be preceded by a char that can be part of an identifier // or a '.' that isn't part of a spread operator '(? { - return '' + replacements[match] - }) - } + return await replaceDefine(code, id, define, config) + }, + } +} - const s = new MagicString(code) - let hasReplaced = false - let match: RegExpExecArray | null +export async function replaceDefine( + code: string, + id: string, + define: Record, + config: ResolvedConfig, +): Promise<{ code: string; map: string | null }> { + // Because esbuild only allows JSON-serializable values, and `import.meta.env` + // may contain values with raw identifiers, making it non-JSON-serializable, + // we replace it with a temporary marker and then replace it back after to + // workaround it. This means that esbuild is unable to optimize the `import.meta.env` + // access, but that's a tradeoff for now. + const replacementMarkers: Record = {} + const env = define['import.meta.env'] + if (env && !canJsonParse(env)) { + const marker = `_${getHash(env, env.length - 2)}_` + replacementMarkers[marker] = env + define = { ...define, 'import.meta.env': marker } + } - while ((match = pattern.exec(code))) { - hasReplaced = true - const start = match.index - const end = start + match[0].length - const replacement = '' + replacements[match[1]] - s.update(start, end, replacement) - } + const esbuildOptions = config.esbuild || {} - if (!hasReplaced) { - return null - } + const result = await transform(code, { + loader: 'js', + charset: esbuildOptions.charset ?? 'utf8', + platform: 'neutral', + define, + sourcefile: id, + sourcemap: config.command === 'build' ? !!config.build.sourcemap : true, + }) - return transformStableResult(s, id, config) - }, + for (const marker in replacementMarkers) { + result.code = result.code.replaceAll(marker, replacementMarkers[marker]) + } + + return { + code: result.code, + map: result.map || null, + } +} + +/** + * Like `JSON.stringify` but keeps raw string values as a literal + * in the generated code. For example: `"window"` would refer to + * the global `window` object directly. + */ +export function serializeDefine(define: Record): string { + let res = `{` + const keys = Object.keys(define) + for (let i = 0; i < keys.length; i++) { + const key = keys[i] + const val = define[key] + res += `${JSON.stringify(key)}: ${handleDefineValue(val)}` + if (i !== keys.length - 1) { + res += `, ` + } + } + return res + `}` +} + +function handleDefineValue(value: any): string { + if (typeof value === 'undefined') return 'undefined' + if (typeof value === 'string') return value + return JSON.stringify(value) +} + +function canJsonParse(value: any): boolean { + try { + JSON.parse(value) + return true + } catch { + return false } } diff --git a/packages/vite/src/node/plugins/importAnalysis.ts b/packages/vite/src/node/plugins/importAnalysis.ts index 88832054df44c4..520c236e940e9f 100644 --- a/packages/vite/src/node/plugins/importAnalysis.ts +++ b/packages/vite/src/node/plugins/importAnalysis.ts @@ -60,6 +60,7 @@ import { } from './optimizedDeps' import { isCSSRequest, isDirectCSSRequest } from './css' import { browserExternalId } from './resolve' +import { serializeDefine } from './define' const debug = createDebugger('vite:import-analysis') @@ -176,23 +177,29 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { let server: ViteDevServer let _env: string | undefined + let _ssrEnv: string | undefined function getEnv(ssr: boolean) { - if (!_env) { - _env = `import.meta.env = ${JSON.stringify({ - ...config.env, - SSR: '__vite__ssr__', - })};` - // account for user env defines + if (!_ssrEnv || !_env) { + const importMetaEnvKeys: Record = {} + const userDefineEnv: Record = {} + for (const key in config.env) { + importMetaEnvKeys[key] = JSON.stringify(config.env[key]) + } for (const key in config.define) { - if (key.startsWith(`import.meta.env.`)) { - const val = config.define[key] - _env += `${key} = ${ - typeof val === 'string' ? val : JSON.stringify(val) - };` + // non-import.meta.env.* is handled in `clientInjection` plugin + if (key.startsWith('import.meta.env.')) { + userDefineEnv[key.slice(16)] = config.define[key] } } + const env = `import.meta.env = ${serializeDefine({ + ...importMetaEnvKeys, + SSR: '__vite_ssr__', + ...userDefineEnv, + })};` + _ssrEnv = env.replace('__vite_ssr__', 'true') + _env = env.replace('__vite_ssr__', 'false') } - return _env.replace('"__vite__ssr__"', ssr + '') + return ssr ? _ssrEnv : _env } return { diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index 1e4adf07d14cc2..dbd48ad43f5cc5 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -1002,8 +1002,10 @@ export function parseRequest(id: string): Record | null { export const blankReplacer = (match: string): string => ' '.repeat(match.length) -export function getHash(text: Buffer | string): string { - return createHash('sha256').update(text).digest('hex').substring(0, 8) +export function getHash(text: Buffer | string, length = 8): string { + const h = createHash('sha256').update(text).digest('hex').substring(0, length) + if (length <= 64) return h + return h.padEnd(length, '_') } const _dirname = path.dirname(fileURLToPath(import.meta.url)) diff --git a/playground/define/__tests__/define.spec.ts b/playground/define/__tests__/define.spec.ts index 7b1e306d973341..66f5a3371e8440 100644 --- a/playground/define/__tests__/define.spec.ts +++ b/playground/define/__tests__/define.spec.ts @@ -1,16 +1,17 @@ import { expect, test } from 'vitest' import viteConfig from '../vite.config' -import { isBuild, page } from '~utils' +import { page } from '~utils' -test('string', async () => { - const defines = viteConfig.define +const defines = viteConfig.define +test('string', async () => { expect(await page.textContent('.exp')).toBe( String(typeof eval(defines.__EXP__)), ) expect(await page.textContent('.string')).toBe(JSON.parse(defines.__STRING__)) expect(await page.textContent('.number')).toBe(String(defines.__NUMBER__)) expect(await page.textContent('.boolean')).toBe(String(defines.__BOOLEAN__)) + expect(await page.textContent('.undefined')).toBe('') expect(await page.textContent('.object')).toBe( JSON.stringify(defines.__OBJ__, null, 2), @@ -44,10 +45,52 @@ test('string', async () => { expect(await page.textContent('.define-in-dep')).toBe( defines.__STRINGIFIED_OBJ__, ) - expect(await page.textContent('.import-meta-env-undefined')).toBe( - isBuild ? '({}).UNDEFINED' : 'import.meta.env.UNDEFINED', - ) - expect(await page.textContent('.process-env-undefined')).toBe( - isBuild ? '({}).UNDEFINED' : 'process.env.UNDEFINED', - ) +}) + +test('ignores constants in string literals', async () => { + expect( + await page.textContent('.ignores-string-literals .process-env-dot'), + ).toBe('process.env.') + expect( + await page.textContent('.ignores-string-literals .global-process-env-dot'), + ).toBe('global.process.env.') + expect( + await page.textContent( + '.ignores-string-literals .globalThis-process-env-dot', + ), + ).toBe('globalThis.process.env.') + expect( + await page.textContent('.ignores-string-literals .process-env-NODE_ENV'), + ).toBe('process.env.NODE_ENV') + expect( + await page.textContent( + '.ignores-string-literals .global-process-env-NODE_ENV', + ), + ).toBe('global.process.env.NODE_ENV') + expect( + await page.textContent( + '.ignores-string-literals .globalThis-process-env-NODE_ENV', + ), + ).toBe('globalThis.process.env.NODE_ENV') + expect( + await page.textContent( + '.ignores-string-literals .__vite_process_env_NODE_ENV', + ), + ).toBe('__vite_process_env_NODE_ENV') + expect( + await page.textContent('.ignores-string-literals .import-meta-hot'), + ).toBe('import' + '.meta.hot') +}) + +test('replaces constants in template literal expressions', async () => { + expect( + await page.textContent( + '.replaces-constants-in-template-literal-expressions .process-env-dot', + ), + ).toBe(JSON.parse(defines['process.env.SOMEVAR'])) + expect( + await page.textContent( + '.replaces-constants-in-template-literal-expressions .process-env-NODE_ENV', + ), + ).toBe('dev') }) diff --git a/playground/define/commonjs-dep/index.js b/playground/define/commonjs-dep/index.js index ba630bf28ff6d5..3525efcea4c5bf 100644 --- a/playground/define/commonjs-dep/index.js +++ b/playground/define/commonjs-dep/index.js @@ -1,5 +1,3 @@ module.exports = { defined: __STRINGIFIED_OBJ__, - importMetaEnvUndefined: 'import.meta.env.UNDEFINED', - processEnvUndefined: 'process.env.UNDEFINED', } diff --git a/playground/define/index.html b/playground/define/index.html index da1fa0fc5f3a23..7b802867903e2f 100644 --- a/playground/define/index.html +++ b/playground/define/index.html @@ -1,9 +1,12 @@ + +

Define

Raw Expression

String

Number

Boolean

+

Undefined

Object

Env Var

process node env:

@@ -17,10 +20,52 @@

Define

define variable in html: __EXP__

import json:

define in dep:

-

- import.meta.env.UNDEFINED: -

-

process.env.UNDEFINED:

+ +

Define ignores string literals

+
+

process.env.

+

global.process.env.

+

+ globalThis.process.env. +

+

process.env.NODE_ENV

+

+ global.process.env.NODE_ENV + +

+

+ globalThis.process.env.NODE_ENV + +

+

+ __vite_process_env_NODE_ENV + +

+

import.meta.hot

+
+ +

Define replaces constants in template literal expressions

+
+

process.env.

+

global.process.env.

+

+ globalThis.process.env. +

+

process.env.NODE_ENV

+

+ global.process.env.NODE_ENV + +

+

+ globalThis.process.env.NODE_ENV + +

+

+ __vite_process_env_NODE_ENV + +

+

import.meta.hot

+