diff --git a/docs/config/server-options.md b/docs/config/server-options.md index e960658162b48c..0c7db4b0a50200 100644 --- a/docs/config/server-options.md +++ b/docs/config/server-options.md @@ -311,6 +311,8 @@ export default defineConfig({ File system watcher options to pass on to [chokidar](https://github.com/paulmillr/chokidar/tree/3.6.0#api). +When bundled-dev mode is enabled, [Rolldown watch options](https://rolldown.rs/reference/InputOptions.watch) (for example, `usePolling`, `pollInterval`, `useDebounce`, `debounceDuration`, `include`, `exclude`) are also accepted. The chokidar-only options are still used by the chokidar watcher, which keeps watching files outside the module graph, such as config file dependencies and env files. + The Vite server watcher watches the `root` and skips the `.git/`, `node_modules/`, `test-results/`, and Vite's `cacheDir` and `build.outDir` directories by default. When updating a watched file, Vite will apply HMR and update the page only if needed. If set to `null`, no files will be watched. [`server.watcher`](/guide/api-javascript.html#vitedevserver) will provide a compatible event emitter, but calling `add` or `unwatch` will have no effect. diff --git a/docs/guide/api-plugin.md b/docs/guide/api-plugin.md index 272461da446b5e..0a06868a4ac31e 100644 --- a/docs/guide/api-plugin.md +++ b/docs/guide/api-plugin.md @@ -337,6 +337,58 @@ Vite plugins can also provide hooks that serve Vite-specific purposes. These hoo }) ``` +### `closeServer` + +- **Type:** `(context: { reason: 'restart' | 'close' }) => void | Promise` +- **Kind:** `async`, `parallel` +- **Scope:** [Global](/guide/api-environment-plugins#per-environment-hooks-and-global-hooks) + + Called when the dev server is restarted or closed, after the server has been torn down. Typically used to dispose resources created in [`configureServer`](/guide/api-plugin.html#configureserver). + + The `context.reason` distinguishes the two cases: + - `'restart'`: the server is restarting (e.g. a config file change or a call to `server.restart()`). + - `'close'`: the server is shutting down (e.g. the `q` shortcut, or a call to `server.close()`). + + ```js + const myPlugin = () => { + let resource + return { + name: 'close-server', + configureServer(server) { + resource = createResource() + }, + async closeServer({ reason }) { + if (reason === 'close') { + await resource.dispose() + } + }, + } + } + ``` + +### `closePreviewServer` + +- **Type:** `() => void | Promise` +- **Kind:** `async`, `parallel` +- **Scope:** [Global](/guide/api-environment-plugins#per-environment-hooks-and-global-hooks) + + Same as [`closeServer`](/guide/api-plugin.html#closeserver) but for the preview server. The preview server never restarts, so there is no `reason`. + + ```js + const myPlugin = () => { + let resource + return { + name: 'close-preview-server', + configurePreviewServer(server) { + resource = createResource() + }, + async closePreviewServer() { + await resource.dispose() + }, + } + } + ``` + ### `transformIndexHtml` - **Type:** `IndexHtmlTransformHook | { order?: 'pre' | 'post', handler: IndexHtmlTransformHook }` @@ -544,6 +596,33 @@ function outputMetadataPlugin(): Plugin { } ``` +## Referencing Emitted Assets + +To emit an asset from a plugin, call [`this.emitFile({ type: 'asset', ... })`](https://rolldown.rs/reference/Interface.PluginContext#in-depth-type-asset). It returns a `referenceId` that you can use to generate the asset's URL, since its final file name isn't known until the bundle is generated. + +### In JavaScript + +Use `import.meta.ROLLDOWN_FILE_URL_`: + +```js +const referenceId = this.emitFile({ + type: 'asset', + name: 'icon.png', + source: fileContent, +}) + +// it's a JavaScript expression, so append any query or hash with string concatenation +return `export default import.meta.ROLLDOWN_FILE_URL_${referenceId} + '#frag'` +``` + +### In CSS or HTML + +`import.meta.ROLLDOWN_FILE_URL_` only works in JavaScript expression position. In CSS or HTML, use the `__VITE_ASSET____` token instead, appending any query or hash right after it: + +```css +background: url(__VITE_ASSET____#frag); +``` + ## Plugin Ordering A Vite plugin can additionally specify an `enforce` property (similar to webpack loaders) to adjust its application order. The value of `enforce` can be either `"pre"` or `"post"`. The resolved plugins will be in the following order: diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 6c477b0ea43c18..3fe64938cdbe8a 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -28,7 +28,7 @@ vite [root] | `-l, --logLevel ` | info \| warn \| error \| silent (`string`) | | `--clearScreen` | Allow/disable clear screen when logging (`boolean`) | | `--configLoader ` | Use `bundle` to bundle the config with Rolldown, or `runner` (experimental) to process it on the fly, or `native` (experimental) to load using the native runtime (default: `bundle`) | -| `--profile` | Start built-in Node.js inspector (check [Performance bottlenecks](/guide/troubleshooting#performance-bottlenecks)) | +| `--profile [name]` | Start built-in Node.js inspector and write the profile to `.cpuprofile` (check [Performance bottlenecks](/guide/troubleshooting#performance-bottlenecks)) (`boolean \| string`) | | `-d, --debug [feat]` | Show debug logs (`string \| boolean`) | | `-f, --filter ` | Filter debug logs (`string`) | | `-m, --mode ` | Set env mode (`string`) | @@ -67,7 +67,7 @@ vite build [root] | `-l, --logLevel ` | info \| warn \| error \| silent (`string`) | | `--clearScreen` | Allow/disable clear screen when logging (`boolean`) | | `--configLoader ` | Use `bundle` to bundle the config with Rolldown, or `runner` (experimental) to process it on the fly, or `native` (experimental) to load using the native runtime (default: `bundle`) | -| `--profile` | Start built-in Node.js inspector (check [Performance bottlenecks](/guide/troubleshooting#performance-bottlenecks)) | +| `--profile [name]` | Start built-in Node.js inspector and write the profile to `.cpuprofile` (check [Performance bottlenecks](/guide/troubleshooting#performance-bottlenecks)) (`boolean \| string`) | | `-d, --debug [feat]` | Show debug logs (`string \| boolean`) | | `-f, --filter ` | Filter debug logs (`string`) | | `-m, --mode ` | Set env mode (`string`) | diff --git a/docs/guide/features.md b/docs/guide/features.md index 8029d71cd7d8ed..bbd9ad8b921eb6 100644 --- a/docs/guide/features.md +++ b/docs/guide/features.md @@ -655,7 +655,7 @@ Note that variables only represent file names one level deep. If `file` is `'foo Also note that the dynamic import must match the following rules to be bundled: -- Imports must start with `./` or `../`: ``import(`./dir/${foo}.js`)`` is valid, but ``import(`${foo}.js`)`` is not. +- Imports must start with `./` or `../` or `#`: ``import(`./dir/${foo}.js`)`` is valid, but ``import(`${foo}.js`)`` is not. - Imports must end with a file extension: ``import(`./dir/${foo}.js`)`` is valid, but ``import(`./dir/${foo}`)`` is not. - Imports to the own directory must specify a file name pattern: ``import(`./prefix-${foo}.js`)`` is valid, but ``import(`./${foo}.js`)`` is not. diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index aed5bf241c4bd9..bb3512eb0a5496 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -240,7 +240,7 @@ vite build --profile Once your application is opened in the browser, just await finish loading it and then go back to the terminal and press `p` key (will stop the Node.js inspector) then press `q` key to stop the dev server. ::: -Node.js inspector will generate `vite-profile-0.cpuprofile` in the root folder, go to https://www.speedscope.app/, and upload the CPU profile using the `BROWSE` button to inspect the result. +Node.js inspector will generate `vite-profile-0.cpuprofile` in the root folder. You can pass `--profile ` (or `--profile=`) to write `.cpuprofile` instead. Go to https://www.speedscope.app/, and upload the CPU profile using the `BROWSE` button to inspect the result. You can install [vite-plugin-inspect](https://github.com/antfu/vite-plugin-inspect), which lets you inspect the intermediate state of Vite plugins and can also help you to identify which plugins or middlewares are the bottleneck in your applications. The plugin can be used in both dev and build modes. Check the readme file for more details. diff --git a/packages/vite/bin/vite.js b/packages/vite/bin/vite.js index 79c49ab70f624e..9ef82005aca034 100755 --- a/packages/vite/bin/vite.js +++ b/packages/vite/bin/vite.js @@ -20,7 +20,9 @@ const debugIndex = process.argv.findIndex((arg) => /^(?:-d|--debug)$/.test(arg)) const filterIndex = process.argv.findIndex((arg) => /^(?:-f|--filter)$/.test(arg), ) -const profileIndex = process.argv.indexOf('--profile') +const profileIndex = process.argv.findIndex( + (arg) => arg === '--profile' || arg.startsWith('--profile='), +) if (debugIndex > 0) { let value = process.argv[debugIndex + 1] @@ -63,10 +65,19 @@ function start() { } if (profileIndex > 0) { - process.argv.splice(profileIndex, 1) - const next = process.argv[profileIndex] - if (next && next[0] !== '-') { - process.argv.splice(profileIndex, 1) + const [profileArg] = process.argv.splice(profileIndex, 1) + // `--profile [name]` writes the profile to `.cpuprofile`. The value is + // optional and consumed like cac does for other `[optional]` value flags. + let profileName = profileArg.slice('--profile='.length) + if (!profileName) { + const next = process.argv[profileIndex] + if (next && next[0] !== '-') { + process.argv.splice(profileIndex, 1) + profileName = next + } + } + if (profileName) { + global.__vite_profile_name = profileName } const inspector = await import('node:inspector').then((r) => r.default) const session = (global.__vite_profile_session = new inspector.Session()) diff --git a/packages/vite/src/client/client.ts b/packages/vite/src/client/client.ts index e75c439dd1bfa7..0127fbe8fa7e22 100644 --- a/packages/vite/src/client/client.ts +++ b/packages/vite/src/client/client.ts @@ -1,6 +1,7 @@ import type { ErrorPayload, HotPayload } from '#types/hmrPayload' import type { ViteHotContext } from '#types/hot' import { HMRClient, HMRContext } from '../shared/hmr' +import { wrapId } from '../shared/utils' import { createWebSocketModuleRunnerTransport, normalizeModuleRunnerTransport, @@ -139,6 +140,10 @@ const debounceReload = (time: number) => { } export const pageReload = debounceReload(20) +function wrapIdIfNeeded(id: string): string { + return id[0] === '.' || id[0] === '/' ? id : wrapId(id) +} + const hmrClient = new HMRClient( { error: (err) => console.error('[vite]', err), @@ -152,10 +157,11 @@ const hmrClient = new HMRClient( isWithinCircularImport, }) { const [acceptedPathWithoutQuery, query] = acceptedPath.split(`?`) + const browserPath = wrapIdIfNeeded(acceptedPathWithoutQuery) const importPromise = import( /* @vite-ignore */ base + - acceptedPathWithoutQuery.slice(1) + + browserPath.slice(1) + `?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${ query ? `&${query}` : '' }` diff --git a/packages/vite/src/module-runner/hmrHandler.ts b/packages/vite/src/module-runner/hmrHandler.ts index ec4fb5aec4b4c9..cfab4df39cbdb4 100644 --- a/packages/vite/src/module-runner/hmrHandler.ts +++ b/packages/vite/src/module-runner/hmrHandler.ts @@ -1,5 +1,5 @@ import type { HotPayload } from '#types/hmrPayload' -import { slash, unwrapId } from '../shared/utils' +import { slash } from '../shared/utils' import { ERR_OUTDATED_OPTIMIZED_DEP } from '../shared/constants' import { createHMRHandler } from '../shared/hmrHandler' import type { ModuleRunner } from './runner' @@ -19,9 +19,6 @@ export function createHMRHandlerForRunner( await Promise.all( payload.updates.map(async (update): Promise => { if (update.type === 'js-update') { - // runner always caches modules by their full path without /@id/ prefix - update.acceptedPath = unwrapId(update.acceptedPath) - update.path = unwrapId(update.path) return hmrClient.queueUpdate(update) } diff --git a/packages/vite/src/node/__tests__/build.spec.ts b/packages/vite/src/node/__tests__/build.spec.ts index cc26b3a47f2347..9ac9aae76973e3 100644 --- a/packages/vite/src/node/__tests__/build.spec.ts +++ b/packages/vite/src/node/__tests__/build.spec.ts @@ -84,6 +84,34 @@ describe('build', () => { assertOutputHashContentChange(result[0], result[1]) }) + test('file hash should change when renderBuiltUrl changes', async () => { + const createRenderBuiltUrl = (base: string) => (filename: string) => + `${base}/${filename}` + const renderBuiltUrlA = createRenderBuiltUrl('/cdn-a') + const renderBuiltUrlB = createRenderBuiltUrl('/cdn-b') + + expect(renderBuiltUrlA.toString()).toBe(renderBuiltUrlB.toString()) + + const result = await Promise.all([ + buildProjectWithRenderBuiltUrl(renderBuiltUrlA), + buildProjectWithRenderBuiltUrl(renderBuiltUrlB), + ]) + + expect(getOutputHashChanges(result[0], result[1])).toMatchInlineSnapshot(` + { + "changed": [ + "index", + ], + "unchanged": [ + "_subentry", + "asset.txt", + "undefined", + ], + } + `) + assertOutputHashContentChange(result[0], result[1]) + }) + test('top-level input is used as the default build entry', async () => { const result = (await build({ root: resolve(dirname, 'packages/build-project'), @@ -1512,6 +1540,44 @@ test('copies public directory after building same environment with write false f ).resolves.toBe('') }) +async function buildProjectWithRenderBuiltUrl( + renderBuiltUrl: (filename: string) => string, +) { + return (await build({ + root: resolve(dirname, 'packages/build-project'), + logLevel: 'silent', + build: { + write: false, + assetsInlineLimit: 0, + }, + experimental: { + renderBuiltUrl, + }, + plugins: [ + { + name: 'test', + resolveId(id) { + if (id === 'entry.js' || id === 'subentry.js') { + return '\0' + id + } + }, + load(id) { + if (id === '\0entry.js') { + return ` + import assetUrl from '/asset.txt?url' + console.log(assetUrl) + window.addEventListener('click', () => { import('subentry.js') }) + ` + } + if (id === '\0subentry.js') { + return `export default 'subentry'` + } + }, + }, + ], + })) as RolldownOutput +} + /** * for each chunks in output1, if there's a chunk in output2 with the same fileName, * ensure that the chunk code is the same. if not, the chunk hash should have changed. diff --git a/packages/vite/src/node/__tests__/packages/build-project/asset.txt b/packages/vite/src/node/__tests__/packages/build-project/asset.txt new file mode 100644 index 00000000000000..2bd69c0fe88147 --- /dev/null +++ b/packages/vite/src/node/__tests__/packages/build-project/asset.txt @@ -0,0 +1 @@ +asset diff --git a/packages/vite/src/node/__tests__/plugins/hooks.spec.ts b/packages/vite/src/node/__tests__/plugins/hooks.spec.ts index 53438096c22011..31f5ddf12b231b 100644 --- a/packages/vite/src/node/__tests__/plugins/hooks.spec.ts +++ b/packages/vite/src/node/__tests__/plugins/hooks.spec.ts @@ -446,3 +446,169 @@ describe('watcher add/unlink error handling', () => { expect(logError).toHaveBeenCalledWith(error) }) }) + +describe('closeServer hook', () => { + test('is called with reason "close" on server.close()', async () => { + const closeServer = vi.fn() + const server = await createServerWithPlugin({ + name: 'test', + closeServer, + }) + + await server.close() + + expect(closeServer).toHaveBeenCalledTimes(1) + expect(closeServer).toHaveBeenCalledWith({ reason: 'close' }) + }) + + test('receives a minimal plugin context as `this`', async () => { + expect.assertions(2) + + const server = await createServerWithPlugin({ + name: 'test', + closeServer() { + expect(this).toMatchObject({ + debug: expect.any(Function), + info: expect.any(Function), + warn: expect.any(Function), + error: expect.any(Function), + meta: expect.any(Object), + }) + // Global hooks don't have an environment. + expect(this).not.toHaveProperty('environment') + }, + }) + + await server.close() + }) + + test('is awaited before server.close() resolves', async () => { + let hookDone = false + const server = await createServerWithPlugin({ + name: 'test', + async closeServer() { + await new Promise((r) => setTimeout(r, 10)) + hookDone = true + }, + }) + + await server.close() + + // `server.close()` does not resolve until the async hook has completed. + expect(hookDone).toBe(true) + }) + + test('runs after the server is torn down (after closeBundle)', async () => { + const order: string[] = [] + const server = await createServerWithPlugin({ + name: 'test', + closeBundle() { + order.push('closeBundle') + }, + closeServer() { + order.push('closeServer') + }, + }) + + await server.close() + + // `closeBundle` runs as part of teardown (once per environment); the + // `closeServer` hook runs afterwards, so it is the last event. + expect(order.at(-1)).toBe('closeServer') + expect(order.indexOf('closeBundle')).toBeLessThan( + order.indexOf('closeServer'), + ) + }) + + test('runs hooks in parallel', async () => { + const events: string[] = [] + const server = await createServer({ + configFile: false, + root: import.meta.dirname, + plugins: [ + { + name: 'a', + async closeServer() { + events.push('a:start') + await new Promise((r) => setTimeout(r, 20)) + events.push('a:end') + }, + }, + { + name: 'b', + async closeServer() { + events.push('b:start') + await new Promise((r) => setTimeout(r, 20)) + events.push('b:end') + }, + }, + resolveEntryPlugin, + ], + logLevel: 'error', + server: { middlewareMode: true, ws: false }, + }) + + await server.close() + + // Both hooks start before either finishes. + expect(events.slice(0, 2)).toStrictEqual(['a:start', 'b:start']) + }) + + test('is called only once even if close() is called multiple times', async () => { + const closeServer = vi.fn() + const server = await createServerWithPlugin({ + name: 'test', + closeServer, + }) + + await Promise.all([server.close(), server.close()]) + await server.close() + + expect(closeServer).toHaveBeenCalledTimes(1) + }) + + test('is called with reason "restart" on server.restart()', async () => { + const closeServer = vi.fn() + const server = await createServerWithPlugin({ + name: 'test', + closeServer, + }) + + await server.restart() + + expect(closeServer).toHaveBeenCalledTimes(1) + expect(closeServer).toHaveBeenCalledWith({ reason: 'restart' }) + + await server.close() + }) +}) + +describe('closePreviewServer hook', () => { + test('is called on preview server.close()', async () => { + const closePreviewServer = vi.fn() + const server = await createPreviewServerWithPlugin({ + name: 'test', + closePreviewServer, + }) + + await server.close() + + expect(closePreviewServer).toHaveBeenCalledTimes(1) + }) + + test('is awaited before server.close() resolves', async () => { + let hookDone = false + const server = await createPreviewServerWithPlugin({ + name: 'test', + async closePreviewServer() { + await new Promise((r) => setTimeout(r, 10)) + hookDone = true + }, + }) + + await server.close() + + // `server.close()` does not resolve until the async hook has completed. + expect(hookDone).toBe(true) + }) +}) diff --git a/packages/vite/src/node/__tests__/plugins/wasm.spec.ts b/packages/vite/src/node/__tests__/plugins/wasm.spec.ts deleted file mode 100644 index 85e2a9760cb129..00000000000000 --- a/packages/vite/src/node/__tests__/plugins/wasm.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { assetUrlRE } from '../../plugins/asset' - -describe('wasm plugin assetUrlRE usage', () => { - // Regression test: assetUrlRE has the /g flag, which makes .test() - // stateful via lastIndex. When the wasm plugin called assetUrlRE.test() - // on consecutive asset URLs without resetting lastIndex, the second call - // would fail because lastIndex was already past the end of the string. - // This caused a non-deterministic bug where ~half of wasm files would - // miss the __VITE_ASSET__ -> __VITE_WASM_INIT__ replacement in SSR - // builds, leading to ENOENT errors at runtime. - test('assetUrlRE.test() fails on second call without lastIndex reset', () => { - const url1 = '__VITE_ASSET__abc123__' - const url2 = '__VITE_ASSET__def456__' - - // Simulate the bug: two consecutive .test() calls without resetting lastIndex - assetUrlRE.lastIndex = 0 - expect(assetUrlRE.test(url1)).toBe(true) - // After the first successful match, lastIndex is advanced past the string - expect(assetUrlRE.lastIndex).toBeGreaterThan(0) - // The second call fails because lastIndex > url2.length - expect(assetUrlRE.test(url2)).toBe(false) - - // Clean up - assetUrlRE.lastIndex = 0 - }) - - test('assetUrlRE.test() succeeds on consecutive calls with lastIndex reset', () => { - const url1 = '__VITE_ASSET__abc123__' - const url2 = '__VITE_ASSET__def456__' - - assetUrlRE.lastIndex = 0 - expect(assetUrlRE.test(url1)).toBe(true) - - // The fix: reset lastIndex before the next call - assetUrlRE.lastIndex = 0 - expect(assetUrlRE.test(url2)).toBe(true) - - // Clean up - assetUrlRE.lastIndex = 0 - }) -}) diff --git a/packages/vite/src/node/__tests__/plugins/worker.spec.ts b/packages/vite/src/node/__tests__/plugins/worker.spec.ts index 7fc52aa7f6c608..a6e8319e9fc2b1 100644 --- a/packages/vite/src/node/__tests__/plugins/worker.spec.ts +++ b/packages/vite/src/node/__tests__/plugins/worker.spec.ts @@ -1,10 +1,33 @@ import { resolve } from 'node:path' -import { expect, test } from 'vitest' +import { describe, expect, test } from 'vitest' import type { OutputChunk, RolldownOutput } from 'rolldown' import { build } from '../../build' +import { splitWorkerRequest } from '../../plugins/worker' const fixturesDir = resolve(import.meta.dirname, 'fixtures') +describe('splitWorkerRequest', () => { + for (const [id, postfix] of [ + ['/worker.js', ''], + ['/worker.js#hash', ''], + ['/worker.js?worker', ''], + ['/worker.js?sharedworker', ''], + ['/worker.js?inline', ''], + ['/worker.js?url', ''], + ['/worker.js?worker&url', ''], + ['/worker.js?worker&inline', ''], + ['/worker.js?worker&foo&url&bar', '?foo&bar'], + ['/worker.js?worker&foo&inline', '?foo'], + ['/worker.js?foo&bar', '?foo&bar'], + ['/worker.js?foo=foo&worker&bar=bar', '?foo=foo&bar=bar'], + ['/worker.js?foo&bar&worker', '?foo&bar'], + ]) { + test(`splits ${id}`, () => { + expect(splitWorkerRequest(id)).toEqual({ file: '/worker.js', postfix }) + }) + } +}) + test('?worker&url should produce the same hash in client and SSR builds', async () => { const root = resolve(fixturesDir, 'worker-url') diff --git a/packages/vite/src/node/__tests__/plugins/workerImportMetaUrl.spec.ts b/packages/vite/src/node/__tests__/plugins/workerImportMetaUrl.spec.ts index 348e3e39be0147..517947b1800667 100644 --- a/packages/vite/src/node/__tests__/plugins/workerImportMetaUrl.spec.ts +++ b/packages/vite/src/node/__tests__/plugins/workerImportMetaUrl.spec.ts @@ -253,4 +253,14 @@ new Worker( )" `) }) + + test('preserves custom search params in worker URL', async () => { + expect( + await transform( + 'new Worker(new URL("./worker.js?foo=bar&baz=qux", import.meta.url), { type: "module" })', + ), + ).toMatchInlineSnapshot( + '"new Worker(new URL(/* @vite-ignore */ "/worker.js?worker_file&type=module&foo=bar&baz=qux", \'\' + import.meta.url), { type: "module" })"', + ) + }) }) diff --git a/packages/vite/src/node/cli.ts b/packages/vite/src/node/cli.ts index 6eb6e67c10a484..a084e7c864d200 100644 --- a/packages/vite/src/node/cli.ts +++ b/packages/vite/src/node/cli.ts @@ -45,6 +45,7 @@ interface GlobalCLIOptions { logLevel?: LogLevel clearScreen?: boolean configLoader?: 'bundle' | 'runner' | 'native' + profile?: boolean | string d?: boolean | string debug?: boolean | string f?: string @@ -74,9 +75,14 @@ export const stopProfiler = ( profileSession!.post('Profiler.stop', (err, { profile }) => { // Write profile to disk, upload, etc. if (!err) { - const outPath = path.resolve( - `./vite-profile-${profileCount++}.cpuprofile`, - ) + const name = global.__vite_profile_name + const count = profileCount++ + const fileName = name + ? count === 0 + ? name + : `${name}-${count}` + : `vite-profile-${count}` + const outPath = path.resolve(`./${fileName}.cpuprofile`) fs.writeFileSync(outPath, JSON.stringify(profile)) log( colors.yellow( @@ -114,6 +120,7 @@ function cleanGlobalCLIOptions( delete ret.logLevel delete ret.clearScreen delete ret.configLoader + delete ret.profile delete ret.d delete ret.debug delete ret.f @@ -183,6 +190,10 @@ cli '--configLoader ', `[string] use 'bundle' to bundle the config with Rolldown, or 'runner' (experimental) to process it on the fly, or 'native' (experimental) to load using the native runtime (default: bundle)`, ) + .option( + '--profile [name]', + `[boolean | string] start built-in Node.js inspector`, + ) .option('-d, --debug [feat]', `[string | boolean] show debug logs`) .option('-f, --filter ', `[string] filter debug logs`) .option('-m, --mode ', `[string] set env mode`) diff --git a/packages/vite/src/node/constants.ts b/packages/vite/src/node/constants.ts index 200d076f76334e..7600888c0b64e3 100644 --- a/packages/vite/src/node/constants.ts +++ b/packages/vite/src/node/constants.ts @@ -17,7 +17,7 @@ export const ROLLUP_HOOKS: RollupPluginHooks[] = [ 'augmentChunkHash', 'outputOptions', // 'renderDynamicImport', - // 'resolveFileUrl', + 'resolveFileUrl', // 'resolveImportMeta', 'intro', 'outro', diff --git a/packages/vite/src/node/index.ts b/packages/vite/src/node/index.ts index 0b02b0fb768577..87d5538410d151 100644 --- a/packages/vite/src/node/index.ts +++ b/packages/vite/src/node/index.ts @@ -146,6 +146,7 @@ export type { ResolvedServerUrls, HttpServer, } from './server' +export type { ServerWatchOptions } from './watch' export type { ViteBuilder, BuildAppHook, diff --git a/packages/vite/src/node/plugin.ts b/packages/vite/src/node/plugin.ts index a0be15a581123e..bed86871a58aa0 100644 --- a/packages/vite/src/node/plugin.ts +++ b/packages/vite/src/node/plugin.ts @@ -19,7 +19,7 @@ import type { ResolvedConfig, UserConfig, } from './config' -import type { ServerHook } from './server' +import type { CloseServerHook, ServerHook } from './server' import type { BuildAppHook } from './build' import type { IndexHtmlTransform } from './plugins/html' import type { EnvironmentModuleNode } from './server/moduleGraph' @@ -28,7 +28,7 @@ import type { HmrContext, HotUpdateOptions } from './server/hmr' import type { DevEnvironment } from './server/environment' import type { Environment } from './environment' import type { PartialEnvironment } from './baseEnvironment' -import type { PreviewServerHook } from './preview' +import type { ClosePreviewServerHook, PreviewServerHook } from './preview' import { arraify, asyncFlatten } from './utils' import type { StringFilter } from './plugins/pluginFilter' @@ -303,6 +303,20 @@ export interface Plugin extends RolldownPlugin { * applied. Hooks can be async functions and will be called in series. */ configurePreviewServer?: ObjectHook + /** + * Run logic when the server is restarted or closed. The hook receives a + * `reason` that is `'restart'` when the server is restarting and `'close'` + * when it is closing. + * + * The hooks are called after the server is torn down. Hooks can be async + * functions and will be called in parallel. + */ + closeServer?: ObjectHook + /** + * Same as `closeServer` but for the preview server. The preview server never + * restarts, so no `reason` is provided. + */ + closePreviewServer?: ObjectHook /** * Transform index.html. * The hook receives the following arguments: diff --git a/packages/vite/src/node/plugins/asset.ts b/packages/vite/src/node/plugins/asset.ts index b7a0a1b3251bc8..0520431f3eafd2 100644 --- a/packages/vite/src/node/plugins/asset.ts +++ b/packages/vite/src/node/plugins/asset.ts @@ -1,6 +1,7 @@ import path from 'node:path' import fsp from 'node:fs/promises' import { Buffer } from 'node:buffer' +import { randomBytes } from 'node:crypto' import { pathToFileURL } from 'node:url' import * as mrmime from 'mrmime' import type { @@ -45,14 +46,48 @@ import type { PartialEnvironment } from '../baseEnvironment' import { getImportMapFilename } from './html' // referenceId is base64url but replaces - with $ -export const assetUrlRE: RegExp = /__VITE_ASSET__([\w$]+)__(?:\$_(.*?)__)?/g +export const assetUrlRE: RegExp = /__VITE_ASSET__([\w$]+)__/g + +interface FileUrlMetadata { + asFileUrl: boolean +} + +const fileUrlMetadata = new WeakMap>() const jsSourceMapRE = /\.[cm]?js\.map$/ export const noInlineRE: RegExp = /[?&]no-inline\b/ export const inlineRE: RegExp = /[?&]inline\b/ -const assetCache = new WeakMap>() +/** + * The resolved form of an asset request during build. + * - `string`: a URL usable as-is (an inlined `data:` URL, a + * `__VITE_PUBLIC_ASSET__` token, or a bundled-dev output URL). + * - `reference`: an emitted file referenced by its `referenceId`, to be turned + * into `import.meta.ROLLDOWN_FILE_URL_` (JS) or a `__VITE_ASSET__` + * token (CSS/HTML). The `postfix` is the query/hash appended after the URL. + */ +type FileToBuiltUrlResult = + | { type: 'string'; value: string } + | { type: 'reference'; referenceId: string; postfix: string } + +/** + * How an asset URL should be embedded by the caller: + * - `'string'`: plain text (CSS/HTML and other text consumers) + * - `'js'`: a JavaScript expression (embedded in generated JS) + */ +type AssetUrlFormat = 'string' | 'js' + +const assetCache = new WeakMap>() + +/** + * Emitted asset file names referenced from each chunk (keyed by preliminary + * chunk name) via `import.meta.ROLLDOWN_FILE_URL_`. + */ +const importedAssetsFromFileUrl = new WeakMap< + Environment, + Map> +>() /** a set of referenceId for entry CSS assets for each environment */ export const cssEntriesMap: WeakMap< @@ -100,13 +135,12 @@ export function renderAssetUrlInJS( assetUrlRE.lastIndex = 0 while ((match = assetUrlRE.exec(code))) { s ||= new MagicString(code) - const [full, referenceId, postfix = ''] = match + const [full, referenceId] = match const file = pluginContext.getFileName(referenceId) chunk.viteMetadata!.importedAssets.add(cleanUrl(file)) - const filename = file + postfix const replacement = toOutputFilePathInJS( environment, - filename, + file, 'asset', chunk.fileName, 'js', @@ -220,18 +254,33 @@ export function assetPlugin(config: ResolvedConfig): Plugin { } id = removeUrlQuery(id) - let url = await fileToUrl(this, id) + let resolved: FileToBuiltUrlResult + if (!this.environment.config.isBundled) { + resolved = { + type: 'string', + value: await fileToDevUrl(this.environment, id), + } + } else { + resolved = await resolveBuiltAsset(this, id) + } // Inherit HMR timestamp if this asset was invalidated - if (!url.startsWith('data:') && this.environment.mode === 'dev') { + if ( + resolved.type === 'string' && + !resolved.value.startsWith('data:') && + this.environment.mode === 'dev' + ) { const mod = this.environment.moduleGraph.getModuleById(id) if (mod && mod.lastHMRTimestamp > 0) { - url = injectQuery(url, `t=${mod.lastHMRTimestamp}`) + resolved = { + type: 'string', + value: injectQuery(resolved.value, `t=${mod.lastHMRTimestamp}`), + } } } return { - code: `export default ${JSON.stringify(encodeURIPath(url))}`, + code: `export default ${formatBuiltAsset(resolved, 'js')}`, // Force rollup to keep this module from being shared between other entry points if it's an entrypoint. // If the resulting chunk is empty, it will be removed in generateBundle. moduleSideEffects: @@ -246,7 +295,54 @@ export function assetPlugin(config: ResolvedConfig): Plugin { ...(config.command === 'build' ? { + resolveFileUrl({ fileName, chunkId, format, urlId }) { + const { environment } = this + + let importedByChunk = importedAssetsFromFileUrl.get(environment) + if (!importedByChunk) { + importedByChunk = new Map() + importedAssetsFromFileUrl.set(environment, importedByChunk) + } + let files = importedByChunk.get(chunkId) + if (!files) { + files = new Set() + importedByChunk.set(chunkId, files) + } + files.add(cleanUrl(fileName)) + + const toRelativeRuntime = createToImportMetaURLBasedRelativeRuntime( + format, + environment.config.isWorker, + ) + const metadata = urlId + ? fileUrlMetadata.get(environment)?.get(urlId) + : undefined + if (metadata?.asFileUrl) { + return toRelativeRuntime(fileName, chunkId).runtime + } + const replacement = toOutputFilePathInJS( + environment, + fileName, + 'asset', + chunkId, + 'js', + toRelativeRuntime, + ) + return typeof replacement === 'string' + ? JSON.stringify(encodeURIPath(replacement)) + : replacement.runtime + }, + renderChunk(code, chunk, opts) { + const importedFromFileUrl = importedAssetsFromFileUrl + .get(this.environment) + ?.get(chunk.fileName) + if (importedFromFileUrl) { + for (const file of importedFromFileUrl) { + chunk.viteMetadata!.importedAssets.add(file) + } + } + const s = renderAssetUrlInJS(this, chunk, opts, code) if (s) { @@ -327,13 +423,22 @@ export function assetPlugin(config: ResolvedConfig): Plugin { export async function fileToUrl( pluginContext: PluginContext, id: string, + format: AssetUrlFormat, asFileUrl = false, ): Promise { const { environment } = pluginContext if (!environment.config.isBundled) { - return fileToDevUrl(environment, id, asFileUrl) + const value = await fileToDevUrl(environment, id, asFileUrl) + return formatBuiltAsset({ type: 'string', value }, format) } else { - return fileToBuiltUrl(pluginContext, id) + return fileToBuiltUrl( + pluginContext, + id, + format, + false, + undefined, + asFileUrl, + ) } } @@ -426,15 +531,80 @@ function isGitLfsPlaceholder(content: Buffer): boolean { } /** - * Register an asset to be emitted as part of the bundle (if necessary) - * and returns the resolved public URL + * Register an asset to be emitted as part of the bundle (if necessary) and + * return its resolved URL in the requested `format`. */ async function fileToBuiltUrl( pluginContext: PluginContext, id: string, + format: AssetUrlFormat, skipPublicCheck = false, forceInline?: boolean, + asFileUrl = false, ): Promise { + const resolved = await resolveBuiltAsset( + pluginContext, + id, + skipPublicCheck, + forceInline, + ) + const urlId = + resolved.type === 'reference' && format === 'js' && asFileUrl + ? addFileUrlMetadata(pluginContext.environment, { asFileUrl }) + : undefined + return formatBuiltAsset(resolved, format, urlId) +} + +function addFileUrlMetadata( + environment: Environment, + metadata: FileUrlMetadata, +): string { + let metadataMap = fileUrlMetadata.get(environment) + if (!metadataMap) { + metadataMap = new Map() + fileUrlMetadata.set(environment, metadataMap) + } + + let urlId: string + do { + urlId = randomBytes(12).toString('hex') + } while (metadataMap.has(urlId)) + metadataMap.set(urlId, metadata) + return urlId +} + +/** Format a resolved asset as either a JS expression or a plain-text string. */ +function formatBuiltAsset( + resolved: FileToBuiltUrlResult, + format: AssetUrlFormat, + urlId?: string, +): string { + if (resolved.type === 'reference') { + if (format === 'js') { + const base = urlId + ? `import.meta.ROLLDOWN_FILE_URL_${resolved.referenceId}_${urlId}` + : `import.meta.ROLLDOWN_FILE_URL_${resolved.referenceId}` + return resolved.postfix + ? `${base} + ${JSON.stringify(resolved.postfix)}` + : base + } + return `__VITE_ASSET__${resolved.referenceId}__${resolved.postfix}` + } + return format === 'js' + ? JSON.stringify(encodeURIPath(resolved.value)) + : resolved.value +} + +/** + * Register an asset to be emitted (if necessary) and return the structured result, + * cached per id so the emitted file is shared. + */ +async function resolveBuiltAsset( + pluginContext: PluginContext, + id: string, + skipPublicCheck = false, + forceInline?: boolean, +): Promise { const environment = pluginContext.environment const topLevelConfig = environment.getTopLevelConfig() if (!skipPublicCheck) { @@ -444,7 +614,10 @@ async function fileToBuiltUrl( // If inline via query, re-assign the id so it can be read by the fs and inlined id = publicFile } else { - return publicFileToBuiltUrl(id, topLevelConfig) + return { + type: 'string', + value: publicFileToBuiltUrl(id, topLevelConfig), + } } } } @@ -458,11 +631,14 @@ async function fileToBuiltUrl( let { file, postfix } = splitFileAndPostfix(id) const content = await fsp.readFile(file) - let url: string + let result: FileToBuiltUrlResult if ( shouldInline(environment, file, id, content, pluginContext, forceInline) ) { - url = assetToDataURL(environment, file, content) + result = { + type: 'string', + value: assetToDataURL(environment, file, content), + } } else { // emit as asset const originalFileName = normalizePath( @@ -485,14 +661,17 @@ async function fileToBuiltUrl( environment.config.isBundled ) { const outputFilename = pluginContext.getFileName(referenceId) - url = toOutputFilePathInJSForBundledDev(environment, outputFilename) + result = { + type: 'string', + value: toOutputFilePathInJSForBundledDev(environment, outputFilename), + } } else { - url = `__VITE_ASSET__${referenceId}__${postfix ? `$_${postfix}__` : ``}` + result = { type: 'reference', referenceId, postfix } } } - cache.set(id, url) - return url + cache.set(id, result) + return result } export function toOutputFilePathInJSForBundledDev( @@ -534,6 +713,7 @@ export async function urlToBuiltUrl( return fileToBuiltUrl( pluginContext, file, + 'string', // skip public check since we just did it above true, forceInline, diff --git a/packages/vite/src/node/plugins/assetImportMetaUrl.ts b/packages/vite/src/node/plugins/assetImportMetaUrl.ts index bc3a7153afdf00..9a9d6ebb93cc9f 100644 --- a/packages/vite/src/node/plugins/assetImportMetaUrl.ts +++ b/packages/vite/src/node/plugins/assetImportMetaUrl.ts @@ -139,14 +139,14 @@ export function assetImportMetaUrlPlugin(config: ResolvedConfig): Plugin { // Get final asset URL. If the file does not exist, // we fall back to the initial URL and let it resolve in runtime - let builtUrl: string | undefined + let builtUrlExpr: string | undefined if (file) { try { if (publicDir && isParentDirectory(publicDir, file)) { const publicPath = '/' + path.posix.relative(publicDir, file) - builtUrl = await fileToUrl(this, publicPath) + builtUrlExpr = await fileToUrl(this, publicPath, 'js') } else { - builtUrl = await fileToUrl(this, file) + builtUrlExpr = await fileToUrl(this, file, 'js') // during dev, builtUrl may point to a directory or a non-existing file if (tryStatSync(file)?.isFile()) { this.addWatchFile(file) @@ -156,19 +156,19 @@ export function assetImportMetaUrlPlugin(config: ResolvedConfig): Plugin { // do nothing, we'll log a warning after this } } - if (!builtUrl) { + if (!builtUrlExpr) { const rawExp = code.slice(startIndex, endIndex) config.logger.warnOnce( `\n${rawExp} doesn't exist at build time, it will remain unchanged to be resolved at runtime. ` + `If this is intended, you can use the /* @vite-ignore */ comment to suppress this warning.`, ) - builtUrl = url + builtUrlExpr = JSON.stringify(url) } s.update( startIndex, endIndex, // NOTE: add `'' +` to opt-out rolldown's transform: https://github.com/rolldown/rolldown/issues/2745 - `new URL(${JSON.stringify(builtUrl)}, '' + import.meta.url)`, + `new URL(${builtUrlExpr}, '' + import.meta.url)`, ) } if (s) { diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index fdbb8b24c3c4c2..898da69a257aac 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -248,6 +248,7 @@ const commonjsProxyRE = /[?&]commonjs-proxy/ const inlineRE = /[?&]inline\b/ const inlineCSSRE = /[?&]inline-css\b/ const styleAttrRE = /[?&]style-attr\b/ +const styleTagCloseRE = /<\/style(?=[\t\n\f\r />])/gi const functionCallRE = /^[A-Z_][.\w-]*\(/i const transformOnlyRE = /[?&]transform-only\b/ const nonEscapedDoubleQuoteRe = /(? { - const filename = this.getFileName(fileHash) + postfix - chunk.viteMetadata!.importedAssets.add(cleanUrl(filename)) - return encodeURIPath( - toOutputFilePathInCss( - filename, - 'asset', - cssAssetName, - 'css', - config, - toRelative, - ), - ) - }, - ) + chunkCSS = chunkCSS.replace(assetUrlRE, (_, fileHash) => { + const filename = this.getFileName(fileHash) + chunk.viteMetadata!.importedAssets.add(cleanUrl(filename)) + return encodeURIPath( + toOutputFilePathInCss( + filename, + 'asset', + cssAssetName, + 'css', + config, + toRelative, + ), + ) + }) // resolve public URL from CSS paths if (encodedPublicUrls) { const relativePathToPublicFromCSS = normalizePath( @@ -3392,7 +3395,7 @@ async function compileLightningCSS( return id }, }, - minify: config.isProduction && !!config.build.cssMinify, + minify: false, sourceMap: config.command === 'build' ? !!config.build.sourcemap diff --git a/packages/vite/src/node/plugins/dynamicImportVars.ts b/packages/vite/src/node/plugins/dynamicImportVars.ts index 70f3f3af5ed559..c472613dba1da2 100644 --- a/packages/vite/src/node/plugins/dynamicImportVars.ts +++ b/packages/vite/src/node/plugins/dynamicImportVars.ts @@ -1,4 +1,4 @@ -import { posix } from 'node:path' +import path, { posix } from 'node:path' import MagicString from 'magic-string' import { init, parse as parseImports } from 'es-module-lexer' import type { ImportSpecifier } from 'es-module-lexer' @@ -21,7 +21,9 @@ import { } from '../utils' import type { Environment } from '../environment' import { perEnvironmentState } from '../environment' +import type { PartialEnvironment } from '../baseEnvironment' import { hasViteIgnoreRE } from './importAnalysis' +import { resolveSubpathImports } from './resolve' import { workerOrSharedWorkerRE } from './worker' export const dynamicImportHelperId = '\0vite/dynamic-import-helper.js' @@ -172,6 +174,22 @@ export function dynamicImportVarsPlugin(config: ResolvedConfig): Plugin { tryIndex: false, extensions: [], }) + const resolveDynamicImport = ( + environment: PartialEnvironment, + id: string, + importer?: string, + ) => { + const subpathImports = resolveSubpathImports(id, importer, { + ...environment.config.resolve, + packageCache: config.packageCache, + isProduction: config.isProduction, + isRequire: false, + }) + if (subpathImports && importer) { + return normalizePath(path.resolve(path.dirname(importer), subpathImports)) + } + return resolve(environment, id, importer) + } const getFilter = perEnvironmentState((environment: Environment) => { const { include, exclude } = @@ -191,7 +209,7 @@ export function dynamicImportVarsPlugin(config: ResolvedConfig): Plugin { include, exclude, resolver(id, importer) { - return resolve(environment, id, importer) + return resolveDynamicImport(environment, id, importer) }, sourcemap: !!environment.config.build.sourcemap, }) @@ -264,7 +282,7 @@ export function dynamicImportVarsPlugin(config: ResolvedConfig): Plugin { result = await transformDynamicImport( source.slice(start, end), importer, - (id, importer) => resolve(environment, id, importer), + (id, importer) => resolveDynamicImport(environment, id, importer), config.root, ) } catch (error) { diff --git a/packages/vite/src/node/plugins/html.ts b/packages/vite/src/node/plugins/html.ts index 36dfafd4fca36b..14887357f0489c 100644 --- a/packages/vite/src/node/plugins/html.ts +++ b/packages/vite/src/node/plugins/html.ts @@ -1064,12 +1064,12 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { }, ) // resolve asset url references - result = result.replace(assetUrlRE, (_, fileHash, postfix = '') => { + result = result.replace(assetUrlRE, (_, fileHash) => { const file = this.getFileName(fileHash) if (chunk) { chunk.viteMetadata!.importedAssets.add(cleanUrl(file)) } - return encodeURIPath(toOutputAssetFilePath(file)) + postfix + return encodeURIPath(toOutputAssetFilePath(file)) }) result = result.replace(publicAssetUrlRE, (_, fileHash) => { diff --git a/packages/vite/src/node/plugins/importAnalysis.ts b/packages/vite/src/node/plugins/importAnalysis.ts index ea27e678a57cf8..4002a41650529d 100644 --- a/packages/vite/src/node/plugins/importAnalysis.ts +++ b/packages/vite/src/node/plugins/importAnalysis.ts @@ -27,7 +27,6 @@ import { handlePrunedModules, lexAcceptedHmrDeps, lexAcceptedHmrExports, - normalizeHmrUrl, } from '../server/hmr' import { createDebugger, @@ -682,10 +681,11 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { // record for HMR import chain analysis // make sure to unwrap and normalize away base - const hmrUrl = unwrapId(stripBase(url, base)) - const isLocalImport = !isExternalUrl(hmrUrl) && !isDataUrl(hmrUrl) + const moduleUrl = unwrapId(stripBase(url, base)) + const isLocalImport = + !isExternalUrl(moduleUrl) && !isDataUrl(moduleUrl) if (isLocalImport) { - orderedImportedUrls[index] = hmrUrl + orderedImportedUrls[index] = moduleUrl } if (enablePartialAccept && importedBindings) { @@ -705,7 +705,7 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { // pre-transform known direct imports // These requests will also be registered in transformRequest to be awaited // by the deps optimizer - const url = removeImportQuery(hmrUrl) + const url = removeImportQuery(moduleUrl) environment.warmupRequest(url) } } else if (!importer.startsWith(withTrailingSlash(clientDir))) { @@ -789,7 +789,7 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { str().prepend( `import { createHotContext as __vite__createHotContext } from "${clientPublicPath}";` + `import.meta.hot = __vite__createHotContext(${JSON.stringify( - normalizeHmrUrl(importerModule.url), + importerModule.url, )});`, ) } @@ -827,8 +827,7 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin { }) } normalizedAcceptedUrls.add(normalized) - const hmrAccept = normalizeHmrUrl(normalized) - str().overwrite(start, end, JSON.stringify(hmrAccept), { + str().overwrite(start, end, JSON.stringify(normalized), { contentOnly: true, }) } diff --git a/packages/vite/src/node/plugins/resolve.ts b/packages/vite/src/node/plugins/resolve.ts index aa454d43de0d0f..1824f8b8019850 100644 --- a/packages/vite/src/node/plugins/resolve.ts +++ b/packages/vite/src/node/plugins/resolve.ts @@ -350,11 +350,10 @@ export function oxcResolvePlugin( ) return newResolvedId === resolvedId ? undefined : newResolvedId }, - resolveSubpathImports(id, importer, isRequire, scan) { + resolveSubpathImports(id, importer, isRequire) { return resolveSubpathImports(id, importer, { ...options, isRequire: resolveOptions.isRequire ?? isRequire, - scan, }) }, @@ -489,11 +488,18 @@ function optimizerResolvePlugin( } } -function resolveSubpathImports( +export function resolveSubpathImports( id: string, importer: string | undefined, - options: InternalResolveOptions, -) { + options: Pick< + InternalResolveOptions, + | 'packageCache' + | 'conditions' + | 'externalConditions' + | 'isProduction' + | 'isRequire' + >, +): string | undefined { if (!importer || !id.startsWith(subpathImportsPrefix)) return const basedir = path.dirname(importer) const pkgData = findNearestPackageData(basedir, options.packageCache) @@ -1027,7 +1033,10 @@ function getConditions( function resolveExportsOrImports( pkg: PackageData['data'], key: string, - options: InternalResolveOptions, + options: Pick< + InternalResolveOptions, + 'conditions' | 'externalConditions' | 'isProduction' | 'isRequire' + >, type: 'imports' | 'exports', externalize?: boolean, ) { diff --git a/packages/vite/src/node/plugins/wasm.ts b/packages/vite/src/node/plugins/wasm.ts index ae28b242a052fd..263aad432b27f6 100644 --- a/packages/vite/src/node/plugins/wasm.ts +++ b/packages/vite/src/node/plugins/wasm.ts @@ -1,11 +1,8 @@ import fsp from 'node:fs/promises' -import MagicString from 'magic-string' import { exactRegex } from 'rolldown/filter' -import type { RolldownMagicString } from 'rolldown' -import { createToImportMetaURLBasedRelativeRuntime } from '../build' import { type Plugin, perEnvironmentPlugin } from '../plugin' import { cleanUrl } from '../../shared/utils' -import { assetUrlRE, fileToUrl } from './asset' +import { fileToUrl } from './asset' const wasmHelperId = '\0vite/wasm-helper.js' @@ -20,8 +17,6 @@ const wasmDirectRE = /(? { - return perEnvironmentPlugin('vite:wasm-helper', (env) => { + return perEnvironmentPlugin('vite:wasm-helper', () => { return { name: 'vite:wasm-helper', @@ -147,16 +142,12 @@ export default ${wasmHelperCode} } } - let url = await fileToUrl(this, cleanedId, ssr) - assetUrlRE.lastIndex = 0 - if (ssr && assetUrlRE.test(url)) { - url = url.replace('__VITE_ASSET__', '__VITE_WASM_INIT__') - } + const urlExpr = await fileToUrl(this, cleanedId, 'js', ssr) if (isInit) { return ` import initWasm from "${wasmHelperId}" - export default opts => initWasm(opts, ${JSON.stringify(url)}) + export default opts => initWasm(opts, ${urlExpr}) ` } @@ -167,59 +158,11 @@ export default ${wasmHelperCode} return ` import __vite__initWasm from "${wasmHelperId}" -const __vite__wasmUrl = ${JSON.stringify(url)} +const __vite__wasmUrl = ${urlExpr} ${glueCode} ` }, }, - - renderChunk: - env.config.consumer === 'server' - ? { - filter: { code: wasmInitUrlRE }, - handler(code, chunk, opts, meta) { - const toRelativeRuntime = - createToImportMetaURLBasedRelativeRuntime( - opts.format, - this.environment.config.isWorker, - ) - - let match: RegExpExecArray | null - let s: RolldownMagicString | MagicString | undefined - - wasmInitUrlRE.lastIndex = 0 - while ((match = wasmInitUrlRE.exec(code))) { - const [full, referenceId] = match - const file = this.getFileName(referenceId) - chunk.viteMetadata!.importedAssets.add(cleanUrl(file)) - const { runtime } = toRelativeRuntime(file, chunk.fileName) - - s ??= meta.magicString ?? new MagicString(code) - - s.update( - match.index, - match.index + full.length, - `"+${runtime}+"`, - ) - } - - if (!s) return null - - return meta.magicString - ? { - code: s as RolldownMagicString, - } - : { - code: s.toString(), - map: this.environment.config.build.sourcemap - ? (s as MagicString).generateMap({ - hires: 'boundary', - }) - : null, - } - }, - } - : undefined, } }) } diff --git a/packages/vite/src/node/plugins/worker.ts b/packages/vite/src/node/plugins/worker.ts index aa9dd02247e386..a04fd165efdea5 100644 --- a/packages/vite/src/node/plugins/worker.ts +++ b/packages/vite/src/node/plugins/worker.ts @@ -1,38 +1,49 @@ import path from 'node:path' import MagicString from 'magic-string' -import type { PluginContext, RolldownOutput, RollupError } from 'rolldown' +import type { + OutputAsset, + OutputChunk, + PluginContext, + RolldownOutput, + RollupError, +} from 'rolldown' import colors from 'picocolors' import { type ImportSpecifier, init, parse } from 'es-module-lexer' import { viteWebWorkerPostPlugin as nativeWebWorkerPostPlugin } from 'rolldown/experimental' import type { ResolvedConfig } from '../config' import type { Plugin } from '../plugin' +import type { Environment } from '../environment' import { ENV_ENTRY, ENV_PUBLIC_PATH } from '../constants' import { - encodeURIPath, - getHash, injectQuery, normalizePath, prettifyUrl, + trailingSeparatorRE, urlRE, } from '../utils' import { BuildEnvironment, ChunkMetadataMap, - createToImportMetaURLBasedRelativeRuntime, injectEnvironmentToHooks, onRollupLog, - toOutputFilePathInJS, } from '../build' -import { cleanUrl } from '../../shared/utils' +import { cleanUrl, splitFileAndPostfix } from '../../shared/utils' import type { Logger } from '../logger' import { fileToUrl, toOutputFilePathInJSForBundledDev } from './asset' type WorkerBundle = { entryFilename: string entryCode: string - entryUrlPlaceholder: string referencedAssets: Set + moduleIds: Set watchedFiles: string[] + /** + * referenceId of the entry emitted for each build via + * `import.meta.ROLLDOWN_FILE_URL_`. Keyed by `Environment` because + * referenceIds are per-build and this bundle is shared across the main build + * and nested worker sub-builds. + */ + entryReferenceIds: WeakMap } type WorkerBundleAsset = { @@ -43,6 +54,12 @@ type WorkerBundleAsset = { source: string | Uint8Array } +/** The input ID of a worker entry, which identifies its bundle. */ +type WorkerBundleId = string + +/** `undefined` identifies the main bundle. */ +type BundleId = WorkerBundleId | undefined + class WorkerOutputCache { /** * worker bundle information for each input id @@ -51,11 +68,22 @@ class WorkerOutputCache { private bundles = new Map() /** list of assets emitted for the worker bundles */ private assets = new Map() - private fileNameHash = new Map< - /* hash */ string, - /* entryFilename */ string - >() private invalidatedBundles = new Set() + /** + * Worker references grouped by their containing bundle and module. + * `referencingModuleId` is the module whose inclusion keeps the reference + * live: the worker wrapper module for a `?worker` import, or the importing + * module for a `new URL(..., import.meta.url)` worker reference. + * `childBundleId` identifies the referenced worker bundle and is used as its + * `BundleId` when traversing that bundle's references. + */ + private bundleReferences = new Map< + BundleId, + Map< + /* referencingModuleId */ string, + Set + > + >() saveWorkerBundle( file: string, @@ -63,6 +91,7 @@ class WorkerOutputCache { outputEntryFilename: string, outputEntryCode: string, outputAssets: WorkerBundleAsset[], + moduleIds: Set, logger: Logger, ): WorkerBundle { for (const asset of outputAssets) { @@ -71,10 +100,10 @@ class WorkerOutputCache { const bundle: WorkerBundle = { entryFilename: outputEntryFilename, entryCode: outputEntryCode, - entryUrlPlaceholder: - this.generateEntryUrlPlaceholder(outputEntryFilename), referencedAssets: new Set(outputAssets.map((asset) => asset.fileName)), + moduleIds, watchedFiles, + entryReferenceIds: new WeakMap(), } this.bundles.set(file, bundle) return bundle @@ -115,7 +144,6 @@ class WorkerOutputCache { if (!bundle) return this.bundles.delete(file) - this.fileNameHash.delete(getHash(bundle.entryFilename)) this.assets.delete(bundle.entryFilename) @@ -126,6 +154,84 @@ class WorkerOutputCache { this.assets.delete(asset) } } + + this.bundleReferences.delete(file) + } + + recordReference( + parentInputId: BundleId, + childBundleId: WorkerBundleId, + referencingModuleId: string, + ) { + let referencesByModule = this.bundleReferences.get(parentInputId) + if (!referencesByModule) { + referencesByModule = new Map() + this.bundleReferences.set(parentInputId, referencesByModule) + } + let childBundleIds = referencesByModule.get(referencingModuleId) + if (!childBundleIds) { + childBundleIds = new Set() + referencesByModule.set(referencingModuleId, childBundleIds) + } + childBundleIds.add(childBundleId) + } + + getLiveAssetFileNames(mainLiveModuleIds: Set): Set { + const liveBundles = new Set() + const queue: [BundleId, Set][] = [[undefined, mainLiveModuleIds]] + while (queue.length > 0) { + const [bundleId, moduleIds] = queue.shift()! + const referencesByModule = this.bundleReferences.get(bundleId) + if (!referencesByModule) continue + for (const moduleId of moduleIds) { + const childBundleIds = referencesByModule.get(moduleId) + if (!childBundleIds) continue + for (const childBundleId of childBundleIds) { + if (liveBundles.has(childBundleId)) continue + liveBundles.add(childBundleId) + const childBundle = this.bundles.get(childBundleId) + if (childBundle) { + queue.push([childBundleId, childBundle.moduleIds]) + } + } + } + } + + const liveFileNames = new Set() + for (const inputId of liveBundles) { + const wb = this.bundles.get(inputId) + if (!wb) continue + liveFileNames.add(wb.entryFilename) + for (const fileName of wb.referencedAssets) { + liveFileNames.add(fileName) + } + } + return liveFileNames + } + + getDeadDirectlyReferencedBundles( + bundleId: BundleId, + liveModuleIds: Set, + ): WorkerBundle[] { + const referencesByModule = this.bundleReferences.get(bundleId) + if (!referencesByModule) return [] + const deadBundleIds = new Set() + for (const references of referencesByModule.values()) { + for (const childBundleId of references) { + deadBundleIds.add(childBundleId) + } + } + for (const moduleId of liveModuleIds) { + for (const childBundleId of referencesByModule.get(moduleId) || []) { + deadBundleIds.delete(childBundleId) + } + } + const deadBundles: WorkerBundle[] = [] + for (const childBundleId of deadBundleIds) { + const bundle = this.bundles.get(childBundleId) + if (bundle) deadBundles.push(bundle) + } + return deadBundles } getWorkerBundle(file: string) { @@ -136,16 +242,34 @@ class WorkerOutputCache { return this.assets.values() } - getEntryFilenameFromHash(hash: string) { - return this.fileNameHash.get(hash) + /** + * Emit the worker entry as an asset (once per build) and return the JS + * expression referencing it: `import.meta.ROLLDOWN_FILE_URL_`. The + * `vite:asset` `resolveFileUrl` hook turns that into the final URL (respecting + * base / `renderBuiltUrl`). The emitted file is deduplicated against this + * cache's `generateBundle` emit via its content check. + */ + generateEntryUrlExpr( + pluginContext: PluginContext, + bundle: WorkerBundle, + ): string { + const { environment } = pluginContext + let referenceId = bundle.entryReferenceIds.get(environment) + if (!referenceId) { + referenceId = pluginContext.emitFile({ + type: 'asset', + fileName: bundle.entryFilename, + source: bundle.entryCode, + }) + bundle.entryReferenceIds.set(environment, referenceId) + } + return `import.meta.ROLLDOWN_FILE_URL_${referenceId}` } - private generateEntryUrlPlaceholder(entryFilename: string): string { - const hash = getHash(entryFilename) - if (!this.fileNameHash.has(hash)) { - this.fileNameHash.set(hash, entryFilename) + clearEntryReferenceIds(environment: Environment): void { + for (const bundle of this.bundles.values()) { + bundle.entryReferenceIds.delete(environment) } - return `__VITE_WORKER_ASSET__${hash}__` } } @@ -155,10 +279,54 @@ export const workerOrSharedWorkerRE: RegExp = /(?:\?|&)(worker|sharedworker)(?:&|$)/ const workerFileRE = /(?:\?|&)worker_file&type=(\w+)(?:&|$)/ const inlineRE = /[?&]inline\b/ +const workerQueriesRE = + /(\?|&)(?:(?:worker|sharedworker|inline|url)=?(?:&|$))+/g + +export function splitWorkerRequest(id: string): { + file: string + postfix: string +} { + const { file, postfix } = splitFileAndPostfix(id) + if (!postfix || postfix[0] !== '?') { + return { file, postfix: '' } + } + return { + file, + postfix: postfix + .replace(workerQueriesRE, '$1') + .replace(trailingSeparatorRE, ''), + } +} export const WORKER_FILE_ID = 'worker_file' const workerOutputCaches = new WeakMap() +export function recordWorkerReference( + config: ResolvedConfig, + parentInputId: string | undefined, + childBundleId: WorkerBundleId, + referencingModuleId: string, +): void { + workerOutputCaches + .get(config.mainConfig || config)! + .recordReference(parentInputId, childBundleId, referencingModuleId) +} + +/** + * Reference a bundled worker entry as `import.meta.ROLLDOWN_FILE_URL_` from the + * given build. Thin accessor over `WorkerOutputCache.generateEntryUrlExpr` so + * both `vite:worker` and `vite:worker-import-meta-url` can share the cache. + */ +export function generateWorkerEntryUrlExpr( + pluginContext: PluginContext, + config: ResolvedConfig, + bundle: WorkerBundle, +): string { + return workerOutputCaches + .get(config.mainConfig || config)! + .generateEntryUrlExpr(pluginContext, bundle) +} + async function bundleWorkerEntry( config: ResolvedConfig, id: string, @@ -267,6 +435,8 @@ async function bundleWorkerEntry( await bundle.close() } + const moduleIds = collectIncludedModuleIds(result.output) + const { output: [outputChunk, ...outputChunks], } = result @@ -300,13 +470,12 @@ async function bundleWorkerEntry( outputChunk.fileName, outputChunk.code, assets, + moduleIds, config.logger, ) return newBundleInfo } -export const workerAssetUrlRE: RegExp = /__VITE_WORKER_ASSET__([a-z\d]{8})__/g - export async function workerFileToUrl( config: ResolvedConfig, id: string, @@ -413,7 +582,6 @@ export function webWorkerPostPlugin(_config: ResolvedConfig): Plugin { } export function webWorkerPlugin(config: ResolvedConfig): Plugin { - const isBuild = config.command === 'build' const isWorker = config.isWorker workerOutputCaches.set(config, new WorkerOutputCache()) @@ -425,6 +593,7 @@ export function webWorkerPlugin(config: ResolvedConfig): Plugin { buildStart() { if (isWorker) return emittedAssets.clear() + workerOutputCaches.get(config)!.clearEntryReferenceIds(this.environment) }, load: { @@ -452,6 +621,12 @@ export function webWorkerPlugin(config: ResolvedConfig): Plugin { if (isWorker && config.bundleChain.at(-1) === cleanUrl(id)) { urlCode = 'self.location.href' } else if (inlineRE.test(id)) { + recordWorkerReference( + config, + config.bundleChain.at(-1), + cleanUrl(id), + id, + ) const result = await bundleWorkerEntry(config, id) for (const file of result.watchedFiles) { this.addWatchFile(file) @@ -502,28 +677,38 @@ export function webWorkerPlugin(config: ResolvedConfig): Plugin { map: { mappings: '' }, } } else { + recordWorkerReference( + config, + config.bundleChain.at(-1), + cleanUrl(id), + id, + ) const result = await workerFileToUrl(config, id) - let url: string if ( this.environment.config.command === 'serve' && this.environment.config.isBundled ) { emitWorkerAssetsForBundledDev(this, config) - url = toOutputFilePathInJSForBundledDev( - this.environment, - result.entryFilename, + urlCode = JSON.stringify( + toOutputFilePathInJSForBundledDev( + this.environment, + result.entryFilename, + ), ) } else { - url = result.entryUrlPlaceholder + urlCode = generateWorkerEntryUrlExpr(this, config, result) } - urlCode = JSON.stringify(url) for (const file of result.watchedFiles) { this.addWatchFile(file) } } } else { - let url = await fileToUrl(this, cleanUrl(id)) - url = injectQuery(url, `${WORKER_FILE_ID}&type=${workerType}`) + const { file, postfix } = splitWorkerRequest(id) + let url = await fileToUrl(this, file, 'string') + url = injectQuery( + `${url}${postfix}`, + `${WORKER_FILE_ID}&type=${workerType}`, + ) urlCode = JSON.stringify(url) } @@ -590,78 +775,43 @@ export function webWorkerPlugin(config: ResolvedConfig): Plugin { }, }, - ...(isBuild - ? { - renderChunk(code, chunk, outputOptions) { - let s: MagicString - const result = () => { - return ( - s && { - code: s.toString(), - map: this.environment.config.build.sourcemap - ? s.generateMap({ hires: 'boundary' }) - : null, - } - ) - } - workerAssetUrlRE.lastIndex = 0 - if (workerAssetUrlRE.test(code)) { - const toRelativeRuntime = - createToImportMetaURLBasedRelativeRuntime( - outputOptions.format, - this.environment.config.isWorker, - ) - - let match: RegExpExecArray | null - s = new MagicString(code) - workerAssetUrlRE.lastIndex = 0 - - // Replace "__VITE_WORKER_ASSET__5aa0ddc0__" using relative paths - const workerOutputCache = workerOutputCaches.get( - config.mainConfig || config, - )! - - while ((match = workerAssetUrlRE.exec(code))) { - const [full, hash] = match - const filename = - workerOutputCache.getEntryFilenameFromHash(hash) - if (!filename) { - this.warn(`Could not find worker asset for hash: ${hash}`) - continue - } - const replacement = toOutputFilePathInJS( - this.environment, - filename, - 'asset', - chunk.fileName, - 'js', - toRelativeRuntime, - ) - const replacementString = - typeof replacement === 'string' - ? JSON.stringify(encodeURIPath(replacement)).slice(1, -1) - : `"+${replacement.runtime}+"` - s.update( - match.index, - match.index + full.length, - replacementString, - ) - } - } - return result() - }, - } - : {}), - generateBundle(opts, bundle) { // to avoid emitting duplicate assets for modern build and legacy build - if ( - this.environment.config.isOutputOptionsForLegacyChunks?.(opts) || - isWorker - ) { + if (this.environment.config.isOutputOptionsForLegacyChunks?.(opts)) { return } - for (const asset of workerOutputCaches.get(config)!.getAssets()) { + const cache = workerOutputCaches.get(config.mainConfig || config)! + const liveModuleIds = collectIncludedModuleIds(Object.values(bundle)) + // Reference tracking relies on hooks running for every module, which is + // not guaranteed when an incremental build reuses cached modules. + const shouldFilter = + this.environment.config.command === 'build' && !config.build.watch + + // `import.meta.ROLLDOWN_FILE_URL_*` requires calling `emitFile` while the + // referencing module is loaded. Remove those eagerly emitted assets when + // Rolldown later tree-shakes the module that referenced them. This also + // needs to run for worker sub-builds so dead nested workers don't become + // referenced assets of their parent worker bundle. + if (shouldFilter) { + const rootBundleId = isWorker ? config.bundleChain.at(-1) : undefined + for (const workerBundle of cache.getDeadDirectlyReferencedBundles( + rootBundleId, + liveModuleIds, + )) { + const emittedAsset = bundle[workerBundle.entryFilename] + if (emittedAsset?.type === 'asset') { + delete bundle[workerBundle.entryFilename] + } + } + } + + if (isWorker) return + + const liveFileNames = shouldFilter + ? cache.getLiveAssetFileNames(liveModuleIds) + : undefined + for (const asset of cache.getAssets()) { + if (liveFileNames && !liveFileNames.has(asset.fileName)) continue if (emittedAssets.has(asset.fileName)) continue emittedAssets.add(asset.fileName) @@ -696,6 +846,20 @@ export function webWorkerPlugin(config: ResolvedConfig): Plugin { } } +function collectIncludedModuleIds( + outputs: (OutputChunk | OutputAsset)[], +): Set { + const moduleIds = new Set() + for (const output of outputs) { + if (output.type === 'chunk') { + for (const moduleId of output.moduleIds) { + moduleIds.add(moduleId) + } + } + } + return moduleIds +} + function isSameContent(a: string | Uint8Array, b: string | Uint8Array) { if (typeof a === 'string') { if (typeof b === 'string') { diff --git a/packages/vite/src/node/plugins/workerImportMetaUrl.ts b/packages/vite/src/node/plugins/workerImportMetaUrl.ts index 7107a1ebadcda5..cc731d24a3fa05 100644 --- a/packages/vite/src/node/plugins/workerImportMetaUrl.ts +++ b/packages/vite/src/node/plugins/workerImportMetaUrl.ts @@ -9,11 +9,13 @@ import type { Plugin } from '../plugin' import { evalValue, injectQuery, transformStableResult } from '../utils' import { createBackCompatIdResolver } from '../idResolver' import type { ResolveIdFn } from '../idResolver' -import { cleanUrl, slash } from '../../shared/utils' +import { cleanUrl, slash, splitFileAndPostfix } from '../../shared/utils' import type { WorkerType } from './worker' import { WORKER_FILE_ID, emitWorkerAssetsForBundledDev, + recordWorkerReference, + generateWorkerEntryUrlExpr, workerFileToUrl, } from './worker' import { fileToUrl, toOutputFilePathInJSForBundledDev } from './asset' @@ -233,9 +235,11 @@ export function workerImportMetaUrlPlugin(config: ResolvedConfig): Plugin { s ||= new MagicString(code) const workerType = await getWorkerType(code, cleanString, endIndex) const url = rawUrl.slice(1, -1) + const { file: urlWithoutPostfix, postfix } = splitFileAndPostfix(url) + const queryPostfix = postfix[0] === '?' ? postfix : '' let file: string | undefined - if (url[0] === '.') { - file = path.resolve(path.dirname(id), url) + if (urlWithoutPostfix[0] === '.') { + file = path.resolve(path.dirname(id), urlWithoutPostfix) file = slash(tryFsResolve(file, fsResolveOptions) ?? file) } else { workerResolver ??= createBackCompatIdResolver(config, { @@ -243,11 +247,11 @@ export function workerImportMetaUrlPlugin(config: ResolvedConfig): Plugin { tryIndex: false, preferRelative: true, }) - file = await workerResolver(this.environment, url, id) + file = await workerResolver(this.environment, urlWithoutPostfix, id) file ??= - url[0] === '/' - ? slash(path.join(config.publicDir, url)) - : slash(path.resolve(path.dirname(id), url)) + urlWithoutPostfix[0] === '/' + ? slash(path.join(config.publicDir, urlWithoutPostfix)) + : slash(path.resolve(path.dirname(id), urlWithoutPostfix)) } if ( @@ -257,33 +261,42 @@ export function workerImportMetaUrlPlugin(config: ResolvedConfig): Plugin { ) { s.update(expStart, expEnd, 'self.location.href') } else { - let builtUrl: string + let builtUrlExpr: string if (isBundled) { + recordWorkerReference( + config, + config.bundleChain.at(-1), + cleanUrl(file), + id, + ) const result = await workerFileToUrl(config, file) if (this.environment.config.command === 'serve') { emitWorkerAssetsForBundledDev(this, config) - builtUrl = toOutputFilePathInJSForBundledDev( - this.environment, - result.entryFilename, + builtUrlExpr = JSON.stringify( + toOutputFilePathInJSForBundledDev( + this.environment, + result.entryFilename, + ), ) } else { - builtUrl = result.entryUrlPlaceholder + builtUrlExpr = generateWorkerEntryUrlExpr(this, config, result) } for (const file of result.watchedFiles) { this.addWatchFile(file) } } else { - builtUrl = await fileToUrl(this, cleanUrl(file)) - builtUrl = injectQuery( - builtUrl, + builtUrlExpr = await fileToUrl(this, cleanUrl(file), 'string') + builtUrlExpr = injectQuery( + `${builtUrlExpr}${queryPostfix}`, `${WORKER_FILE_ID}&type=${workerType}`, ) + builtUrlExpr = JSON.stringify(builtUrlExpr) } s.update( expStart, expEnd, // NOTE: add `'' +` to opt-out rolldown's transform: https://github.com/rolldown/rolldown/issues/2745 - `new URL(/* @vite-ignore */ ${JSON.stringify(builtUrl)}, '' + import.meta.url)`, + `new URL(/* @vite-ignore */ ${builtUrlExpr}, '' + import.meta.url)`, ) } } diff --git a/packages/vite/src/node/preview.ts b/packages/vite/src/node/preview.ts index ac34549420e126..93e1331491b561 100644 --- a/packages/vite/src/node/preview.ts +++ b/packages/vite/src/node/preview.ts @@ -121,6 +121,10 @@ export type PreviewServerHook = ( server: PreviewServer, ) => (() => void) | void | Promise<(() => void) | void> +export type ClosePreviewServerHook = ( + this: MinimalPluginContextWithoutEnvironment, +) => void | Promise + /** * Starts the Vite server in preview mode, to simulate a production deployment */ @@ -169,8 +173,20 @@ export async function preview( let closeServerPromise: Promise | undefined const closeServer = async () => { teardownSIGTERMListener(closeServerAndExit) + await closeHttpServer() server.resolvedUrls = null + + // Run `closePreviewServer` plugin hooks after the server has been torn down. + const closePreviewServerContext = new BasicMinimalPluginContext( + { ...basePluginContextMeta, watchMode: false }, + config.logger, + ) + await Promise.all( + config + .getSortedPluginHooks('closePreviewServer') + .map((hook) => hook.call(closePreviewServerContext)), + ) } const server: PreviewServer = { diff --git a/packages/vite/src/node/server/__tests__/pluginContainer.spec.ts b/packages/vite/src/node/server/__tests__/pluginContainer.spec.ts index 297ee5eeee4afa..e05f9a045b4c11 100644 --- a/packages/vite/src/node/server/__tests__/pluginContainer.spec.ts +++ b/packages/vite/src/node/server/__tests__/pluginContainer.spec.ts @@ -477,6 +477,62 @@ describe('plugin container', () => { }) }) }) + + describe('closeBundle', () => { + it('passes buildEnd errors to closeBundle', async () => { + const buildEndError = new Error('buildEnd failed') + let closeBundleError: Error | undefined + const environment = await getDevEnvironment({ + plugins: [ + { + name: 'failing-build-end', + buildEnd() { + throw buildEndError + }, + }, + { + name: 'close-bundle-cleanup', + closeBundle(error) { + closeBundleError = error + }, + }, + ], + }) + + await expect(environment.pluginContainer.close()).rejects.toBe( + buildEndError, + ) + expect(closeBundleError).toBe(buildEndError) + }) + + it('passes no error to closeBundle when buildEnd succeeds', async () => { + let buildEndCalled = false + let closeBundleCalled = false + let closeBundleError: Error | undefined + const environment = await getDevEnvironment({ + plugins: [ + { + name: 'successful-build-end', + buildEnd() { + buildEndCalled = true + }, + }, + { + name: 'close-bundle-cleanup', + closeBundle(error) { + closeBundleCalled = true + closeBundleError = error + }, + }, + ], + }) + + await expect(environment.pluginContainer.close()).resolves.toBeUndefined() + expect(buildEndCalled).toBe(true) + expect(closeBundleCalled).toBe(true) + expect(closeBundleError).toBeUndefined() + }) + }) }) async function getDevEnvironment( diff --git a/packages/vite/src/node/server/bundledDev.ts b/packages/vite/src/node/server/bundledDev.ts index f5b6ca0c4c06dc..7add7882d73649 100644 --- a/packages/vite/src/node/server/bundledDev.ts +++ b/packages/vite/src/node/server/bundledDev.ts @@ -8,6 +8,7 @@ import type { RolldownOutput } from 'rolldown' import colors from 'picocolors' import getEtag from 'etag' import { ChunkMetadataMap, resolveRolldownOptions } from '../build' +import { convertToDevWatchOptions } from '../watch' import { BUNDLED_DEV_CLIENT_FILENAME } from '../constants' import { getHmrImplementation } from '../plugins/clientInjections' import { createDebugger, formatAndTruncateFileList } from '../utils' @@ -246,6 +247,7 @@ export class BundledDev { }, watch: { skipWrite: true, + ...convertToDevWatchOptions(this.environment.config.server.watch), }, }) debug?.('INITIAL: setup dev engine') diff --git a/packages/vite/src/node/server/environment.ts b/packages/vite/src/node/server/environment.ts index a08b4e11a59653..b0291f2004f3cc 100644 --- a/packages/vite/src/node/server/environment.ts +++ b/packages/vite/src/node/server/environment.ts @@ -17,7 +17,7 @@ import { createExplicitDepsOptimizer, } from '../optimizer/optimizer' import { ERR_OUTDATED_OPTIMIZED_DEP } from '../../shared/constants' -import { cleanUrl, promiseWithResolvers } from '../../shared/utils' +import { cleanUrl, promiseWithResolvers, unwrapId } from '../../shared/utils' import type { ViteDevServer } from '../server' import { EnvironmentModuleGraph } from './moduleGraph' import type { EnvironmentModuleNode } from './moduleGraph' @@ -176,7 +176,7 @@ export class DevEnvironment extends BaseEnvironment { ({ path, message, firstInvalidatedBy }, client) => { this.invalidateModule( { - path, + path: unwrapId(path), message, firstInvalidatedBy, }, diff --git a/packages/vite/src/node/server/hmr.ts b/packages/vite/src/node/server/hmr.ts index 84e9616b5d45f1..a911ebb9a913ad 100644 --- a/packages/vite/src/node/server/hmr.ts +++ b/packages/vite/src/node/server/hmr.ts @@ -21,7 +21,7 @@ import { getHookHandler } from '../plugins' import { isExplicitImportRequired } from '../plugins/importAnalysis' import { getEnvFilesForMode } from '../env' import type { Environment } from '../environment' -import { withTrailingSlash, wrapId } from '../../shared/utils' +import { withTrailingSlash } from '../../shared/utils' import type { Plugin } from '../plugin' import { ignoreDeprecationWarnings, @@ -719,8 +719,7 @@ export function updateModules( if ( firstInvalidatedBy && boundaries.some( - ({ acceptedVia }) => - normalizeHmrUrl(acceptedVia.url) === firstInvalidatedBy, + ({ acceptedVia }) => acceptedVia.url === firstInvalidatedBy, ) ) { needFullReload = 'circular import invalidate' @@ -732,8 +731,8 @@ export function updateModules( ({ boundary, acceptedVia, isWithinCircularImport }) => ({ type: `${boundary.type}-update` as const, timestamp, - path: normalizeHmrUrl(boundary.url), - acceptedPath: normalizeHmrUrl(acceptedVia.url), + path: boundary.url, + acceptedPath: acceptedVia.url, explicitImportRequired: boundary.type === 'js' ? isExplicitImportRequired(acceptedVia.url) @@ -1124,13 +1123,6 @@ export function lexAcceptedHmrExports( return urls.size > 0 } -export function normalizeHmrUrl(url: string): string { - if (url[0] !== '.' && url[0] !== '/') { - url = wrapId(url) - } - return url -} - function error(pos: number) { const err = new Error( `import.meta.hot.accept() can only accept string literals or an ` + diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index 2dd71aa85c84f8..85056f56878cc4 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -16,7 +16,7 @@ import { determineAgent } from '@vercel/detect-agent' import { disableCache } from '@voidzero-dev/vite-task-client' import type { SourceMap } from 'rolldown' import type { ModuleRunner } from 'vite/module-runner' -import type { FSWatcher, WatchOptions } from '#dep-types/chokidar' +import type { FSWatcher } from '#dep-types/chokidar' import type { Connect } from '#dep-types/connect' import type { CommonServerOptions } from '../http' import type { @@ -68,6 +68,7 @@ import { resolveChokidarOptions, resolveEmptyOutDir, } from '../watch' +import type { ServerWatchOptions } from '../watch' import { initPublicFiles } from '../publicDir' import { getEnvFilesForMode } from '../env' import type { RequiredExceptFor } from '../typeUtils' @@ -139,10 +140,14 @@ export interface ServerOptions extends CommonServerOptions { ssrFiles?: string[] } /** - * chokidar watch options or null to disable FS watching - * https://github.com/paulmillr/chokidar/tree/3.6.0#api + * File system watcher options, or null to disable FS watching. + * + * Accepts chokidar options + * (https://github.com/paulmillr/chokidar/tree/3.6.0#api), which are used by + * the chokidar watcher, and Rolldown watch options, which are used by the + * Rolldown file watcher when bundled dev mode is enabled. */ - watch?: WatchOptions | null + watch?: ServerWatchOptions | null /** * Create Vite dev server to be used as a middleware in an existing server * @default false @@ -266,6 +271,20 @@ export type ServerHook = ( server: ViteDevServer, ) => (() => void) | void | Promise<(() => void) | void> +export interface CloseServerHookContext { + /** + * Whether the server is being restarted (e.g. a config change or + * `server.restart()`) or closed (e.g. the `q` shortcut, SIGTERM, stdin + * ending, or `server.close()`). + */ + reason: 'restart' | 'close' +} + +export type CloseServerHook = ( + this: MinimalPluginContextWithoutEnvironment, + context: CloseServerHookContext, +) => void | Promise + export type HttpServer = http.Server | Http2SecureServer export async function resolveForwardConsoleOptions( @@ -439,6 +458,13 @@ export interface ViteDevServer { * @internal */ _setInternalServer(server: ViteDevServer): void + /** + * Internal close implementation shared by `close()` and `restart()`. The + * `reason` is forwarded to `closeServer` plugin hooks so they can distinguish + * a restart from a real close. + * @internal + */ + _closeServer(reason: 'restart' | 'close'): Promise /** * @internal */ @@ -606,7 +632,7 @@ export async function _createServer( // Promise used by `server.close()` to ensure `closeServer()` is only called once let closeServerPromise: Promise | undefined - const closeServer = async () => { + const closeServer = async (reason: 'restart' | 'close') => { if (!middlewareMode) { teardownSIGTERMListener(closeServerAndExit) } @@ -624,6 +650,17 @@ export async function _createServer( ]) server.resolvedUrls = null server._ssrCompatModuleRunner = undefined + + // Run `closeServer` plugin hooks after the server has been torn down. + const closeServerContext = new BasicMinimalPluginContext( + { ...basePluginContextMeta, watchMode: true }, + config.logger, + ) + await Promise.all( + config + .getSortedPluginHooks('closeServer') + .map((hook) => hook.call(closeServerContext, { reason })), + ) } let hot = ws @@ -782,10 +819,7 @@ export async function _createServer( } }, async close() { - if (!closeServerPromise) { - closeServerPromise = closeServer() - } - return closeServerPromise + return server._closeServer('close') }, printUrls() { if (server.resolvedUrls) { @@ -825,6 +859,12 @@ export async function _createServer( // server instance after a restart server = _server }, + _closeServer(reason: 'restart' | 'close') { + if (!closeServerPromise) { + closeServerPromise = closeServer(reason) + } + return closeServerPromise + }, _restartPromise: options.previousRestartPromise ?? null, _forceOptimizeOnRestart: options.previousForceOptimizeOnRestart ?? false, _shortcutsState: options.previousShortcutsState, @@ -1380,7 +1420,9 @@ async function restartServer(server: ViteDevServer) { // Detach readline so close handler skips it. Reused to avoid stdin issues server._shortcutsState = undefined - await server.close() + // Close with reason 'restart' so `closeServer` hooks can distinguish a + // restart from a real close. + await server._closeServer('restart') // Assign new server props to existing server instance const middlewares = server.middlewares diff --git a/packages/vite/src/node/server/pluginContainer.ts b/packages/vite/src/node/server/pluginContainer.ts index 081b57d4476bf6..9f5a24ab186e4e 100644 --- a/packages/vite/src/node/server/pluginContainer.ts +++ b/packages/vite/src/node/server/pluginContainer.ts @@ -646,20 +646,28 @@ class EnvironmentPluginContainer { this._closed = true await Promise.allSettled(Array.from(this._processesing)) const config = this.environment.getTopLevelConfig() - await this.hookParallel( - 'buildEnd', - (plugin) => this._getPluginContext(plugin), - () => [], - (plugin) => - this.environment.name === 'client' || - config.server.perEnvironmentStartEndDuringDev || - plugin.perEnvironmentStartEndDuringDev, - ) + let buildEndError: Error | undefined + try { + await this.hookParallel( + 'buildEnd', + (plugin) => this._getPluginContext(plugin), + () => [], + (plugin) => + this.environment.name === 'client' || + config.server.perEnvironmentStartEndDuringDev || + plugin.perEnvironmentStartEndDuringDev, + ) + } catch (error) { + buildEndError = error as Error + } await this.hookParallel( 'closeBundle', (plugin) => this._getPluginContext(plugin), - () => [], + () => [buildEndError], ) + if (buildEndError) { + throw buildEndError + } } } diff --git a/packages/vite/src/node/server/transformRequest.ts b/packages/vite/src/node/server/transformRequest.ts index 35d01b3833d129..d682a382760011 100644 --- a/packages/vite/src/node/server/transformRequest.ts +++ b/packages/vite/src/node/server/transformRequest.ts @@ -491,15 +491,15 @@ async function handleModuleSoftInvalidation( } const urlWithoutTimestamp = removeTimestampQuery(rawUrl) - // hmrUrl must be derived the same way as importAnalysis - const hmrUrl = unwrapId( + // moduleUrl must be derived the same way as importAnalysis + const moduleUrl = unwrapId( stripBase( removeImportQuery(urlWithoutTimestamp), environment.config.base, ), ) for (const importedMod of mod.importedModules) { - if (importedMod.url !== hmrUrl) continue + if (importedMod.url !== moduleUrl) continue if (importedMod.lastHMRTimestamp > 0) { const replacedUrl = injectQuery( urlWithoutTimestamp, @@ -512,7 +512,7 @@ async function handleModuleSoftInvalidation( if (imp.d === -1 && environment.config.dev.preTransformRequests) { // pre-transform known direct imports - environment.warmupRequest(hmrUrl) + environment.warmupRequest(moduleUrl) } break diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index 779020aaa0fda9..d3856602ebe2eb 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -314,7 +314,7 @@ const internalPrefixes = [ ENV_PUBLIC_PATH, ] const InternalPrefixRE = new RegExp(`^(?:${internalPrefixes.join('|')})`) -const trailingSeparatorRE = /[?&]$/ +export const trailingSeparatorRE: RegExp = /[?&]$/ export const isImportRequest = (url: string): boolean => importQueryRE.test(url) export const isInternalRequest = (url: string): boolean => InternalPrefixRE.test(url) diff --git a/packages/vite/src/node/watch.ts b/packages/vite/src/node/watch.ts index 3fc2a5a600acc5..e44fb7e5107f65 100644 --- a/packages/vite/src/node/watch.ts +++ b/packages/vite/src/node/watch.ts @@ -1,6 +1,7 @@ import { EventEmitter } from 'node:events' import path from 'node:path' import type { OutputOptions, WatcherOptions } from 'rolldown' +import type { DevWatchOptions } from 'rolldown/experimental' import colors from 'picocolors' import { escapePath } from 'tinyglobby' import type { FSWatcher, WatchOptions } from '#dep-types/chokidar' @@ -48,13 +49,32 @@ export function resolveEmptyOutDir( return true } +/** + * Watch options for the dev server. Accepts both chokidar options and Rolldown + * watch options. The chokidar options are used by the chokidar watcher, while + * the Rolldown options are used by the Rolldown file watcher when bundled dev + * mode is enabled. + */ +export type ServerWatchOptions = WatchOptions & + Omit + export function resolveChokidarOptions( - options: WatchOptions | undefined, + options: ServerWatchOptions | undefined, resolvedOutDirs: Set, emptyOutDir: boolean, cacheDir: string, ): WatchOptions { - const { ignored: ignoredList, ...otherOptions } = options ?? {} + const { + ignored: ignoredList, + pollInterval, + useDebounce, + debounceDuration, + debounceTickRate, + compareContentsForPolling, + include, + exclude, + ...otherOptions + } = options ?? {} const ignored: WatchOptions['ignored'] = [ '**/.git/**', '**/node_modules/**', @@ -89,6 +109,25 @@ export function convertToWatcherOptions( } } +export function convertToDevWatchOptions( + options: ServerWatchOptions | null | undefined, +): DevWatchOptions { + // eslint-disable-next-line eqeqeq + if (options === null) return { enabled: false } + if (!options) return {} + + return { + usePolling: options.usePolling, + pollInterval: options.pollInterval ?? options.interval, + useDebounce: options.useDebounce, + debounceDuration: options.debounceDuration, + debounceTickRate: options.debounceTickRate, + compareContentsForPolling: options.compareContentsForPolling, + include: options.include, + exclude: options.exclude, + } +} + class NoopWatcher extends EventEmitter implements FSWatcher { constructor(public options: WatchOptions) { super() diff --git a/packages/vite/src/types/shims.d.ts b/packages/vite/src/types/shims.d.ts index ef06f63d87308d..e654065209e036 100644 --- a/packages/vite/src/types/shims.d.ts +++ b/packages/vite/src/types/shims.d.ts @@ -26,4 +26,6 @@ declare module 'postcss-import' { // eslint-disable-next-line no-var declare var __vite_profile_session: import('node:inspector').Session | undefined // eslint-disable-next-line no-var +declare var __vite_profile_name: string | undefined +// eslint-disable-next-line no-var declare var __vite_start_time: number | undefined diff --git a/packages/vite/types/customEvent.d.ts b/packages/vite/types/customEvent.d.ts index d117f4bc18f2a0..252b014bcd7a49 100644 --- a/packages/vite/types/customEvent.d.ts +++ b/packages/vite/types/customEvent.d.ts @@ -39,8 +39,16 @@ export interface WebSocketConnectionPayload { } export interface InvalidatePayload { + /** + * Module URL of the invalidated module. + * @remarks This changed from a browser-safe URL to a module URL. + */ path: string message: string | undefined + /** + * Module URL of the first module that invalidated the update. + * @remarks This changed from a browser-safe URL to a module URL. + */ firstInvalidatedBy: string } diff --git a/packages/vite/types/hmrPayload.d.ts b/packages/vite/types/hmrPayload.d.ts index 85daa03647ba1f..3b4e76762c0a91 100644 --- a/packages/vite/types/hmrPayload.d.ts +++ b/packages/vite/types/hmrPayload.d.ts @@ -38,7 +38,15 @@ export interface BundledDevUpdatePayload { export interface Update { type: 'js-update' | 'css-update' + /** + * Module URL of the HMR boundary. + * @remarks This changed from a browser-safe URL to a module URL. + */ path: string + /** + * Module URL of the accepted module. + * @remarks This changed from a browser-safe URL to a module URL. + */ acceptedPath: string timestamp: number /** @internal */ diff --git a/playground/assets/__tests__/assets.spec.ts b/playground/assets/__tests__/assets.spec.ts index 30fc7672a9d55d..46e540d5c7a610 100644 --- a/playground/assets/__tests__/assets.spec.ts +++ b/playground/assets/__tests__/assets.spec.ts @@ -147,6 +147,10 @@ describe('asset imports from js', () => { expect(await page.textContent('.public-import')).toMatch(iconMatch) }) + test('typeof asset import', async () => { + expect(await page.textContent('.asset-import-typeof')).toBe('string') + }) + test('from /public (json)', async () => { expect(await page.textContent('.public-json-import')).toMatch( '/foo/bar/foo.json', diff --git a/playground/assets/__tests__/encoded-base/assets-encoded-base.spec.ts b/playground/assets/__tests__/encoded-base/assets-encoded-base.spec.ts index 5d236696fdf222..900fb34df2c9a8 100644 --- a/playground/assets/__tests__/encoded-base/assets-encoded-base.spec.ts +++ b/playground/assets/__tests__/encoded-base/assets-encoded-base.spec.ts @@ -67,6 +67,10 @@ describe('asset imports from js', () => { absolutePublicIconMatch, ) }) + + test('typeof asset import', async () => { + expect(await page.textContent('.asset-import-typeof')).toBe('string') + }) }) describe('css url() references', () => { diff --git a/playground/assets/__tests__/relative-base/assets-relative-base.spec.ts b/playground/assets/__tests__/relative-base/assets-relative-base.spec.ts index 7691c7fd669e8b..69b2b324119891 100644 --- a/playground/assets/__tests__/relative-base/assets-relative-base.spec.ts +++ b/playground/assets/__tests__/relative-base/assets-relative-base.spec.ts @@ -71,6 +71,10 @@ describe('asset imports from js', () => { absolutePublicIconMatch, ) }) + + test('typeof asset import', async () => { + expect(await page.textContent('.asset-import-typeof')).toBe('string') + }) }) describe('css url() references', () => { diff --git a/playground/assets/__tests__/runtime-base/assets-runtime-base.spec.ts b/playground/assets/__tests__/runtime-base/assets-runtime-base.spec.ts index 1534382dcf7386..2b89521b8cabc5 100644 --- a/playground/assets/__tests__/runtime-base/assets-runtime-base.spec.ts +++ b/playground/assets/__tests__/runtime-base/assets-runtime-base.spec.ts @@ -64,6 +64,10 @@ describe('asset imports from js', () => { absolutePublicIconMatch, ) }) + + test('typeof asset import', async () => { + expect(await page.textContent('.asset-import-typeof')).toBe('string') + }) }) describe('css url() references', () => { diff --git a/playground/assets/__tests__/url-base/assets-url-base.spec.ts b/playground/assets/__tests__/url-base/assets-url-base.spec.ts index 8090d61a6a2280..0e7b044d7e9be9 100644 --- a/playground/assets/__tests__/url-base/assets-url-base.spec.ts +++ b/playground/assets/__tests__/url-base/assets-url-base.spec.ts @@ -69,6 +69,10 @@ describe('asset imports from js', () => { absolutePublicIconMatch, ) }) + + test('typeof asset import', async () => { + expect(await page.textContent('.asset-import-typeof')).toBe('string') + }) }) describe('css url() references', () => { diff --git a/playground/assets/index.html b/playground/assets/index.html index e847e7fa893cbf..6ba5233498bcd4 100644 --- a/playground/assets/index.html +++ b/playground/assets/index.html @@ -32,6 +32,7 @@

Asset Imports from JS

  • Relative:
  • Absolute:
  • From publicDir:
  • +
  • Typeof asset import: failed
  • From publicDir (json): Content: @@ -512,6 +513,11 @@

    assets in template

    import publicUrl from '/icon.png' text('.public-import', publicUrl) + import typeofAssetUrl from './nested/asset2.png' + if (typeof typeofAssetUrl === 'string') { + text('.asset-import-typeof', 'string') + } + import publicJsonUrl from '/foo.json?url' text('.public-json-import', publicJsonUrl) ;(async () => { diff --git a/playground/assets/nested/asset2.png b/playground/assets/nested/asset2.png new file mode 100644 index 00000000000000..b795d87c3e6b91 Binary files /dev/null and b/playground/assets/nested/asset2.png differ diff --git a/playground/css-lightningcss/__tests__/css-lightningcss.spec.ts b/playground/css-lightningcss/__tests__/css-lightningcss.spec.ts index dbfe3c7984141a..d9f1e5e38c19bc 100644 --- a/playground/css-lightningcss/__tests__/css-lightningcss.spec.ts +++ b/playground/css-lightningcss/__tests__/css-lightningcss.spec.ts @@ -80,6 +80,18 @@ test.runIf(isBuild)('minify css', async () => { expect(cssFile).not.toMatch('#ffff00b3') }) +test.runIf(isBuild)('minify inline style', () => { + expect(readFile('dist/index.html')).toContain( + '', + ) +}) + +test.runIf(isBuild)('escapes closing style tags in inline CSS', () => { + expect(readFile('dist/index.html')).toContain( + '', + ) +}) + test.runIf(isBuild)('does not run the visitor again during minify', () => { expect(readFile('dist/media-query-visits.txt')).toBe('2') }) diff --git a/playground/css-lightningcss/index.html b/playground/css-lightningcss/index.html index 75d9eebafda16c..376d3930e5b049 100644 --- a/playground/css-lightningcss/index.html +++ b/playground/css-lightningcss/index.html @@ -1,5 +1,17 @@ + + + +

    Lightning CSS

    diff --git a/playground/dynamic-import/__tests__/dynamic-import.spec.ts b/playground/dynamic-import/__tests__/dynamic-import.spec.ts index 07a4dd3f0c9451..c43e3dbaf7fee3 100644 --- a/playground/dynamic-import/__tests__/dynamic-import.spec.ts +++ b/playground/dynamic-import/__tests__/dynamic-import.spec.ts @@ -115,6 +115,12 @@ test('should load dynamic import with vars alias', async () => { .toMatch('hi') }) +test('should load dynamic import with vars subpath imports', async () => { + await expect + .poll(() => page.textContent('.dynamic-import-with-vars-subpath-imports')) + .toMatch('hi') +}) + test('should load dynamic import with vars raw', async () => { await expect .poll(() => page.textContent('.dynamic-import-with-vars-raw')) diff --git a/playground/dynamic-import/index.html b/playground/dynamic-import/index.html index d8677c3293e73f..51c1f613d1c23b 100644 --- a/playground/dynamic-import/index.html +++ b/playground/dynamic-import/index.html @@ -25,6 +25,9 @@

    dynamic-import-with-vars-alias

    todo
    +

    dynamic-import-with-vars-subpath-imports

    +
    todo
    +

    dynamic-import-with-vars-raw

    todo
    diff --git a/playground/dynamic-import/nested/index.js b/playground/dynamic-import/nested/index.js index b495b2d9be4863..96660cc8f4b2af 100644 --- a/playground/dynamic-import/nested/index.js +++ b/playground/dynamic-import/nested/index.js @@ -127,6 +127,10 @@ import(`@/${base}.js`).then((mod) => { text('.dynamic-import-with-vars-alias', mod.hi()) }) +import(`#alias/${base}.js`).then((mod) => { + text('.dynamic-import-with-vars-subpath-imports', mod.hi()) +}) + base = 'self' import(`../nested/${base}.js`).then((mod) => { text('.dynamic-import-self', mod.self) diff --git a/playground/dynamic-import/package.json b/playground/dynamic-import/package.json index d3ab6846463268..b2d73b662aa010 100644 --- a/playground/dynamic-import/package.json +++ b/playground/dynamic-import/package.json @@ -3,6 +3,9 @@ "private": true, "version": "0.0.0", "type": "module", + "imports": { + "#alias/*": "./alias/*" + }, "scripts": { "dev": "vite", "build": "vite build", diff --git a/playground/hmr/__tests__/hmr.spec.ts b/playground/hmr/__tests__/hmr.spec.ts index 7e17b2363aedf1..f0fc876ea68765 100644 --- a/playground/hmr/__tests__/hmr.spec.ts +++ b/playground/hmr/__tests__/hmr.spec.ts @@ -303,6 +303,24 @@ if (!isBuild) { } }) + // bundled dev: same missing-importer-factory fallback as `invalidate` + test.skipIf(isBundledDev)( + 'invalidate virtual module propagates to importers', + async () => { + const el = await page.$('.virtual-invalidation-parent') + await expect.poll(() => el.textContent()).toBe('initial') + + // Edit the real dep file — Vite detects the change and sends + // js-update to the virtual module. The virtual module accepts + // then invalidates, which should propagate to parent.js. + editFile('virtual-invalidation/dep.js', (code) => + code.replace('initial', 'updated2'), + ) + + await expect.poll(() => el.textContent()).toBe('updated2') + }, + ) + test('invalidate on root triggers page reload', async () => { editFile('invalidation/root.js', (code) => code.replace('Init', 'Updated')) await page.waitForEvent('load') diff --git a/playground/hmr/index.html b/playground/hmr/index.html index e4ea468b98d67d..92cfda5c2c54bc 100644 --- a/playground/hmr/index.html +++ b/playground/hmr/index.html @@ -28,6 +28,8 @@
    +
    +
    diff --git a/playground/hmr/virtual-invalidation/dep.js b/playground/hmr/virtual-invalidation/dep.js new file mode 100644 index 00000000000000..de8c19c14c7d21 --- /dev/null +++ b/playground/hmr/virtual-invalidation/dep.js @@ -0,0 +1 @@ +export const depValue = 'initial' diff --git a/playground/hmr/virtual-invalidation/parent.js b/playground/hmr/virtual-invalidation/parent.js new file mode 100644 index 00000000000000..47b56698c4787a --- /dev/null +++ b/playground/hmr/virtual-invalidation/parent.js @@ -0,0 +1,7 @@ +import { value } from 'virtual:invalidation-file' + +if (import.meta.hot) { + import.meta.hot.accept() +} + +document.querySelector('.virtual-invalidation-parent').innerHTML = value diff --git a/playground/hmr/vite.config.ts b/playground/hmr/vite.config.ts index fbe6e4d6a21d9c..030775cd3fa3f2 100644 --- a/playground/hmr/vite.config.ts +++ b/playground/hmr/vite.config.ts @@ -52,6 +52,7 @@ export default defineConfig(({ command }) => ({ }, }, virtualPlugin(), + virtualInvalidationPlugin(), transformCountPlugin(), watchCssDepsPlugin(), TestCssLinkPlugin(), @@ -59,6 +60,33 @@ export default defineConfig(({ command }) => ({ ], })) +// Virtual module that calls invalidate() +function virtualInvalidationPlugin(): Plugin { + return { + name: 'virtual-invalidation-file', + resolveId(id) { + if (id === 'virtual:invalidation-file') { + return '\0virtual:invalidation-file' + } + }, + load(id) { + if (id === '\0virtual:invalidation-file') { + // Import a real file so editing it triggers Vite's HMR pipeline. + // The virtual module accepts and immediately invalidates, + // which should propagate to its importers. + return `\ +import { depValue } from '/virtual-invalidation/dep.js'; +export const value = depValue; +if (import.meta.hot) { + import.meta.hot.accept(() => { + import.meta.hot.invalidate() + }) +}` + } + }, + } +} + function virtualPlugin(): Plugin { let num = 0 return { diff --git a/playground/minify/__tests__/minify.spec.ts b/playground/minify/__tests__/minify.spec.ts index e7449449f8c40d..2cd45726c781bd 100644 --- a/playground/minify/__tests__/minify.spec.ts +++ b/playground/minify/__tests__/minify.spec.ts @@ -19,3 +19,15 @@ test.runIf(isBuild)('no minifySyntax', () => { expect(cssContent).toContain('color:#ff0000') expect(cssContent).not.toContain('/*! explicit comment */') }) + +test.runIf(isBuild)('minifies inline style with esbuild', () => { + expect(readFile('dist/index.html')).toContain( + '', + ) +}) + +test.runIf(isBuild)('escapes closing style tags with esbuild', () => { + expect(readFile('dist/index.html')).toContain( + '', + ) +}) diff --git a/playground/minify/index.html b/playground/minify/index.html index 1b599018cd92b6..6d032f2cfed258 100644 --- a/playground/minify/index.html +++ b/playground/minify/index.html @@ -1,3 +1,15 @@

    Minify

    + + + + diff --git a/playground/worker/__tests__/es/worker-es.spec.ts b/playground/worker/__tests__/es/worker-es.spec.ts index 781ead7a765435..dc2d46352bad59 100644 --- a/playground/worker/__tests__/es/worker-es.spec.ts +++ b/playground/worker/__tests__/es/worker-es.spec.ts @@ -141,6 +141,29 @@ describe.runIf(isBuild)('build', () => { .poll(() => page.textContent('.nested-worker-constructor')) .toMatch('"type":"constructor"') }) + + test('dead-code-eliminated worker asset is not emitted', () => { + const assetsDir = path.resolve(testDir, 'dist/es/assets') + const files = fs.readdirSync(assetsDir) + + // dce-test-importer.js is imported from main-module.js but its export is + // unused; rolldown tree-shakes the importer, so its `?worker` import + // should never reach the output bundle. + expect(files.some((f) => f.includes('dce-test-worker'))).toBe(false) + // the nested worker is only reachable via the (tree-shaken) parent worker, + // so it must not be emitted either. + expect(files.some((f) => f.includes('dce-test-nested-worker'))).toBe(false) + + // This parent worker is live, but the module that references its nested + // worker is tree-shaken from the parent worker bundle. + expect(files.some((f) => f.includes('dce-test-live-worker'))).toBe(true) + expect(files.some((f) => f.includes('dce-test-live-nested-worker'))).toBe( + false, + ) + + // sanity: the worker we DO use is still emitted (`my-worker`). + expect(files.some((f) => f.includes('my-worker'))).toBe(true) + }) }) test('module worker', async () => { diff --git a/playground/worker/__tests__/relative-base/worker-relative-base.spec.ts b/playground/worker/__tests__/relative-base/worker-relative-base.spec.ts index 883b5e5a743e5f..7a24ca37836712 100644 --- a/playground/worker/__tests__/relative-base/worker-relative-base.spec.ts +++ b/playground/worker/__tests__/relative-base/worker-relative-base.spec.ts @@ -79,8 +79,8 @@ describe.runIf(isBuild)('build', () => { expect(workerContent).not.toMatch(/import\s*["(]/) expect(workerContent).not.toMatch(/\bexport\b/) // chunk - expect(content).toMatch('new Worker(``+new URL(`../worker-entries/') - expect(content).toMatch('new SharedWorker(``+new URL(`../worker-entries/') + expect(content).toMatch('new Worker(new URL(`../worker-entries/') + expect(content).toMatch('new SharedWorker(new URL(`../worker-entries/') // inlined expect(content).toMatch(`(self.URL||self.webkitURL).createObjectURL`) expect(content).toMatch(`self.Blob`) diff --git a/playground/worker/dce-test-importer.js b/playground/worker/dce-test-importer.js new file mode 100644 index 00000000000000..b5af3b02afe242 --- /dev/null +++ b/playground/worker/dce-test-importer.js @@ -0,0 +1,3 @@ +import DceTestWorker from './dce-test-worker.js?worker' + +export const dceTestWorker = DceTestWorker diff --git a/playground/worker/dce-test-live-nested-worker.js b/playground/worker/dce-test-live-nested-worker.js new file mode 100644 index 00000000000000..d11e764e05375a --- /dev/null +++ b/playground/worker/dce-test-live-nested-worker.js @@ -0,0 +1 @@ +self.postMessage('dce-test-live-nested-worker should be tree-shaken') diff --git a/playground/worker/dce-test-live-worker-importer.js b/playground/worker/dce-test-live-worker-importer.js new file mode 100644 index 00000000000000..2e86ec0b718fe9 --- /dev/null +++ b/playground/worker/dce-test-live-worker-importer.js @@ -0,0 +1,3 @@ +import DceTestLiveNestedWorker from './dce-test-live-nested-worker.js?worker' + +export { DceTestLiveNestedWorker } diff --git a/playground/worker/dce-test-live-worker.js b/playground/worker/dce-test-live-worker.js new file mode 100644 index 00000000000000..ce3963f437bb4c --- /dev/null +++ b/playground/worker/dce-test-live-worker.js @@ -0,0 +1,3 @@ +import { DceTestLiveNestedWorker as _DceTestLiveNestedWorker } from './dce-test-live-worker-importer.js' + +self.postMessage('dce-test-live-worker is live') diff --git a/playground/worker/dce-test-nested-worker.js b/playground/worker/dce-test-nested-worker.js new file mode 100644 index 00000000000000..7b6fa58decf2ce --- /dev/null +++ b/playground/worker/dce-test-nested-worker.js @@ -0,0 +1 @@ +self.postMessage('dce-test-nested-worker should be tree-shaken') diff --git a/playground/worker/dce-test-worker.js b/playground/worker/dce-test-worker.js new file mode 100644 index 00000000000000..7a492e0f437c72 --- /dev/null +++ b/playground/worker/dce-test-worker.js @@ -0,0 +1,4 @@ +import DceTestNestedWorker from './dce-test-nested-worker.js?worker' + +const _nested = new DceTestNestedWorker() +self.postMessage('dce-test-worker should be tree-shaken') diff --git a/playground/worker/worker/main-module.js b/playground/worker/worker/main-module.js index 87259a46279712..8dd7965a793c4a 100644 --- a/playground/worker/worker/main-module.js +++ b/playground/worker/worker/main-module.js @@ -1,4 +1,9 @@ import * as depSelfReferenceUrlWorker from '@vitejs/test-dep-self-reference-url-worker' +import DceTestLiveWorker from '../dce-test-live-worker.js?worker' +// imported but never used — the dead-importer module itself is side-effect-free, +// so rolldown DCEs it along with its `?worker` import. The worker plugin should +// then skip emitting the dce-test-worker asset (and its nested worker too). +import { dceTestWorker as _dceTestWorker } from '../dce-test-importer.js' import myWorker from '../my-worker.ts?worker' import InlineWorker from '../my-worker.ts?worker&inline' import InlineSharedWorker from '../my-inline-shared-worker?sharedworker&inline' @@ -14,6 +19,8 @@ function text(el, text) { document.querySelector('.mode-true').textContent = mode +new DceTestLiveWorker() + const worker = new myWorker() worker.postMessage('ping') worker.addEventListener('message', (e) => {