From 2141d3132d297c6dc3ad5698ef508fd08f5229a9 Mon Sep 17 00:00:00 2001 From: Bjorn Lu Date: Tue, 24 Oct 2023 00:05:39 +0800 Subject: [PATCH 01/10] fix(resolve)!: remove special .mjs handling (#14723) --- docs/guide/migration.md | 2 ++ packages/vite/src/node/plugins/resolve.ts | 16 +++------------- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/docs/guide/migration.md b/docs/guide/migration.md index 65d8b4e78c6101..99e39a4e37fa81 100644 --- a/docs/guide/migration.md +++ b/docs/guide/migration.md @@ -133,6 +133,8 @@ Also there are other breaking changes which only affect few users. - Renamed `ResolveWorkerOptions` type to `ResolvedWorkerOptions` - [[#5657] fix: return 404 for resources requests outside the base path](https://github.com/vitejs/vite/pull/5657) - In the past, Vite responded to requests outside the base path without `Accept: text/html`, as if they were requested with the base path. Vite no longer does that and responds with 404 instead. +- [[#14723] fix(resolve)!: remove special .mjs handling](https://github.com/vitejs/vite/pull/14723) + - In the past, when a library `"exports"` field maps to an `.mjs` file, Vite will still try to match the `"browser"` and `"module"` fields to fix compatibility with certain libraries. This behavior is now removed to align with the exports resolution algorithm. ## Migration from v3 diff --git a/packages/vite/src/node/plugins/resolve.ts b/packages/vite/src/node/plugins/resolve.ts index 7d9a1cc5093b51..556a85101b3db7 100644 --- a/packages/vite/src/node/plugins/resolve.ts +++ b/packages/vite/src/node/plugins/resolve.ts @@ -987,17 +987,8 @@ export function resolvePackageEntry( ) } - const resolvedFromExports = !!entryPoint - - // if exports resolved to .mjs, still resolve other fields. - // This is because .mjs files can technically import .cjs files which would - // make them invalid for pure ESM environments - so if other module/browser - // fields are present, prioritize those instead. - if ( - targetWeb && - options.browserField && - (!entryPoint || entryPoint.endsWith('.mjs')) - ) { + // handle edge case with browser and module field semantics + if (!entryPoint && targetWeb && options.browserField) { // check browser field // https://github.com/defunctzombie/package-browser-field-spec const browserEntry = @@ -1039,8 +1030,7 @@ export function resolvePackageEntry( } // fallback to mainFields if still not resolved - // TODO: review if `.mjs` check is still needed - if (!resolvedFromExports && (!entryPoint || entryPoint.endsWith('.mjs'))) { + if (!entryPoint) { for (const field of options.mainFields) { if (field === 'browser') continue // already checked above if (typeof data[field] === 'string') { From 5631b9439ad171372dcdbdaaa9f37309c736a606 Mon Sep 17 00:00:00 2001 From: bluwy Date: Tue, 24 Oct 2023 00:10:22 +0800 Subject: [PATCH 02/10] release: v5.0.0-beta.12 --- packages/vite/CHANGELOG.md | 11 +++++++++++ packages/vite/package.json | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/vite/CHANGELOG.md b/packages/vite/CHANGELOG.md index bbcb070082a62b..5b95f65e6c8cef 100644 --- a/packages/vite/CHANGELOG.md +++ b/packages/vite/CHANGELOG.md @@ -1,3 +1,14 @@ +## 5.0.0-beta.12 (2023-10-23) + +* fix(resolve)!: remove special .mjs handling (#14723) ([2141d31](https://github.com/vitejs/vite/commit/2141d31)), closes [#14723](https://github.com/vitejs/vite/issues/14723) +* fix(assets): fix svg inline in css url (#14714) ([eef4aaa](https://github.com/vitejs/vite/commit/eef4aaa)), closes [#14714](https://github.com/vitejs/vite/issues/14714) +* fix(resolve): make directory package.json check best effort (#14626) ([d520388](https://github.com/vitejs/vite/commit/d520388)), closes [#14626](https://github.com/vitejs/vite/issues/14626) +* docs: port 4.4.10 to 4.5 changelog to main (#14732) ([2728a31](https://github.com/vitejs/vite/commit/2728a31)), closes [#14732](https://github.com/vitejs/vite/issues/14732) +* refactor(ssr): remove unused metadata code (#14711) ([c5f2d60](https://github.com/vitejs/vite/commit/c5f2d60)), closes [#14711](https://github.com/vitejs/vite/issues/14711) +* chore: link migration guide to changelog (#14699) ([4cedcdc](https://github.com/vitejs/vite/commit/4cedcdc)), closes [#14699](https://github.com/vitejs/vite/issues/14699) + + + ## 5.0.0-beta.11 (2023-10-19) Learn more at the [Vite 5 Migration guide](https://main.vitejs.dev/guide/migration) and help us test the beta! diff --git a/packages/vite/package.json b/packages/vite/package.json index 8827a9fa216f0b..148eee33d23a3a 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -1,6 +1,6 @@ { "name": "vite", - "version": "5.0.0-beta.11", + "version": "5.0.0-beta.12", "type": "module", "license": "MIT", "author": "Evan You", From 1e487e06ce8601c3010e8c07fa7264e266f8db21 Mon Sep 17 00:00:00 2001 From: Bjorn Lu Date: Tue, 24 Oct 2023 01:11:22 +0800 Subject: [PATCH 03/10] docs: add performance page (#14693) --- docs/.vitepress/config.ts | 4 ++ docs/config/server-options.md | 16 ++---- docs/guide/performance.md | 102 ++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 11 deletions(-) create mode 100644 docs/guide/performance.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 00d962b1ecf772..9cc84c010ea6df 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -243,6 +243,10 @@ export default defineConfig({ text: 'Troubleshooting', link: '/guide/troubleshooting', }, + { + text: 'Performance', + link: '/guide/performance', + }, { text: 'Philosophy', link: '/guide/philosophy', diff --git a/docs/config/server-options.md b/docs/config/server-options.md index b2f70de3e2a04c..8a2e3a17645600 100644 --- a/docs/config/server-options.md +++ b/docs/config/server-options.md @@ -177,26 +177,20 @@ The error that appears in the Browser when the fallback happens can be ignored. ## server.warmup - **Type:** `{ clientFiles?: string[], ssrFiles?: string[] }` +- **Related:** [Warm Up Frequently Used Files](/guide/performance.html#warm-up-frequently-used-files) Warm up files to transform and cache the results in advance. This improves the initial page load during server starts and prevents transform waterfalls. -The `clientFiles` and `ssrFiles` options accept an array of file paths or glob patterns relative to the `root`. Make sure to only add files that are hot code, as otherwise adding too many may slow down the transform process. +`clientFiles` are files that are used in the client only, while `ssrFiles` are files that are used in SSR only. They accept an array of file paths or [`fast-glob`](https://github.com/mrmlnc/fast-glob) patterns relative to the `root`. -To understand why warmup can be useful, here's an example. Given this module graph where the left file imports the right file: - -``` -main.js -> Component.vue -> big-file.js -> large-data.json -``` - -The imported ids can only be known after the file is transformed, so if `Component.vue` takes some time to transform, `big-file.js` has to wait for it's turn, and so on. This causes an internal waterfall. - -By warming up `big-file.js`, or any files that you know is hot path in your app, they'll be cached and can be served immediately. +Make sure to only add files that are frequently used to not overload the Vite dev server on startup. ```js export default defineConfig({ server: { warmup: { - clientFiles: ['./src/big-file.js', './src/components/*.vue'], + clientFiles: ['./src/components/*.vue', './src/utils/big-utils.js'], + ssrFiles: ['./src/server/modules/*.js'], }, }, }) diff --git a/docs/guide/performance.md b/docs/guide/performance.md new file mode 100644 index 00000000000000..25e99f8f8ee22d --- /dev/null +++ b/docs/guide/performance.md @@ -0,0 +1,102 @@ +# Performance + +While Vite is fast by default, performance issues can creep in as the project's requirements grow. This guide aims to help you identify and fix common performance issues, such as: + +- Slow server starts +- Slow page loads +- Slow builds + +## Audit Configured Vite Plugins + +Vite's internal and official plugins are optimized to do the least amount of work possible while providing compatibility with the broader ecosystem. For example, code transformations use regex in dev, but do a complete parse in build to ensure correctness. + +However, the performance of community plugins is out of Vite's control, which may affect the developer experience. Here are a few things you can look out for when using additional Vite plugins: + +1. The `buildStart`, `config`, and `configResolved` hooks should not run long and extensive operations. These hooks are awaited during dev server startup, which delays when you can access the site in the browser. + +2. The `resolveId`, `load`, and `transform` hooks may cause some files to load slower than others. While sometimes unavoidable, it's still worth checking for possible areas to optimize. For example, checking if the `code` contains a specific keyword, or the `id` matches a specific extension, before doing the full transformation. + + The longer it takes to transform a file, the more significant the request waterfall will be when loading the site in the browser. + + You can inspect the duration it takes to transform a file using `DEBUG="vite:plugin-transform" vite` or [vite-plugin-inspect](https://github.com/antfu/vite-plugin-inspect). Note that as asynchronous operations tend to provide inaccurate timings, you should treat the numbers as a rough estimate, but it should still reveal the more expensive operations. + +::: tip Profiling +You can run `vite --profile`, visit the site, and press `p + enter` in your terminal to record a `.cpuprofile`. A tool like [speedscope](https://www.speedscope.app) can then be used to inspect the profile and identify the bottlenecks. You can also [share the profiles](https://chat.vitejs.dev) with the Vite team to help us identify performance issues. +::: + +## Reduce Resolve Operations + +Resolving import paths can be an expensive operation when hitting its worst case often. For example, Vite supports "guessing" import paths with the [`resolve.extensions`](/config/shared-options.md#resolve-extensions) option, which defaults to `['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json']`. + +When you try to import `./Component.jsx` with `import './Component'`, Vite will run these steps to resolve it: + +1. Check if `./Component` exists, no. +2. Check if `./Component.mjs` exists, no. +3. Check if `./Component.js` exists, no. +4. Check if `./Component.mts` exists, no. +5. Check if `./Component.ts` exists, no. +6. Check if `./Component.jsx` exists, yes! + +As shown, a total of 6 filesystem checks is required to resolve an import path. The more implicit imports you have, the more time it adds up to resolve the paths. + +Hence, it's usually better to be explicit with your import paths, e.g. `import './Component.jsx'`. You can also narrow down the list for `resolve.extensions` to reduce the general filesystem checks, but you have to make sure it works for files in `node_modules` too. + +If you're a plugin author, make sure to only call [`this.resolve`](https://rollupjs.org/plugin-development/#this-resolve) when needed to reduce the number of checks above. + +::: tip TypeScript +If you are using TypeScript, enable `"moduleResolution": "bundler"` and `"allowImportingTsExtensions": true` in your `tsconfig.json`'s `compilerOptions` to use `.ts` and `.tsx` extensions directly in your code. +::: + +## Avoid Barrel Files + +Barrel files are files that re-export the APIs of other files in the same directory. For example: + +```js +// src/utils/index.js +export * from './color' +export * from './dom' +export * from './string' +``` + +When you only import an individual API, e.g. `import { slash } from './utils'`, all the files in that barrel file need to be fetched and transformed as they may contain the `slash` API and may also contain side-effects that run on initialization. This means you're loading more files than required on the initial page load, resulting in a slower page load. + +If possible, you should avoid barrel files and import the individual APIs directly, e.g. `import { slash } from './utils/slash'`. You can read [issue #8237](https://github.com/vitejs/vite/issues/8237) for more information. + +## Warm Up Frequently Used Files + +The Vite dev server only transforms files as requested by the browser, which allows it to start up quickly and only apply transformations for used files. It can also pre-transform files if it anticipates certain files will be requested shortly. However, request waterfalls may still happen if some files take longer to transform than others. For example: + +Given an import graph where the left file imports the right file: + +``` +main.js -> BigComponent.vue -> big-utils.js -> large-data.json +``` + +The import relationship can only be known after the file is transformed. If `BigComponent.vue` takes some time to transform, `big-utils.js` has to wait for its turn, and so on. This causes an internal waterfall even with pre-transformation built-in. + +Vite allows you to warm up files that you know are frequently used, e.g. `big-utils.js`, using the [`server.warmup`](/config/server-options.md#server-warmup) option. This way `big-utils.js` will be ready and cached to be served immediately when requested. + +You can find files that are frequently used by running `DEBUG="vite:transform" vite` and inspect the logs: + +```bash +vite:transform 28.72ms /@vite/client +1ms +vite:transform 62.95ms /src/components/BigComponent.vue +1ms +vite:transform 102.54ms /src/utils/big-utils.js +1ms +``` + +```js +export default defineConfig({ + server: { + warmup: { + clientFiles: [ + './src/components/BigComponent.vue', + './src/utils/big-utils.js', + ], + }, + }, +}) +``` + +Note that you should only warm up files that are frequently used to not overload the Vite dev server on startup. Check the [`server.warmup`](/config/server-options.md#server-warmup) option for more information. + +Using [`--open` or `server.open`](/config/server-options.html#server-open) also provides a performance boost, as Vite will automatically warm up the entry point of your app or the provided URL to open. From cc9fb8736cf7fd974c50b98ea9ecf9d8bb61b22f Mon Sep 17 00:00:00 2001 From: Gabriel Cozma Date: Tue, 24 Oct 2023 08:59:56 +0300 Subject: [PATCH 04/10] docs(static-deploy): bump gh-pages action to v2 (#14740) --- docs/guide/static-deploy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/static-deploy.md b/docs/guide/static-deploy.md index b7d612f65a803e..2fea6afabefbf9 100644 --- a/docs/guide/static-deploy.md +++ b/docs/guide/static-deploy.md @@ -115,7 +115,7 @@ Now the `preview` command will launch the server at `http://localhost:8080`. path: './dist' - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v1 + uses: actions/deploy-pages@v2 ``` ## GitLab Pages and GitLab CI From 43cc3b9e6db4e8d2a5da69d6f75ae40601835e17 Mon Sep 17 00:00:00 2001 From: Bjorn Lu Date: Tue, 24 Oct 2023 22:28:53 +0800 Subject: [PATCH 05/10] feat(resolve)!: remove `resolve.browserField` (#14733) --- docs/config/shared-options.md | 12 +-------- docs/guide/migration.md | 2 ++ packages/vite/src/node/config.ts | 16 ++++++++++-- packages/vite/src/node/constants.ts | 1 + packages/vite/src/node/plugins/resolve.ts | 25 +++++++++++-------- packages/vite/src/node/ssr/ssrModuleLoader.ts | 1 - playground/resolve/vite.config.js | 2 +- 7 files changed, 33 insertions(+), 26 deletions(-) diff --git a/docs/config/shared-options.md b/docs/config/shared-options.md index e7708d80c517cf..2e825609b96862 100644 --- a/docs/config/shared-options.md +++ b/docs/config/shared-options.md @@ -154,20 +154,10 @@ Export keys ending with "/" is deprecated by Node and may not work well. Please ## resolve.mainFields - **Type:** `string[]` -- **Default:** `['module', 'jsnext:main', 'jsnext']` +- **Default:** `['browser', 'module', 'jsnext:main', 'jsnext']` List of fields in `package.json` to try when resolving a package's entry point. Note this takes lower precedence than conditional exports resolved from the `exports` field: if an entry point is successfully resolved from `exports`, the main field will be ignored. -## resolve.browserField - -- **Type:** `boolean` -- **Default:** `true` -- **Deprecated** - -Whether to enable resolving to `browser` field. - -In future, `resolve.mainFields`'s default value will be `['browser', 'module', 'jsnext:main', 'jsnext']` and this option will be removed. - ## resolve.extensions - **Type:** `string[]` diff --git a/docs/guide/migration.md b/docs/guide/migration.md index 99e39a4e37fa81..667cbe3fbff6b0 100644 --- a/docs/guide/migration.md +++ b/docs/guide/migration.md @@ -135,6 +135,8 @@ Also there are other breaking changes which only affect few users. - In the past, Vite responded to requests outside the base path without `Accept: text/html`, as if they were requested with the base path. Vite no longer does that and responds with 404 instead. - [[#14723] fix(resolve)!: remove special .mjs handling](https://github.com/vitejs/vite/pull/14723) - In the past, when a library `"exports"` field maps to an `.mjs` file, Vite will still try to match the `"browser"` and `"module"` fields to fix compatibility with certain libraries. This behavior is now removed to align with the exports resolution algorithm. +- [[#14733] feat(resolve)!: remove `resolve.browserField`](https://github.com/vitejs/vite/pull/14733) + - `resolve.browserField` has been deprecated since Vite 3 in favour of an updated default of `['browser', 'module', 'jsnext:main', 'jsnext']` for `resolve.mainFields`. ## Migration from v3 diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index 7c2c5c5dff9732..e923e9c348ec10 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -530,7 +530,6 @@ export async function resolveConfig( const resolveOptions: ResolvedConfig['resolve'] = { mainFields: config.resolve?.mainFields ?? DEFAULT_MAIN_FIELDS, - browserField: config.resolve?.browserField ?? true, conditions: config.resolve?.conditions ?? [], extensions: config.resolve?.extensions ?? DEFAULT_EXTENSIONS, dedupe: config.resolve?.dedupe ?? [], @@ -538,6 +537,20 @@ export async function resolveConfig( alias: resolvedAlias, } + if ( + // @ts-expect-error removed field + config.resolve?.browserField === false && + resolveOptions.mainFields.includes('browser') + ) { + logger.warn( + colors.yellow( + `\`resolve.browserField\` is set to false, but the option is removed in favour of ` + + `the 'browser' string in \`resolve.mainFields\`. You may want to update \`resolve.mainFields\` ` + + `to remove the 'browser' string and preserve the previous browser behaviour.`, + ), + ) + } + // load .env files const envDir = config.envDir ? normalizePath(path.resolve(resolvedRoot, config.envDir)) @@ -1060,7 +1073,6 @@ async function bundleConfigFile( preferRelative: false, tryIndex: true, mainFields: [], - browserField: false, conditions: [], overrideConditions: ['node'], dedupe: [], diff --git a/packages/vite/src/node/constants.ts b/packages/vite/src/node/constants.ts index 414d1b6430918f..5d8d0d10ca7839 100644 --- a/packages/vite/src/node/constants.ts +++ b/packages/vite/src/node/constants.ts @@ -9,6 +9,7 @@ const { version } = JSON.parse( export const VERSION = version as string export const DEFAULT_MAIN_FIELDS = [ + 'browser', 'module', 'jsnext:main', // moment still uses this... 'jsnext', diff --git a/packages/vite/src/node/plugins/resolve.ts b/packages/vite/src/node/plugins/resolve.ts index 556a85101b3db7..67b8d079e724ea 100644 --- a/packages/vite/src/node/plugins/resolve.ts +++ b/packages/vite/src/node/plugins/resolve.ts @@ -71,14 +71,9 @@ const debug = createDebugger('vite:resolve-details', { export interface ResolveOptions { /** - * @default ['module', 'jsnext:main', 'jsnext'] + * @default ['browser', 'module', 'jsnext:main', 'jsnext'] */ mainFields?: string[] - /** - * @deprecated In future, `mainFields` should be used instead. - * @default true - */ - browserField?: boolean conditions?: string[] /** * @default ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json'] @@ -283,7 +278,7 @@ export function resolvePlugin(resolveOptions: InternalResolveOptions): Plugin { if ( targetWeb && - options.browserField && + options.mainFields.includes('browser') && (res = tryResolveBrowserMapping(fsPath, importer, options, true)) ) { return res @@ -367,7 +362,7 @@ export function resolvePlugin(resolveOptions: InternalResolveOptions): Plugin { if ( targetWeb && - options.browserField && + options.mainFields.includes('browser') && (res = tryResolveBrowserMapping( id, importer, @@ -988,7 +983,7 @@ export function resolvePackageEntry( } // handle edge case with browser and module field semantics - if (!entryPoint && targetWeb && options.browserField) { + if (!entryPoint && targetWeb && options.mainFields.includes('browser')) { // check browser field // https://github.com/defunctzombie/package-browser-field-spec const browserEntry = @@ -1059,7 +1054,11 @@ export function resolvePackageEntry( } else { // resolve object browser field in package.json const { browser: browserField } = data - if (targetWeb && options.browserField && isObject(browserField)) { + if ( + targetWeb && + options.mainFields.includes('browser') && + isObject(browserField) + ) { entry = mapWithBrowserField(entry, browserField) || entry } } @@ -1181,7 +1180,11 @@ function resolveDeepImport( `${path.join(dir, 'package.json')}.`, ) } - } else if (targetWeb && options.browserField && isObject(browserField)) { + } else if ( + targetWeb && + options.mainFields.includes('browser') && + isObject(browserField) + ) { // resolve without postfix (see #7098) const { file, postfix } = splitFileAndPostfix(relativeId) const mapped = mapWithBrowserField(file, browserField) diff --git a/packages/vite/src/node/ssr/ssrModuleLoader.ts b/packages/vite/src/node/ssr/ssrModuleLoader.ts index 65a357b127656f..6ad10fe85d0eb1 100644 --- a/packages/vite/src/node/ssr/ssrModuleLoader.ts +++ b/packages/vite/src/node/ssr/ssrModuleLoader.ts @@ -137,7 +137,6 @@ async function instantiateModule( const resolveOptions: NodeImportResolveOptions = { mainFields: ['main'], - browserField: true, conditions: [], overrideConditions: [...overrideConditions, 'production', 'development'], extensions: ['.js', '.cjs', '.json'], diff --git a/playground/resolve/vite.config.js b/playground/resolve/vite.config.js index b27df23e734eeb..c016401a85ffb2 100644 --- a/playground/resolve/vite.config.js +++ b/playground/resolve/vite.config.js @@ -27,7 +27,7 @@ const generatedContentImports = [ export default defineConfig({ resolve: { extensions: ['.mjs', '.js', '.es', '.ts'], - mainFields: ['custom', 'module'], + mainFields: ['browser', 'custom', 'module'], conditions: ['custom'], }, define: { From 4150bcb0bd627b8c873e5f1ffce1b9aefc8766c7 Mon Sep 17 00:00:00 2001 From: Bjorn Lu Date: Wed, 25 Oct 2023 22:58:01 +0800 Subject: [PATCH 06/10] perf(hmr): implement soft invalidation (#14654) --- .../vite/src/node/plugins/importAnalysis.ts | 11 +- packages/vite/src/node/server/moduleGraph.ts | 69 ++++++++++- .../vite/src/node/server/transformRequest.ts | 116 +++++++++++++++++- playground/hmr/__tests__/hmr.spec.ts | 14 +++ playground/hmr/hmr.ts | 2 + playground/hmr/index.html | 1 + playground/hmr/soft-invalidation/child.js | 1 + playground/hmr/soft-invalidation/index.js | 4 + playground/hmr/vite.config.ts | 13 ++ 9 files changed, 220 insertions(+), 11 deletions(-) create mode 100644 playground/hmr/soft-invalidation/child.js create mode 100644 playground/hmr/soft-invalidation/index.js diff --git a/packages/vite/src/node/plugins/importAnalysis.ts b/packages/vite/src/node/plugins/importAnalysis.ts index 88832054df44c4..becbd0d5554ab4 100644 --- a/packages/vite/src/node/plugins/importAnalysis.ts +++ b/packages/vite/src/node/plugins/importAnalysis.ts @@ -31,6 +31,7 @@ import { injectQuery, isBuiltin, isDataUrl, + isDefined, isExternalUrl, isInNodeModules, isJSRequest, @@ -677,9 +678,12 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { }), ) - const importedUrls = new Set( - orderedImportedUrls.filter(Boolean) as string[], - ) + const _orderedImportedUrls = orderedImportedUrls.filter(isDefined) + const importedUrls = new Set(_orderedImportedUrls) + // `importedUrls` will be mixed with watched files for the module graph, + // `staticImportedUrls` will only contain the static top-level imports and + // dynamic imports + const staticImportedUrls = new Set(_orderedImportedUrls) const acceptedUrls = mergeAcceptedUrls(orderedAcceptedUrls) const acceptedExports = mergeAcceptedUrls(orderedAcceptedExports) @@ -767,6 +771,7 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { isPartiallySelfAccepting ? acceptedExports : null, isSelfAccepting, ssr, + staticImportedUrls, ) if (hasHMR && prunedImports) { handlePrunedModules(prunedImports, server) diff --git a/packages/vite/src/node/server/moduleGraph.ts b/packages/vite/src/node/server/moduleGraph.ts index 888f8d72d35fcf..9266e2090e29cb 100644 --- a/packages/vite/src/node/server/moduleGraph.ts +++ b/packages/vite/src/node/server/moduleGraph.ts @@ -36,6 +36,28 @@ export class ModuleNode { ssrError: Error | null = null lastHMRTimestamp = 0 lastInvalidationTimestamp = 0 + /** + * If the module only needs to update its imports timestamp (e.g. within an HMR chain), + * it is considered soft-invalidated. In this state, its `transformResult` should exist, + * and the next `transformRequest` for this module will replace the timestamps. + * + * By default the value is `undefined` if it's not soft/hard-invalidated. If it gets + * soft-invalidated, this will contain the previous `transformResult` value. If it gets + * hard-invalidated, this will be set to `'HARD_INVALIDATED'`. + * @internal + */ + invalidationState: TransformResult | 'HARD_INVALIDATED' | undefined + /** + * @internal + */ + ssrInvalidationState: TransformResult | 'HARD_INVALIDATED' | undefined + /** + * The module urls that are statically imported in the code. This information is separated + * out from `importedModules` as only importers that statically import the module can be + * soft invalidated. Other imports (e.g. watched files) needs the importer to be hard invalidated. + * @internal + */ + staticImportedUrls?: Set /** * @param setIsSelfAccepting - set `false` to set `isSelfAccepting` later. e.g. #7870 @@ -131,11 +153,35 @@ export class ModuleGraph { timestamp: number = Date.now(), isHmr: boolean = false, hmrBoundaries: ModuleNode[] = [], + softInvalidate = false, ): void { - if (seen.has(mod)) { + const prevInvalidationState = mod.invalidationState + const prevSsrInvalidationState = mod.ssrInvalidationState + + // Handle soft invalidation before the `seen` check, as consecutive soft/hard invalidations can + // cause the final soft invalidation state to be different. + // If soft invalidated, save the previous `transformResult` so that we can reuse and transform the + // import timestamps only in `transformRequest`. If there's no previous `transformResult`, hard invalidate it. + if (softInvalidate) { + mod.invalidationState ??= mod.transformResult ?? 'HARD_INVALIDATED' + mod.ssrInvalidationState ??= mod.ssrTransformResult ?? 'HARD_INVALIDATED' + } + // If hard invalidated, further soft invalidations have no effect until it's reset to `undefined` + else { + mod.invalidationState = 'HARD_INVALIDATED' + mod.ssrInvalidationState = 'HARD_INVALIDATED' + } + + // Skip updating the module if it was already invalidated before and the invalidation state has not changed + if ( + seen.has(mod) && + prevInvalidationState === mod.invalidationState && + prevSsrInvalidationState === mod.ssrInvalidationState + ) { return } seen.add(mod) + if (isHmr) { mod.lastHMRTimestamp = timestamp } else { @@ -143,6 +189,7 @@ export class ModuleGraph { // processing being done for this module mod.lastInvalidationTimestamp = timestamp } + // Don't invalidate mod.info and mod.meta, as they are part of the processing pipeline // Invalidating the transform result is enough to ensure this module is re-processed next time it is requested mod.transformResult = null @@ -160,7 +207,20 @@ export class ModuleGraph { } mod.importers.forEach((importer) => { if (!importer.acceptedHmrDeps.has(mod)) { - this.invalidateModule(importer, seen, timestamp, isHmr) + // If the importer statically imports the current module, we can soft-invalidate the importer + // to only update the import timestamps. If it's not statically imported, e.g. watched/glob file, + // we can only soft invalidate if the current module was also soft-invalidated. A soft-invalidation + // doesn't need to trigger a re-load and re-transform of the importer. + const shouldSoftInvalidateImporter = + importer.staticImportedUrls?.has(mod.url) || softInvalidate + this.invalidateModule( + importer, + seen, + timestamp, + isHmr, + undefined, + shouldSoftInvalidateImporter, + ) } }) } @@ -177,6 +237,9 @@ export class ModuleGraph { * Update the module graph based on a module's updated imports information * If there are dependencies that no longer have any importers, they are * returned as a Set. + * + * @param staticImportedUrls Subset of `importedModules` where they're statically imported in code. + * This is only used for soft invalidations so `undefined` is fine but may cause more runtime processing. */ async updateModuleInfo( mod: ModuleNode, @@ -186,6 +249,7 @@ export class ModuleGraph { acceptedExports: Set | null, isSelfAccepting: boolean, ssr?: boolean, + staticImportedUrls?: Set, ): Promise | undefined> { mod.isSelfAccepting = isSelfAccepting const prevImports = ssr ? mod.ssrImportedModules : mod.clientImportedModules @@ -257,6 +321,7 @@ export class ModuleGraph { } mod.acceptedHmrDeps = new Set(resolveResults) + mod.staticImportedUrls = staticImportedUrls // update accepted hmr exports mod.acceptedHmrExports = acceptedExports diff --git a/packages/vite/src/node/server/transformRequest.ts b/packages/vite/src/node/server/transformRequest.ts index 69304c7f957850..5b485ed6c7b9c7 100644 --- a/packages/vite/src/node/server/transformRequest.ts +++ b/packages/vite/src/node/server/transformRequest.ts @@ -3,6 +3,8 @@ import path from 'node:path' import { performance } from 'node:perf_hooks' import getEtag from 'etag' import convertSourceMap from 'convert-source-map' +import MagicString from 'magic-string' +import { init, parse as parseImports } from 'es-module-lexer' import type { PartialResolvedId, SourceDescription, SourceMap } from 'rollup' import colors from 'picocolors' import type { ModuleNode, ViteDevServer } from '..' @@ -10,10 +12,14 @@ import { blankReplacer, cleanUrl, createDebugger, + injectQuery, isObject, prettifyUrl, + removeImportQuery, removeTimestampQuery, + stripBase, timeFrom, + unwrapId, } from '../utils' import { checkPublicFile } from '../plugins/asset' import { getDepsOptimizer } from '../optimizer' @@ -128,16 +134,25 @@ async function doTransform( const module = await server.moduleGraph.getModuleByUrl(url, ssr) + // tries to handle soft invalidation of the module if available, + // returns a boolean true is successful, or false if no handling is needed + const softInvalidatedTransformResult = + module && + (await handleModuleSoftInvalidation( + module, + ssr, + timestamp, + server.config.base, + )) + if (softInvalidatedTransformResult) { + debugCache?.(`[memory-hmr] ${prettyUrl}`) + return softInvalidatedTransformResult + } + // check if we have a fresh cache const cached = module && (ssr ? module.ssrTransformResult : module.transformResult) if (cached) { - // TODO: check if the module is "partially invalidated" - i.e. an import - // down the chain has been fully invalidated, but this current module's - // content has not changed. - // in this case, we can reuse its previous cached result and only update - // its import timestamps. - debugCache?.(`[memory] ${prettyUrl}`) return cached } @@ -357,3 +372,92 @@ function createConvertSourceMapReadMap(originalFileName: string) { ) } } + +/** + * When a module is soft-invalidated, we can preserve its previous `transformResult` and + * return similar code to before: + * + * - Client: We need to transform the import specifiers with new timestamps + * - SSR: We don't need to change anything as `ssrLoadModule` controls it + */ +async function handleModuleSoftInvalidation( + mod: ModuleNode, + ssr: boolean, + timestamp: number, + base: string, +) { + const transformResult = ssr ? mod.ssrInvalidationState : mod.invalidationState + + // Reset invalidation state + if (ssr) mod.ssrInvalidationState = undefined + else mod.invalidationState = undefined + + // Skip if not soft-invalidated + if (!transformResult || transformResult === 'HARD_INVALIDATED') return + + if (ssr ? mod.ssrTransformResult : mod.transformResult) { + throw new Error( + `Internal server error: Soft-invalidated module "${mod.url}" should not have existing tranform result`, + ) + } + + let result: TransformResult + // For SSR soft-invalidation, no transformation is needed + if (ssr) { + result = transformResult + } + // For client soft-invalidation, we need to transform each imports with new timestamps if available + else { + await init + const source = transformResult.code + const s = new MagicString(source) + const [imports] = parseImports(source) + + for (const imp of imports) { + let rawUrl = source.slice(imp.s, imp.e) + if (rawUrl === 'import.meta') continue + + const hasQuotes = rawUrl[0] === '"' || rawUrl[0] === "'" + if (hasQuotes) { + rawUrl = rawUrl.slice(1, -1) + } + + const urlWithoutTimestamp = removeTimestampQuery(rawUrl) + // hmrUrl must be derived the same way as importAnalysis + const hmrUrl = unwrapId( + stripBase(removeImportQuery(urlWithoutTimestamp), base), + ) + for (const importedMod of mod.clientImportedModules) { + if (importedMod.url !== hmrUrl) continue + if (importedMod.lastHMRTimestamp > 0) { + const replacedUrl = injectQuery( + urlWithoutTimestamp, + `t=${importedMod.lastHMRTimestamp}`, + ) + const start = hasQuotes ? imp.s + 1 : imp.s + const end = hasQuotes ? imp.e - 1 : imp.e + s.overwrite(start, end, replacedUrl) + } + break + } + } + + // Update `transformResult` with new code. We don't have to update the sourcemap + // as the timestamp changes doesn't affect the code lines (stable). + const code = s.toString() + result = { + ...transformResult, + code, + etag: getEtag(code, { weak: true }), + } + } + + // Only cache the result if the module wasn't invalidated while it was + // being processed, so it is re-processed next time if it is stale + if (timestamp > mod.lastInvalidationTimestamp) { + if (ssr) mod.ssrTransformResult = result + else mod.transformResult = result + } + + return result +} diff --git a/playground/hmr/__tests__/hmr.spec.ts b/playground/hmr/__tests__/hmr.spec.ts index 02371a8362dca1..fcfec0f351baf2 100644 --- a/playground/hmr/__tests__/hmr.spec.ts +++ b/playground/hmr/__tests__/hmr.spec.ts @@ -171,6 +171,20 @@ if (!isBuild) { await untilUpdated(() => el.textContent(), 'child updated') }) + test('soft invalidate', async () => { + const el = await page.$('.soft-invalidation') + expect(await el.textContent()).toBe( + 'soft-invalidation/index.js is transformed 1 times. child is bar', + ) + editFile('soft-invalidation/child.js', (code) => + code.replace('bar', 'updated'), + ) + await untilUpdated( + () => el.textContent(), + 'soft-invalidation/index.js is transformed 1 times. child is updated', + ) + }) + test('plugin hmr handler + custom event', async () => { const el = await page.$('.custom') editFile('customFile.js', (code) => code.replace('custom', 'edited')) diff --git a/playground/hmr/hmr.ts b/playground/hmr/hmr.ts index 3fd552c1e598eb..872258511ee273 100644 --- a/playground/hmr/hmr.ts +++ b/playground/hmr/hmr.ts @@ -6,12 +6,14 @@ import './file-delete-restore' import './optional-chaining/parent' import './intermediate-file-delete' import logo from './logo.svg' +import { msg as softInvalidationMsg } from './soft-invalidation' export const foo = 1 text('.app', foo) text('.dep', depFoo) text('.nested', nestedFoo) text('.virtual', virtual) +text('.soft-invalidation', softInvalidationMsg) setLogo(logo) const btn = document.querySelector('.virtual-update') as HTMLButtonElement diff --git a/playground/hmr/index.html b/playground/hmr/index.html index 9fac186d584d71..c21f6b13b7c496 100644 --- a/playground/hmr/index.html +++ b/playground/hmr/index.html @@ -23,6 +23,7 @@
+
diff --git a/playground/hmr/soft-invalidation/child.js b/playground/hmr/soft-invalidation/child.js new file mode 100644 index 00000000000000..21ec276fc7f825 --- /dev/null +++ b/playground/hmr/soft-invalidation/child.js @@ -0,0 +1 @@ +export const foo = 'bar' diff --git a/playground/hmr/soft-invalidation/index.js b/playground/hmr/soft-invalidation/index.js new file mode 100644 index 00000000000000..f236a2579b0c24 --- /dev/null +++ b/playground/hmr/soft-invalidation/index.js @@ -0,0 +1,4 @@ +import { foo } from './child' + +// @ts-expect-error global +export const msg = `soft-invalidation/index.js is transformed ${__TRANSFORM_COUNT__} times. child is ${foo}` diff --git a/playground/hmr/vite.config.ts b/playground/hmr/vite.config.ts index 1d952cf12be28a..9ae2186d1b8b5e 100644 --- a/playground/hmr/vite.config.ts +++ b/playground/hmr/vite.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ }, }, virtualPlugin(), + transformCountPlugin(), ], }) @@ -53,3 +54,15 @@ export const virtual = _virtual + '${num}';` }, } } + +function transformCountPlugin(): Plugin { + let num = 0 + return { + name: 'transform-count', + transform(code) { + if (code.includes('__TRANSFORM_COUNT__')) { + return code.replace('__TRANSFORM_COUNT__', String(++num)) + } + }, + } +} From c3622d70495d9f4fcfa9690f4f4dba7154d0a6c8 Mon Sep 17 00:00:00 2001 From: Bjorn Lu Date: Thu, 26 Oct 2023 10:41:38 +0800 Subject: [PATCH 07/10] fix: suppress addWatchFile invalid phase error (#14751) --- packages/vite/src/node/build.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/vite/src/node/build.ts b/packages/vite/src/node/build.ts index 49617b16de9ac2..ee8993a9457118 100644 --- a/packages/vite/src/node/build.ts +++ b/packages/vite/src/node/build.ts @@ -893,6 +893,15 @@ export function onRollupWarning( return } + // Rollup tracks the build phase slightly earlier before `buildEnd` is called, + // so there's a chance we can call `this.addWatchFile` in the invalid phase. Skip for now. + if ( + warning.plugin === 'vite:worker-import-meta-url' && + warning.pluginCode === 'INVALID_ROLLUP_PHASE' + ) { + return + } + if (warningIgnoreList.includes(warning.code!)) { return } From 0ae2e1dc63950ac5ff45110a360514850226c962 Mon Sep 17 00:00:00 2001 From: Bjorn Lu Date: Thu, 26 Oct 2023 10:57:36 +0800 Subject: [PATCH 08/10] refactor(shortcuts)!: tweak shortcuts api (#14749) --- packages/vite/src/node/cli.ts | 50 ++++++++++++++--------------- packages/vite/src/node/shortcuts.ts | 37 ++++++++++++--------- 2 files changed, 46 insertions(+), 41 deletions(-) diff --git a/packages/vite/src/node/cli.ts b/packages/vite/src/node/cli.ts index 02e9ff3b6ea473..0b632d575bdbde 100644 --- a/packages/vite/src/node/cli.ts +++ b/packages/vite/src/node/cli.ts @@ -5,6 +5,7 @@ import { cac } from 'cac' import colors from 'picocolors' import type { BuildOptions } from './build' import type { ServerOptions } from './server' +import type { CLIShortcut } from './shortcuts' import type { LogLevel } from './logger' import { createLogger } from './logger' import { VERSION } from './constants' @@ -192,34 +193,33 @@ cli ) server.printUrls() - server.bindCLIShortcuts({ - print: true, - customShortcuts: [ - profileSession && { - key: 'p', - description: 'start/stop the profiler', - async action(server) { - if (profileSession) { - await stopProfiler(server.config.logger.info) - } else { - const inspector = await import('node:inspector').then( - (r) => r.default, - ) - await new Promise((res) => { - profileSession = new inspector.Session() - profileSession.connect() - profileSession.post('Profiler.enable', () => { - profileSession!.post('Profiler.start', () => { - server.config.logger.info('Profiler started') - res() - }) + const customShortcuts: CLIShortcut[] = [] + if (profileSession) { + customShortcuts.push({ + key: 'p', + description: 'start/stop the profiler', + async action(server) { + if (profileSession) { + await stopProfiler(server.config.logger.info) + } else { + const inspector = await import('node:inspector').then( + (r) => r.default, + ) + await new Promise((res) => { + profileSession = new inspector.Session() + profileSession.connect() + profileSession.post('Profiler.enable', () => { + profileSession!.post('Profiler.start', () => { + server.config.logger.info('Profiler started') + res() }) }) - } - }, + }) + } }, - ], - }) + }) + } + server.bindCLIShortcuts({ print: true, customShortcuts }) } catch (e) { const logger = createLogger(options.logLevel) logger.error(colors.red(`error when starting dev server:\n${e.stack}`), { diff --git a/packages/vite/src/node/shortcuts.ts b/packages/vite/src/node/shortcuts.ts index 2c604fecfe4550..1e69ea8c5b3ee7 100644 --- a/packages/vite/src/node/shortcuts.ts +++ b/packages/vite/src/node/shortcuts.ts @@ -1,16 +1,19 @@ import readline from 'node:readline' import colors from 'picocolors' import type { ViteDevServer } from './server' -import { isDefined } from './utils' import type { PreviewServer } from './preview' import { openBrowser } from './server/openBrowser' export type BindCLIShortcutsOptions = { /** - * Print a one line hint to the terminal. + * Print a one-line shortcuts "help" hint to the terminal */ print?: boolean - customShortcuts?: (CLIShortcut | undefined | null)[] + /** + * Custom shortcuts to run when a key is pressed. These shortcuts take priority + * over the default shortcuts if they have the same keys (except the `h` key). + */ + customShortcuts?: CLIShortcut[] } export type CLIShortcut = { @@ -43,7 +46,6 @@ export function bindCLIShortcuts( } const shortcuts = (opts?.customShortcuts ?? []) - .filter(isDefined) // @ts-expect-error passing the right types, but typescript can't detect it .concat(isDev ? BASE_DEV_SHORTCUTS : BASE_PREVIEW_SHORTCUTS) @@ -53,18 +55,21 @@ export function bindCLIShortcuts( if (actionRunning) return if (input === 'h') { - server.config.logger.info( - [ - '', - colors.bold(' Shortcuts'), - ...shortcuts.map( - (shortcut) => - colors.dim(' press ') + - colors.bold(`${shortcut.key} + enter`) + - colors.dim(` to ${shortcut.description}`), - ), - ].join('\n'), - ) + const loggedKeys = new Set() + server.config.logger.info('\n Shortcuts') + + for (const shortcut of shortcuts) { + if (loggedKeys.has(shortcut.key)) continue + loggedKeys.add(shortcut.key) + + server.config.logger.info( + colors.dim(' press ') + + colors.bold(`${shortcut.key} + enter`) + + colors.dim(` to ${shortcut.description}`), + ) + } + + return } const shortcut = shortcuts.find((shortcut) => shortcut.key === input) From e4c801c552edc4a60659720b89777e29eb93db6b Mon Sep 17 00:00:00 2001 From: Bjorn Lu Date: Thu, 26 Oct 2023 11:30:05 +0800 Subject: [PATCH 09/10] feat(define): handle replacement with esbuild (#11151) Co-authored-by: Tony Trinh --- docs/config/shared-options.md | 33 +-- docs/guide/migration.md | 36 +++ .../src/node/__tests__/plugins/define.spec.ts | 23 +- .../vite/src/node/plugins/clientInjections.ts | 40 ++-- packages/vite/src/node/plugins/define.ts | 214 +++++++++++------- .../vite/src/node/plugins/importAnalysis.ts | 31 ++- packages/vite/src/node/utils.ts | 6 +- playground/define/__tests__/define.spec.ts | 61 ++++- playground/define/commonjs-dep/index.js | 2 - playground/define/index.html | 95 +++++++- playground/define/vite.config.js | 1 + playground/env/__tests__/env.spec.ts | 26 +++ playground/env/index.html | 22 ++ playground/html/__tests__/html.spec.ts | 3 - playground/html/env.html | 1 - playground/html/vite.config.js | 1 - .../react/components/DefineVariables.jsx | 3 + playground/vue/DefineVariable.vue | 3 + 18 files changed, 420 insertions(+), 181 deletions(-) create mode 100644 playground/react/components/DefineVariables.jsx create mode 100644 playground/vue/DefineVariable.vue 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 becbd0d5554ab4..e19f4831fd94fc 100644 --- a/packages/vite/src/node/plugins/importAnalysis.ts +++ b/packages/vite/src/node/plugins/importAnalysis.ts @@ -61,6 +61,7 @@ import { } from './optimizedDeps' import { isCSSRequest, isDirectCSSRequest } from './css' import { browserExternalId } from './resolve' +import { serializeDefine } from './define' const debug = createDebugger('vite:import-analysis') @@ -177,23 +178,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

+