Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(define): handle replacement with esbuild #11151

Merged
merged 18 commits into from Oct 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
33 changes: 10 additions & 23 deletions docs/config/shared-options.md
Expand Up @@ -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.
Expand All @@ -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<Plugin | Plugin[]>)[]`
Expand Down
36 changes: 36 additions & 0 deletions docs/guide/migration.md
Expand Up @@ -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
Expand Down
23 changes: 11 additions & 12 deletions packages/vite/src/node/__tests__/plugins/define.spec.ts
Expand Up @@ -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
}
}
Expand All @@ -23,35 +25,32 @@ 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',
)
})

test('preserve import.meta.hot with override', async () => {
// 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,
)
})
})
40 changes: 21 additions & 19 deletions packages/vite/src/node/plugins/clientInjections.ts
Expand Up @@ -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)
Expand Down Expand Up @@ -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<string, any> = {}
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)
Expand Down Expand Up @@ -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,
)
}
},
Expand All @@ -105,14 +118,3 @@ function escapeReplacement(value: string | number | boolean | null) {
const jsonValue = JSON.stringify(value)
return () => jsonValue
}

function serializeDefine(define: Record<string, any>): 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 + `}`
}