diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 912db25cd40a20..39d212a1567c42 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -22,6 +22,7 @@ vite [root] | `--cors` | Enable CORS (`boolean`) | | `--strictPort` | Exit if specified port is already in use (`boolean`) | | `--force` | Force the optimizer to ignore the cache and re-bundle (`boolean`) | +| `--experimentalBundle` | Use experimental full bundle mode (this is highly experimental) (`boolean`) | | `-c, --config ` | Use specified config file (`string`) | | `--base ` | Public base path (default: `/`) (`string`) | | `-l, --logLevel ` | info \| warn \| error \| silent (`string`) | diff --git a/packages/vite/rollupLicensePlugin.ts b/packages/vite/rollupLicensePlugin.ts index 01b1c16001ddf7..9a0526afb5b0a7 100644 --- a/packages/vite/rollupLicensePlugin.ts +++ b/packages/vite/rollupLicensePlugin.ts @@ -12,7 +12,7 @@ export default function licensePlugin( ): Plugin { const originalPlugin = license({ thirdParty(dependencies) { - // https://github.com/rollup/rollup/blob/master/build-plugins/generate-license-file.js + // https://github.com/rollup/rollup/blob/1378cae13b33838de9c8ba9ef9152354f6eed27b/build-plugins/generate-license-file.js // MIT Licensed https://github.com/rollup/rollup/blob/master/LICENSE-CORE.md const coreLicense = fs.readFileSync( new URL('../../LICENSE', import.meta.url), diff --git a/packages/vite/src/client/bundledDevHmrClient.ts b/packages/vite/src/client/bundledDevHmrClient.ts index e75e603b7a5d6f..a8cc428234a818 100644 --- a/packages/vite/src/client/bundledDevHmrClient.ts +++ b/packages/vite/src/client/bundledDevHmrClient.ts @@ -16,13 +16,18 @@ export interface RolldownRuntimeLike { loadExports(id: string): unknown } +interface PropagationBoundary { + boundary: string + acceptedVia: string + isWithinCircularImport: boolean +} + type HmrUpdate = | { type: 'noop' } | { type: 'full-reload'; reason: string } | { type: 'boundaries' - /** `[boundary, acceptedVia]` pairs */ - boundaries: [string, string][] + boundaries: PropagationBoundary[] updateSet: string[] } @@ -69,7 +74,7 @@ export class BundledDevHMRClient extends HMRClient { changedIds: string[], opts?: { firstInvalidatedBy?: string }, ): HmrUpdate { - const boundaries: [string, string][] = [] + const boundaries: PropagationBoundary[] = [] const updateSet = new Set() const traversedModules = new Set() for (const changed of changedIds) { @@ -95,7 +100,7 @@ export class BundledDevHMRClient extends HMRClient { id: string, stack: string[], updateSet: Set, - boundaries: [string, string][], + boundaries: PropagationBoundary[], firstInvalidatedBy: string | undefined, traversedModules: Set, ): HmrUpdate | undefined { @@ -109,7 +114,11 @@ export class BundledDevHMRClient extends HMRClient { } } if (this.isSelfAccepted(id)) { - boundaries.push([id, id]) + boundaries.push({ + boundary: id, + acceptedVia: id, + isWithinCircularImport: this.isNodeWithinCircularImports(id, stack), + }) return } const parents = this.runtime @@ -122,28 +131,107 @@ export class BundledDevHMRClient extends HMRClient { } } for (const parent of parents) { + const subChain = [...stack, parent] if (this.acceptsDep(parent, id)) { - boundaries.push([parent, id]) + boundaries.push({ + boundary: parent, + acceptedVia: id, + isWithinCircularImport: this.isNodeWithinCircularImports( + parent, + subChain, + ), + }) continue } - if (stack.includes(parent)) { - return { - type: 'full-reload', - reason: `circular import chain between \`${id}\` and \`${parent}\``, - } + if (!stack.includes(parent)) { + const fullReload = this.bubble( + parent, + subChain, + updateSet, + boundaries, + firstInvalidatedBy, + traversedModules, + ) + if (fullReload) return fullReload } - const fullReload = this.bubble( - parent, - [...stack, parent], - updateSet, - boundaries, - firstInvalidatedBy, - traversedModules, - ) - if (fullReload) return fullReload } } + /** + * Check importers recursively if it's an import loop. An accepted module within + * an import loop cannot recover its execution order and should be reloaded. + * + * @param node The node that accepts HMR and is a boundary + * @param nodeChain The chain of nodes/imports that lead to the node. + * (The last node in the chain imports the `node` parameter) + * @param currentChain The current chain tracked from the `node` parameter + * @param traversedModules The set of modules that have traversed + */ + private isNodeWithinCircularImports( + node: string, + nodeChain: string[], + currentChain: string[] = [node], + traversedModules = new Set(), + ): boolean { + // To help visualize how each parameter works, imagine this import graph: + // + // A -> B -> C -> ACCEPTED -> D -> E -> NODE + // ^--------------------------| + // + // ACCEPTED: the node that accepts HMR. the `node` parameter. + // NODE : the initial node that triggered this HMR. + // + // This function will return true in the above graph, which: + // `node` : ACCEPTED + // `nodeChain` : [NODE, E, D, ACCEPTED] + // `currentChain` : [ACCEPTED, C, B] + // + // It works by checking if any `node` importers are within `nodeChain`, which + // means there's an import loop with a HMR-accepted module in it. + + if (traversedModules.has(node)) { + return false + } + traversedModules.add(node) + + for (const importer of this.runtime.getImporters(node)) { + // Node may import itself which is safe + if (importer === node) continue + + // Check circular imports + const importerIndex = nodeChain.indexOf(importer) + if (importerIndex > -1) { + // Log extra debug information so users can fix and remove the circular imports + // Following explanation above: + // `importer` : E + // `currentChain` reversed : [B, C, ACCEPTED] + // `nodeChain` sliced & reversed : [D, E] + // Combined : [E, B, C, ACCEPTED, D, E] + const importChain = [ + importer, + ...[...currentChain].reverse(), + ...nodeChain.slice(importerIndex, -1).reverse(), + ] + this.logger.debug( + `circular imports detected: ${importChain.join(' -> ')}`, + ) + return true + } + + // Continue recursively + if (!currentChain.includes(importer)) { + const result = this.isNodeWithinCircularImports( + importer, + nodeChain, + currentChain.concat(importer), + traversedModules, + ) + if (result) return result + } + } + return false + } + handlePush(payload: BundledDevUpdatePayload): void { this.applyQueue = this.applyQueue .then(() => this.applyPush(payload)) @@ -247,22 +335,41 @@ export class BundledDevHMRClient extends HMRClient { } // collect callbacks before the caches are removed - const applies = update.boundaries.map(([boundary, acceptedVia]) => ({ - boundary, - acceptedVia, - callbacks: - this.hotModulesMap - .get(boundary) - ?.callbacks.filter((c) => c.deps.includes(acceptedVia)) ?? [], - })) + const applies = update.boundaries.map( + ({ boundary, acceptedVia, isWithinCircularImport }) => ({ + boundary, + acceptedVia, + isWithinCircularImport, + callbacks: + this.hotModulesMap + .get(boundary) + ?.callbacks.filter((c) => c.deps.includes(acceptedVia)) ?? [], + }), + ) for (const id of update.updateSet) { this.runtime.removeModuleCache(id) } - for (const { boundary, acceptedVia, callbacks } of applies) { - this.runtime.initModule(acceptedVia) - const fresh = this.runtime.loadExports(acceptedVia) + for (const { + boundary, + acceptedVia, + isWithinCircularImport, + callbacks, + } of applies) { + let fresh: unknown + try { + this.runtime.initModule(acceptedVia) + fresh = this.runtime.loadExports(acceptedVia) + } catch (err) { + if (isWithinCircularImport) { + this.requestFullReload( + `${acceptedVia} failed to apply HMR as it's within a circular import. Reloading page to reset the execution order.`, + ) + return + } + throw err + } try { this.currentFirstInvalidatedBy = firstInvalidatedBy for (const { deps, fn } of callbacks) { @@ -284,16 +391,19 @@ export class BundledDevHMRClient extends HMRClient { } private toUpdatePayload( - boundaries: [string, string][], + boundaries: PropagationBoundary[], firstInvalidatedBy: string | undefined, ): UpdatePayload { - const updates: Update[] = boundaries.map(([boundary, acceptedVia]) => ({ - type: 'js-update', - path: boundary, - acceptedPath: acceptedVia, - timestamp: Date.now(), - firstInvalidatedBy, - })) + const updates: Update[] = boundaries.map( + ({ boundary, acceptedVia, isWithinCircularImport }) => ({ + type: 'js-update', + path: boundary, + acceptedPath: acceptedVia, + timestamp: Date.now(), + isWithinCircularImport, + firstInvalidatedBy, + }), + ) return { type: 'update', updates } } diff --git a/packages/vite/src/node/optimizer/index.ts b/packages/vite/src/node/optimizer/index.ts index 4c5db0eb67bb55..fb9357c12b6048 100644 --- a/packages/vite/src/node/optimizer/index.ts +++ b/packages/vite/src/node/optimizer/index.ts @@ -1469,7 +1469,7 @@ export async function cleanupDepsCacheStaleDirs( // The ISC License // Copyright (c) 2011-2022 Isaac Z. Schlueter, Ben Noordhuis, and Contributors -// https://github.com/isaacs/node-graceful-fs/blob/main/LICENSE +// https://github.com/isaacs/node-graceful-fs/blob/234379906b7d2f4c9cfeb412d2516f42b0fb4953/LICENSE // On Windows, A/V software can lock the directory, causing this // to fail with an EACCES or EPERM if the directory contains newly diff --git a/packages/vite/src/node/server/middlewares/triggerLazyBundling.ts b/packages/vite/src/node/server/middlewares/triggerLazyBundling.ts index dbb91ac2121f5d..1c6956fd879c38 100644 --- a/packages/vite/src/node/server/middlewares/triggerLazyBundling.ts +++ b/packages/vite/src/node/server/middlewares/triggerLazyBundling.ts @@ -26,7 +26,17 @@ export function triggerLazyBundlingMiddleware( const moduleId = params.get('id') const clientId = params.get('clientId') - const result = await bundledDev.triggerLazyBundling(moduleId, clientId) + let result: { code: string; filename: string } | undefined + try { + result = await bundledDev.triggerLazyBundling(moduleId, clientId) + } catch (e) { + server.config.logger.error( + `Failed to trigger lazy bundling for ${moduleId} (clientId: ${clientId}):` + + e, + { error: e }, + ) + return next(new Error(`Failed to trigger lazy bundling`)) + } if (result == null) { return next() } diff --git a/packages/vite/src/types/alias.d.ts b/packages/vite/src/types/alias.d.ts index 25819aeab0a750..9208778dffef61 100644 --- a/packages/vite/src/types/alias.d.ts +++ b/packages/vite/src/types/alias.d.ts @@ -1,5 +1,5 @@ /** -Types from https://github.com/rollup/plugins/blob/master/packages/alias/types/index.d.ts +Types from https://github.com/rollup/plugins/blob/4e85ed78cd2e941107fdf0e8e118e7bee550109d/packages/alias/types/index.d.ts Inlined because the plugin is bundled. https://github.com/rollup/plugins/blob/master/LICENSE diff --git a/playground/hmr-full-bundle-mode/__tests__/hmr-full-bundle-mode.spec.ts b/playground/hmr-full-bundle-mode/__tests__/hmr-full-bundle-mode.spec.ts index f187623aa10580..27c09900dbd7fe 100644 --- a/playground/hmr-full-bundle-mode/__tests__/hmr-full-bundle-mode.spec.ts +++ b/playground/hmr-full-bundle-mode/__tests__/hmr-full-bundle-mode.spec.ts @@ -448,6 +448,13 @@ if (isBuild) { .toBe('worker-plain-updated') }) + test('lazy bundling errors return 500', async () => { + const response = await page.request.get( + new URL('/@vite/lazy?id=%2Ffoo%2Fbar&clientId=x', page.url()).href, + ) + expect(response.status()).toBe(500) + }) + // Blocked by https://github.com/rolldown/rolldown/issues/10340 test.skip('chained invalidate in an import cycle settles', async () => { const original = readFile('cycle-a.js') diff --git a/playground/hmr/__tests__/hmr.spec.ts b/playground/hmr/__tests__/hmr.spec.ts index 7c030ea2b2ab24..7e17b2363aedf1 100644 --- a/playground/hmr/__tests__/hmr.spec.ts +++ b/playground/hmr/__tests__/hmr.spec.ts @@ -9,6 +9,7 @@ import { getBg, getColor, isBuild, + isBundledDev, page, readFile, removeFile, @@ -18,14 +19,20 @@ import { } from '~utils' test('should render', async () => { - expect(await page.textContent('.app')).toBe('1') - expect(await page.textContent('.dep')).toBe('1') - expect(await page.textContent('.nested')).toBe('1') + await expect.poll(() => page.textContent('.app')).toBe('1') + await expect.poll(() => page.textContent('.dep')).toBe('1') + await expect.poll(() => page.textContent('.nested')).toBe('1') }) if (!isBuild) { test('should connect', async () => { - expect(browserLogs.length).toBe(5) + if (isBundledDev) { + // bundled-dev logs extra `connecting...` and `connected` message (5 + 2) + // if the first bundle is not ready and a fallback page is shown when the client connects. + expect(browserLogs.length).toBeOneOf([5, 7]) + } else { + expect(browserLogs.length).toBe(5) + } expect(browserLogs.some((msg) => msg.includes('connected'))).toBe(true) browserLogs.length = 0 }) @@ -38,19 +45,34 @@ if (!isBuild) { return res.json() } test('hot events', async () => { - expect(await fetchHotEvents()).toStrictEqual({ - connectCount: 1, - disconnectCount: 0, - }) + if (isBundledDev) { + expect(await fetchHotEvents()).toBeOneOf([ + { connectCount: 1, disconnectCount: 0 }, + { connectCount: 2, disconnectCount: 1 }, // landed on fallback page first, then reconnected to main page + ]) + } else { + expect(await fetchHotEvents()).toStrictEqual({ + connectCount: 1, + disconnectCount: 0, + }) + } await untilBrowserLogAfter(() => page.reload(), [/connected/]) - expect(await fetchHotEvents()).toStrictEqual({ - connectCount: 2, - disconnectCount: 1, - }) + + if (isBundledDev) { + expect(await fetchHotEvents()).toBeOneOf([ + { connectCount: 2, disconnectCount: 1 }, + { connectCount: 3, disconnectCount: 2 }, + ]) + } else { + expect(await fetchHotEvents()).toStrictEqual({ + connectCount: 2, + disconnectCount: 1, + }) + } }) test('self accept', async () => { - const el = await page.$('.app') + const el = page.locator('.app') await untilBrowserLogAfter( () => editFile('hmr.ts', (code) => @@ -61,7 +83,11 @@ if (!isBuild) { 'foo was: 1', '(self-accepting 1) foo is now: 2', '(self-accepting 2) foo is now: 2', - '[vite] hot updated: /hmr.ts', + // bundled dev: the client logs rolldown module ids (cwd-relative, like + // `playground-temp/hmr/hmr.ts`). The same `cwd` issue as in hot update log in #23028. + isBundledDev + ? '[vite] hot updated: playground-temp/hmr/hmr.ts' + : '[vite] hot updated: /hmr.ts', '>>> vite:afterUpdate -- update', ], true, @@ -78,7 +104,9 @@ if (!isBuild) { 'foo was: 2', '(self-accepting 1) foo is now: 3', '(self-accepting 2) foo is now: 3', - '[vite] hot updated: /hmr.ts', + isBundledDev + ? '[vite] hot updated: playground-temp/hmr/hmr.ts' + : '[vite] hot updated: /hmr.ts', '>>> vite:afterUpdate -- update', ], true, @@ -96,7 +124,9 @@ if (!isBuild) { '>>> vite:beforeUpdate -- update', '(hot data) value from execution: 1', '(hot data) value from dispose: 1', - '[vite] hot updated: /hotData.js', + isBundledDev + ? '[vite] hot updated: playground-temp/hmr/hotData.js' + : '[vite] hot updated: /hotData.js', '>>> vite:afterUpdate -- update', ], true, @@ -111,7 +141,9 @@ if (!isBuild) { '>>> vite:beforeUpdate -- update', '(hot data) value from execution: 2', '(hot data) value from dispose: 2', - '[vite] hot updated: /hotData.js', + isBundledDev + ? '[vite] hot updated: playground-temp/hmr/hotData.js' + : '[vite] hot updated: /hotData.js', '>>> vite:afterUpdate -- update', ], true, @@ -119,7 +151,7 @@ if (!isBuild) { }) test('accept dep', async () => { - const el = await page.$('.dep') + const el = page.locator('.dep') await untilBrowserLogAfter( () => editFile('hmrDep.js', (code) => @@ -133,7 +165,9 @@ if (!isBuild) { '(single dep) nested foo is now: 1', '(multi deps) foo is now: 2', '(multi deps) nested foo is now: 1', - '[vite] hot updated: /hmrDep.js via /hmr.ts', + isBundledDev + ? '[vite] hot updated: playground-temp/hmr/hmrDep.js via playground-temp/hmr/hmr.ts' + : '[vite] hot updated: /hmrDep.js via /hmr.ts', '>>> vite:afterUpdate -- update', ], true, @@ -153,7 +187,9 @@ if (!isBuild) { '(single dep) nested foo is now: 1', '(multi deps) foo is now: 3', '(multi deps) nested foo is now: 1', - '[vite] hot updated: /hmrDep.js via /hmr.ts', + isBundledDev + ? '[vite] hot updated: playground-temp/hmr/hmrDep.js via playground-temp/hmr/hmr.ts' + : '[vite] hot updated: /hmrDep.js via /hmr.ts', '>>> vite:afterUpdate -- update', ], true, @@ -162,7 +198,7 @@ if (!isBuild) { }) test('nested dep propagation', async () => { - const el = await page.$('.nested') + const el = page.locator('.nested') await untilBrowserLogAfter( () => editFile('hmrNestedDep.js', (code) => @@ -176,7 +212,9 @@ if (!isBuild) { '(single dep) nested foo is now: 2', '(multi deps) foo is now: 3', '(multi deps) nested foo is now: 2', - '[vite] hot updated: /hmrDep.js via /hmr.ts', + isBundledDev + ? '[vite] hot updated: playground-temp/hmr/hmrDep.js via playground-temp/hmr/hmr.ts' + : '[vite] hot updated: /hmrDep.js via /hmr.ts', '>>> vite:afterUpdate -- update', ], true, @@ -196,7 +234,9 @@ if (!isBuild) { '(single dep) nested foo is now: 3', '(multi deps) foo is now: 3', '(multi deps) nested foo is now: 3', - '[vite] hot updated: /hmrDep.js via /hmr.ts', + isBundledDev + ? '[vite] hot updated: playground-temp/hmr/hmrDep.js via playground-temp/hmr/hmr.ts' + : '[vite] hot updated: /hmrDep.js via /hmr.ts', '>>> vite:afterUpdate -- update', ], true, @@ -204,8 +244,11 @@ if (!isBuild) { await expect.poll(() => el.textContent()).toMatch('3') }) - test('invalidate', async () => { - const el = await page.$('.invalidation-parent') + // bundled dev: propagating `import.meta.hot.invalidate()` to the importer + // fails with `no factory for module \`...invalidation/parent.js\``, so the + // client falls back to a full page reload instead of hot-updating the parent + test.skipIf(isBundledDev)('invalidate', async () => { + const el = page.locator('.invalidation-parent') await untilBrowserLogAfter( () => editFile('invalidation/child.js', (code) => @@ -227,13 +270,14 @@ if (!isBuild) { await expect.poll(() => el.textContent()).toMatch('child updated') }) - test('invalidate works with multiple tabs', async () => { + // bundled dev: same missing-importer-factory fallback as `invalidate` + test.skipIf(isBundledDev)('invalidate works with multiple tabs', async () => { let page2: Page try { page2 = await browser.newPage() await page2.goto(viteTestUrl) - const el = await page.$('.invalidation-parent') + const el = page.locator('.invalidation-parent') await untilBrowserLogAfter( () => editFile('invalidation/child.js', (code) => @@ -263,15 +307,15 @@ if (!isBuild) { editFile('invalidation/root.js', (code) => code.replace('Init', 'Updated')) await page.waitForEvent('load') await expect - .poll(async () => (await page.$('.invalidation-root')).textContent()) + .poll(() => page.textContent('.invalidation-root')) .toMatch('Updated') }) test('soft invalidate', async () => { - const el = await page.$('.soft-invalidation') - expect(await el.textContent()).toBe( - 'soft-invalidation/index.js is transformed 1 times. child is bar', - ) + const el = page.locator('.soft-invalidation') + await expect + .poll(() => el.textContent()) + .toBe('soft-invalidation/index.js is transformed 1 times. child is bar') editFile('soft-invalidation/child.js', (code) => code.replace('bar', 'updated'), ) @@ -295,7 +339,7 @@ if (!isBuild) { }) test('invalidate in circular dep should not trigger infinite HMR', async () => { - const el = await page.$('.invalidation-circular-deps') + const el = page.locator('.invalidation-circular-deps') await expect.poll(() => el.textContent()).toMatch('child') editFile( 'invalidation-circular-deps/circular-invalidate/child.js', @@ -308,7 +352,7 @@ if (!isBuild) { }) test('invalidate in circular dep should be hot updated if possible', async () => { - const el = await page.$('.invalidation-circular-deps-handled') + const el = page.locator('.invalidation-circular-deps-handled') await expect.poll(() => el.textContent()).toMatch('child') editFile( 'invalidation-circular-deps/invalidate-handled-in-circle/child.js', @@ -317,46 +361,50 @@ if (!isBuild) { await expect.poll(() => el.textContent()).toMatch('child updated') }) - test('plugin hmr handler + custom event', async () => { - const el = await page.$('.custom') + // bundled dev: file changes do not run the `hotUpdate` plugin hook, so the + // plugin's custom events never reach the page + test.skipIf(isBundledDev)('plugin hmr handler + custom event', async () => { + const el = page.locator('.custom') editFile('customFile.js', (code) => code.replace('custom', 'edited2')) await expect.poll(() => el.textContent()).toMatch('edited2') }) - test('plugin hmr remove custom events', async () => { - const el = await page.$('.toRemove') + // bundled dev: same missing `hotUpdate` plugin hook as the previous case + test.skipIf(isBundledDev)('plugin hmr remove custom events', async () => { + const el = page.locator('.toRemove') await expect.poll(() => el.textContent()).toMatch('edited2') editFile('customFile.js', (code) => code.replace('edited2', 'custom33')) await expect.poll(() => el.textContent()).toMatch('edited2') }) test('plugin client-server communication', async () => { - const el = await page.$('.custom-communication') + const el = page.locator('.custom-communication') await expect.poll(() => el.textContent()).toMatch('3') }) - test('full-reload encodeURI path', async () => { + // bundled dev: editing an html file does not trigger a page reload + test.skipIf(isBundledDev)('full-reload encodeURI path', async () => { await page.goto( viteTestUrl + '/unicode-path/中文-にほんご-한글-🌕🌖🌗/index.html', ) - const el = await page.$('#app') - expect(await el.textContent()).toBe('title') + const el = page.locator('#app') + await expect.poll(() => el.textContent()).toBe('title') editFile('unicode-path/中文-にほんご-한글-🌕🌖🌗/index.html', (code) => code.replace('title', 'title2'), ) await page.waitForEvent('load') - await expect - .poll(async () => (await page.$('#app')).textContent()) - .toBe('title2') + await expect.poll(() => el.textContent()).toBe('title2') }) - test('CSS update preserves query params', async () => { + // bundled dev: css is a bundle asset, so an edit does not swap the + // stylesheet link in place and the query params are lost + test.skipIf(isBundledDev)('CSS update preserves query params', async () => { await page.goto(viteTestUrl) editFile('global.css', (code) => code.replace('white', 'tomato')) - const elprev = await page.$('.css-prev') - const elpost = await page.$('.css-post') + const elprev = page.locator('.css-prev') + const elpost = page.locator('.css-post') await expect.poll(() => elprev.textContent()).toMatch('param=required') await expect.poll(() => elpost.textContent()).toMatch('param=required') const textprev = await elprev.textContent() @@ -366,37 +414,41 @@ if (!isBuild) { expect(textpost).not.toMatch('direct') }) - test('it swaps out link tags', async () => { + // bundled dev: stylesheet links point at bundle assets and are not swapped + // on css edits + test.skipIf(isBundledDev)('it swaps out link tags', async () => { await page.goto(viteTestUrl) editFile('global.css', (code) => code.replace('tomato', 'white')) - let el = await page.$('.link-tag-added') - await expect.poll(() => el.textContent()).toMatch('yes') - - el = await page.$('.link-tag-removed') - await expect.poll(() => el.textContent()).toMatch('yes') + await expect + .poll(() => page.locator('.link-tag-added').textContent()) + .toMatch('yes') + await expect + .poll(() => page.locator('.link-tag-removed').textContent()) + .toMatch('yes') - await expect.poll(async () => (await page.$$('link')).length).toBe(1) + await expect.poll(() => page.locator('link').count()).toBe(1) }) - test('not loaded dynamic import', async () => { + // bundled dev: editing a dynamic import that was never loaded triggers a + // page reload; plain dev ignores the change (#7561) + test.skipIf(isBundledDev)('not loaded dynamic import', async () => { await page.goto(viteTestUrl + '/counter/index.html', { waitUntil: 'load' }) - let btn = await page.$('button') - expect(await btn.textContent()).toBe('Counter 0') + const btn = page.locator('button') + await expect.poll(() => btn.textContent()).toBe('Counter 0') await btn.click() - expect(await btn.textContent()).toBe('Counter 1') + await expect.poll(() => btn.textContent()).toBe('Counter 1') // Modifying `index.ts` triggers a page reload, as expected const indexTsLoadPromise = page.waitForEvent('load') editFile('counter/index.ts', (code) => code + '\n') await indexTsLoadPromise - btn = await page.$('button') - expect(await btn.textContent()).toBe('Counter 0') + await expect.poll(() => btn.textContent()).toBe('Counter 0') await btn.click() - expect(await btn.textContent()).toBe('Counter 1') + await expect.poll(() => btn.textContent()).toBe('Counter 1') // #7561 // `dep.ts` defines `import.module.hot.accept` and has not been loaded. @@ -410,14 +462,15 @@ if (!isBuild) { /page\.waitForEvent: Timeout \d+ms exceeded while waiting for event "load"/, ) - btn = await page.$('button') - expect(await btn.textContent()).toBe('Counter 1') + await expect.poll(() => btn.textContent()).toBe('Counter 1') }) // #2255 - test('importing reloaded', async () => { + // bundled dev: the test passes on its own, but its edits queue stray page + // reloads that swallow a later hot update (`import.meta.hot?.accept` times out) + test.skipIf(isBundledDev)('importing reloaded', async () => { await page.goto(viteTestUrl) - const outputEle = await page.$('.importing-reloaded') + const outputEle = page.locator('.importing-reloaded') const getOutput = () => { return outputEle.innerHTML() } @@ -442,7 +495,9 @@ if (!isBuild) { ) }) - describe('acceptExports', () => { + // bundled dev: partial accept (`import.meta.hot.acceptExports`) is not + // supported (rolldown#10061) + describe.skipIf(isBundledDev)('acceptExports', () => { const HOT_UPDATED = /hot updated/ const CONNECTED = /connected/ @@ -823,164 +878,169 @@ if (!isBuild) { }) }) - test('css in html hmr', async () => { + // bundled dev: the icon is inlined as a data URI (build-style inlining), + // and editing index.html does not trigger a page reload + test.skipIf(isBundledDev)('css in html hmr', async () => { await page.goto(viteTestUrl) - expect(await getBg('.import-image')).toMatch('icon') + await expect.poll(() => getBg('.import-image')).toMatch('icon') await page.goto(viteTestUrl + '/foo/', { waitUntil: 'load' }) - expect(await getBg('.import-image')).toMatch('icon') + await expect.poll(() => getBg('.import-image')).toMatch('icon') const loadPromise = page.waitForEvent('load') editFile('index.html', (code) => code.replace("url('./icon.png')", '')) await loadPromise - expect(await getBg('.import-image')).toMatch('') + await expect.poll(() => getBg('.import-image')).toMatch('') }) - test('HTML', async () => { + // bundled dev: editing an html file does not trigger a page reload + test.skipIf(isBundledDev)('HTML', async () => { await page.goto(viteTestUrl + '/counter/index.html') - let btn = await page.$('button') - expect(await btn.textContent()).toBe('Counter 0') + const btn = page.locator('button') + await expect.poll(() => btn.textContent()).toBe('Counter 0') const loadPromise = page.waitForEvent('load') editFile('counter/index.html', (code) => code.replace('Counter', 'Compteur'), ) await loadPromise - btn = await page.$('button') - expect(await btn.textContent()).toBe('Compteur 0') + await expect.poll(() => btn.textContent()).toBe('Compteur 0') }) - test('handle virtual module updates', async () => { + // bundled dev: the update itself applies via HMR, but the edit also queues + // stray page reloads that swallow a later hot update + // (`import.meta.hot?.accept` times out) + test.skipIf(isBundledDev)('handle virtual module updates', async () => { await page.goto(viteTestUrl) - const el = await page.$('.virtual') - expect(await el.textContent()).toBe('[success]0') + const el = page.locator('.virtual') + await expect.poll(() => el.textContent()).toBe('[success]0') editFile('importedVirtual.js', (code) => code.replace('[success]', '[wow]')) - await expect - .poll(async () => { - const el = await page.$('.virtual') - return await el.textContent() - }) - .toBe('[wow]0') - }) - - test('invalidate virtual module', async () => { - await page.goto(viteTestUrl) - const el = await page.$('.virtual') - expect(await el.textContent()).toBe('[wow]0') - const btn = await page.$('.virtual-update') - btn.click() - await expect - .poll(async () => { - const el = await page.$('.virtual') - return await el.textContent() - }) - .toBe('[wow]1') + await expect.poll(() => el.textContent()).toBe('[wow]0') }) - test('handle virtual module accept updates', async () => { + // bundled dev: `server.moduleGraph` is empty, so the plugin's + // `reloadModule(virtual module)` call finds nothing to reload + test.skipIf(isBundledDev)('invalidate virtual module', async () => { await page.goto(viteTestUrl) - const el = await page.$('.virtual-dep') - expect(await el.textContent()).toBe('0') - editFile('importedVirtual.js', (code) => code.replace('[wow]', '[wow2]')) - await expect - .poll(async () => { - const el = await page.$('.virtual-dep') - return await el.textContent() - }) - .toBe('[wow2]0') - }) - - test('invalidate virtual module and accept', async () => { - await page.goto(viteTestUrl) - const el = await page.$('.virtual-dep') - expect(await el.textContent()).toBe('0') - const btn = await page.$('.virtual-update-dep') - btn.click() - await expect - .poll(async () => { - const el = await page.$('.virtual-dep') - return await el.textContent() - }) - .toBe('[wow2]2') + const el = page.locator('.virtual') + await expect.poll(() => el.textContent()).toBe('[wow]0') + await page.locator('.virtual-update').click() + await expect.poll(() => el.textContent()).toBe('[wow]1') }) - test('keep hmr reload after missing import on server startup', async () => { - const file = 'missing-import/a.js' - const importCode = "import 'missing-modules'" - const unImportCode = `// ${importCode}` + // bundled dev: same stray-reload fallout as `handle virtual module updates` + test.skipIf(isBundledDev)( + 'handle virtual module accept updates', + async () => { + await page.goto(viteTestUrl) + const el = page.locator('.virtual-dep') + await expect.poll(() => el.textContent()).toBe('0') + editFile('importedVirtual.js', (code) => code.replace('[wow]', '[wow2]')) + await expect.poll(() => el.textContent()).toBe('[wow2]0') + }, + ) + + // bundled dev: same empty-module-graph gap as `invalidate virtual module` + test.skipIf(isBundledDev)( + 'invalidate virtual module and accept', + async () => { + await page.goto(viteTestUrl) + const el = page.locator('.virtual-dep') + await expect.poll(() => el.textContent()).toBe('0') + await page.locator('.virtual-update-dep').click() + await expect.poll(() => el.textContent()).toBe('[wow2]2') + }, + ) + + // bundled dev: recovery after a startup build error (missing import) does + // not rebuild and reload the page + test.skipIf(isBundledDev)( + 'keep hmr reload after missing import on server startup', + async () => { + const file = 'missing-import/a.js' + const importCode = "import 'missing-modules'" + const unImportCode = `// ${importCode}` - await untilBrowserLogAfter( - () => - page.goto(viteTestUrl + '/missing-import/index.html', { - waitUntil: 'load', - }), - /connected/, // wait for HMR connection - ) - - await untilBrowserLogAfter(async () => { - const loadPromise = page.waitForEvent('load') - editFile(file, (code) => code.replace(importCode, unImportCode)) - await loadPromise - }, ['missing test', /connected/]) - - await untilBrowserLogAfter(async () => { - const loadPromise = page.waitForEvent('load') - editFile(file, (code) => code.replace(unImportCode, importCode)) - await loadPromise - }, [/500/, /connected/]) - }) - - test('should hmr when file is deleted and restored', async () => { - await page.goto(viteTestUrl) - - const parentFile = 'file-delete-restore/parent.js' - const childFile = 'file-delete-restore/child.js' + await untilBrowserLogAfter( + () => + page.goto(viteTestUrl + '/missing-import/index.html', { + waitUntil: 'load', + }), + /connected/, // wait for HMR connection + ) - await expect - .poll(() => page.textContent('.file-delete-restore')) - .toMatch('parent:child') + await untilBrowserLogAfter(async () => { + const loadPromise = page.waitForEvent('load') + editFile(file, (code) => code.replace(importCode, unImportCode)) + await loadPromise + }, ['missing test', /connected/]) + + await untilBrowserLogAfter(async () => { + const loadPromise = page.waitForEvent('load') + editFile(file, (code) => code.replace(unImportCode, importCode)) + await loadPromise + }, [/500/, /connected/]) + }, + ) + + // bundled dev: deleting and re-adding files is not handled — no dispose + // events, no rebuild, no reload + test.skipIf(isBundledDev)( + 'should hmr when file is deleted and restored', + async () => { + await page.goto(viteTestUrl) + + const parentFile = 'file-delete-restore/parent.js' + const childFile = 'file-delete-restore/child.js' + + await expect + .poll(() => page.textContent('.file-delete-restore')) + .toMatch('parent:child') - editFile(childFile, (code) => - code.replace("value = 'child'", "value = 'child1'"), - ) - await expect - .poll(() => page.textContent('.file-delete-restore')) - .toMatch('parent:child1') - - // delete the file - editFile(parentFile, (code) => - code.replace( - "export { value as childValue } from './child'", - "export const childValue = 'not-child'", - ), - ) - const originalChildFileCode = readFile(childFile) - await Promise.all([ - untilBrowserLogAfter( - () => removeFile(childFile), - `${childFile} is disposed`, - ), - expect + editFile(childFile, (code) => + code.replace("value = 'child'", "value = 'child1'"), + ) + await expect .poll(() => page.textContent('.file-delete-restore')) - .toMatch('parent:not-child'), - ]) + .toMatch('parent:child1') - await untilBrowserLogAfter(async () => { - const loadPromise = page.waitForEvent('load') - addFile(childFile, originalChildFileCode) + // delete the file editFile(parentFile, (code) => code.replace( - "export const childValue = 'not-child'", "export { value as childValue } from './child'", + "export const childValue = 'not-child'", ), ) - await loadPromise - }, [/connected/]) - await expect - .poll(() => page.textContent('.file-delete-restore')) - .toMatch('parent:child') - }) + const originalChildFileCode = readFile(childFile) + await Promise.all([ + untilBrowserLogAfter( + () => removeFile(childFile), + `${childFile} is disposed`, + ), + expect + .poll(() => page.textContent('.file-delete-restore')) + .toMatch('parent:not-child'), + ]) + + await untilBrowserLogAfter(async () => { + const loadPromise = page.waitForEvent('load') + addFile(childFile, originalChildFileCode) + editFile(parentFile, (code) => + code.replace( + "export const childValue = 'not-child'", + "export { value as childValue } from './child'", + ), + ) + await loadPromise + }, [/connected/]) + await expect + .poll(() => page.textContent('.file-delete-restore')) + .toMatch('parent:child') + }, + ) - test('delete file should not break hmr', async () => { + // bundled dev: same file-delete gap as + // `should hmr when file is deleted and restored` + test.skipIf(isBundledDev)('delete file should not break hmr', async () => { await page.goto(viteTestUrl) await expect @@ -1025,72 +1085,82 @@ if (!isBuild) { .toMatch('count is 2') }) - test('deleted file should trigger dispose and prune callbacks', async () => { - await page.goto(viteTestUrl) + // bundled dev: dispose and prune events for deleted files are not sent + test.skipIf(isBundledDev)( + 'deleted file should trigger dispose and prune callbacks', + async () => { + await page.goto(viteTestUrl) - const parentFile = 'file-delete-restore/parent.js' - const childFile = 'file-delete-restore/child.js' - const originalChildFileCode = readFile(childFile) + const parentFile = 'file-delete-restore/parent.js' + const childFile = 'file-delete-restore/child.js' + const originalChildFileCode = readFile(childFile) - await untilBrowserLogAfter( - () => { - // delete the file + await untilBrowserLogAfter( + () => { + // delete the file + editFile(parentFile, (code) => + code.replace( + "export { value as childValue } from './child'", + "export const childValue = 'not-child'", + ), + ) + removeFile(childFile) + }, + [ + 'file-delete-restore/child.js is disposed', + 'file-delete-restore/child.js is pruned', + ], + false, + ) + await expect + .poll(() => page.textContent('.file-delete-restore')) + .toMatch('parent:not-child') + + // restore the file + await untilBrowserLogAfter(() => { + addFile(childFile, originalChildFileCode) editFile(parentFile, (code) => code.replace( - "export { value as childValue } from './child'", "export const childValue = 'not-child'", + "export { value as childValue } from './child'", ), ) - removeFile(childFile) - }, - [ - 'file-delete-restore/child.js is disposed', - 'file-delete-restore/child.js is pruned', - ], - false, - ) - await expect - .poll(() => page.textContent('.file-delete-restore')) - .toMatch('parent:not-child') - - // restore the file - await untilBrowserLogAfter(() => { - addFile(childFile, originalChildFileCode) - editFile(parentFile, (code) => - code.replace( - "export const childValue = 'not-child'", - "export { value as childValue } from './child'", - ), + }, 'file-delete-restore/child.js hot data after prune: undefined') + await expect + .poll(() => page.textContent('.file-delete-restore')) + .toMatch('parent:child') + }, + ) + + // bundled dev: prune events are not sent when an import is removed + test.skipIf(isBundledDev)( + 'deleting import from non-self-accepting module can trigger prune event', + async () => { + await page.goto(viteTestUrl) + await expect.poll(() => page.textContent('.prune')).toMatch('prune-init') + editFile('prune/dep1.js', (code) => + code.replace(`import './dep2.js'`, `// import './dep2.js'`), ) - }, 'file-delete-restore/child.js hot data after prune: undefined') - await expect - .poll(() => page.textContent('.file-delete-restore')) - .toMatch('parent:child') - }) - - test('deleting import from non-self-accepting module can trigger prune event', async () => { - await page.goto(viteTestUrl) - await expect.poll(() => page.textContent('.prune')).toMatch('prune-init') - editFile('prune/dep1.js', (code) => - code.replace(`import './dep2.js'`, `// import './dep2.js'`), - ) - // Prune is triggered when there are other dependencies. - await expect - .poll(() => page.textContent('.prune')) - .toMatch('prune-init|dep2-disposed|dep2-pruned') - editFile('prune/dep1.js', (code) => - code.replace(`import './dep3.js'`, `// import './dep3.js'`), - ) - // Prune is triggered when there are no more dependencies. - await expect - .poll(() => page.textContent('.prune')) - .toMatch('prune-init|dep2-disposed|dep2-pruned|dep3-disposed|dep3-pruned') - }) + // Prune is triggered when there are other dependencies. + await expect + .poll(() => page.textContent('.prune')) + .toMatch('prune-init|dep2-disposed|dep2-pruned') + editFile('prune/dep1.js', (code) => + code.replace(`import './dep3.js'`, `// import './dep3.js'`), + ) + // Prune is triggered when there are no more dependencies. + await expect + .poll(() => page.textContent('.prune')) + .toMatch( + 'prune-init|dep2-disposed|dep2-pruned|dep3-disposed|dep3-pruned', + ) + }, + ) test('import.meta.hot?.accept', async () => { await page.goto(viteTestUrl) - const el = await page.$('.optional-chaining') + const el = page.locator('.optional-chaining') await untilBrowserLogAfter( () => editFile( @@ -1104,27 +1174,37 @@ if (!isBuild) { test('hmr works for self-accepted module within circular imported files', async () => { await page.goto(viteTestUrl + '/self-accept-within-circular/index.html') - const el = await page.$('.self-accept-within-circular') - expect(await el.textContent()).toBe('c') + const el = page.locator('.self-accept-within-circular') + await expect.poll(() => el.textContent()).toBe('c') const lastServerLogIndex = serverLogs.length editFile('self-accept-within-circular/c.js', (code) => code.replace(`export const c = 'c'`, `export const c = 'cc'`), ) - await expect - .poll(() => page.textContent('.self-accept-within-circular')) - .toBe('cc') + await expect.poll(() => el.textContent()).toBe('cc') // Should still keep hmr update, but it'll error on the browser-side and will refresh itself. expect( serverLogs.slice(lastServerLogIndex).map(stripVTControlCharacters), - ).toContain('hmr update /self-accept-within-circular/c.js') + ).toContain( + isBundledDev + ? 'hmr update playground-temp/hmr/self-accept-within-circular/c.js' + : 'hmr update /self-accept-within-circular/c.js', + ) }) test('hmr should not reload if no accepted within circular imported files', async () => { await page.goto(viteTestUrl + '/circular/index.html') - const el = await page.$('.circular') - expect(await el.textContent()).toBe( - 'mod-a -> mod-b -> mod-c -> mod-a (expected error)', - ) + const el = page.locator('.circular') + await expect + .poll(() => el.textContent()) + .toBe( + isBundledDev + ? // bundled-dev: `unexpected no error` is shown instead of `expected error`. + // Vite uses `output.topLevelVar = true` by default in bundled dev, which causes the circular import to be `undefined` instead of throwing a TDZ error, + // (see https://rolldown.rs/guide/troubleshooting#avoid-relying-on-temporal-dead-zone-tdz-errors), + // whereas in unbundled dev, it follows TDZ and shows `expected error`. + 'mod-a -> mod-b -> mod-c -> undefined (unexpected no error)' + : 'mod-a -> mod-b -> mod-c -> mod-a (expected error)', + ) editFile('circular/mod-b.js', (code) => code.replace(`mod-b ->`, `mod-b (edited) ->`), ) @@ -1133,9 +1213,10 @@ if (!isBuild) { .toBe('mod-a -> mod-b (edited) -> mod-c -> mod-a (expected error)') }) - test('not inlined assets HMR', async () => { + // bundled dev: editing an asset file does not push an update to the page + test.skipIf(isBundledDev)('not inlined assets HMR', async () => { await page.goto(viteTestUrl) - const el = await page.$('#logo-no-inline') + const el = page.locator('#logo-no-inline') await untilBrowserLogAfter( () => editFile('logo-no-inline.svg', (code) => @@ -1148,9 +1229,10 @@ if (!isBuild) { .toMatch('40') }) - test('inlined assets HMR', async () => { + // bundled dev: same asset-edit gap as `not inlined assets HMR` + test.skipIf(isBundledDev)('inlined assets HMR', async () => { await page.goto(viteTestUrl) - const el = await page.$('#logo') + const el = page.locator('#logo') await untilBrowserLogAfter( () => editFile('logo.svg', (code) => @@ -1165,50 +1247,59 @@ if (!isBuild) { test('CSS HMR with this.addWatchFile', async () => { await page.goto(viteTestUrl + '/css-deps/index.html') - expect(await getColor('.css-deps')).toBe('red') + await expect.poll(() => getColor('.css-deps')).toBe('red') editFile('css-deps/dep.js', (code) => code.replace(`red`, `green`)) await expect.poll(() => getColor('.css-deps')).toBe('green') }) - test('hmr should happen after missing file is created', async () => { - const file = 'missing-file/a.js' - const code = 'console.log("a.js")' + // bundled dev: adding the missing file does not rebuild and reload the page + test.skipIf(isBundledDev)( + 'hmr should happen after missing file is created', + async () => { + const file = 'missing-file/a.js' + const code = 'console.log("a.js")' - await untilBrowserLogAfter( - () => - page.goto(viteTestUrl + '/missing-file/index.html', { - waitUntil: 'load', - }), - /connected/, // wait for HMR connection - ) - - await untilBrowserLogAfter(async () => { - const loadPromise = page.waitForEvent('load') - addFile(file, code) - await loadPromise - }, [/connected/, 'a.js']) - }) - - test('deduplicate server rendered link stylesheet', async () => { - await page.goto(viteTestUrl + '/css-link/index.html') - await expect.poll(() => getColor('.test-css-link')).toBe('orange') + await untilBrowserLogAfter( + () => + page.goto(viteTestUrl + '/missing-file/index.html', { + waitUntil: 'load', + }), + /connected/, // wait for HMR connection + ) - // remove color - editFile('css-link/styles.css', (code) => - code.replace('color: orange;', '/* removed */'), - ) - await expect.poll(() => getColor('.test-css-link')).toBe('black') + await untilBrowserLogAfter(async () => { + const loadPromise = page.waitForEvent('load') + addFile(file, code) + await loadPromise + }, [/connected/, 'a.js']) + }, + ) + + // bundled dev: /css-link/index.html is not a bundle input, so the page is + // never served and the styled element never appears + test.skipIf(isBundledDev)( + 'deduplicate server rendered link stylesheet', + async () => { + await page.goto(viteTestUrl + '/css-link/index.html') + await expect.poll(() => getColor('.test-css-link')).toBe('orange') + + // remove color + editFile('css-link/styles.css', (code) => + code.replace('color: orange;', '/* removed */'), + ) + await expect.poll(() => getColor('.test-css-link')).toBe('black') - // add color - editFile('css-link/styles.css', (code) => - code.replace('/* removed */', 'color: blue;'), - ) - await expect.poll(() => getColor('.test-css-link')).toBe('blue') + // add color + editFile('css-link/styles.css', (code) => + code.replace('/* removed */', 'color: blue;'), + ) + await expect.poll(() => getColor('.test-css-link')).toBe('blue') - // // remove css import from js - editFile('css-link/main.js', (code) => - code.replace(`import './styles.css'`, ``), - ) - await expect.poll(() => getColor('.test-css-link')).toBe('black') - }) + // // remove css import from js + editFile('css-link/main.js', (code) => + code.replace(`import './styles.css'`, ``), + ) + await expect.poll(() => getColor('.test-css-link')).toBe('black') + }, + ) } diff --git a/vitest.config.e2e.ts b/vitest.config.e2e.ts index 9b235669a142b0..48f126c16dc2c3 100644 --- a/vitest.config.e2e.ts +++ b/vitest.config.e2e.ts @@ -19,7 +19,6 @@ const bundledDevExclude = [ './playground/fs-serve/__tests__/base/fs-serve-base.spec.ts', './playground/fs-serve/__tests__/deny/fs-serve-deny.spec.ts', './playground/fs-serve/__tests__/fs-serve.spec.ts', - './playground/hmr/__tests__/hmr.spec.ts', './playground/legacy/__tests__/chunk-importmap/legacy-chunk-importmap.spec.ts', './playground/object-hooks/__tests__/object-hooks.spec.ts', './playground/optimize-deps/__tests__/optimize-deps.spec.ts',