From bd5b015bf60f861eb7fa8b4236020d5a478b9384 Mon Sep 17 00:00:00 2001 From: Paul Cameron Date: Sat, 19 Sep 2026 22:49:04 -0400 Subject: [PATCH 1/9] fix: hard-invalidate importers when dependency is deleted (#23527) --- .../node/server/__tests__/moduleGraph.spec.ts | 48 +++++++++++++++++++ packages/vite/src/node/server/hmr.ts | 9 ++-- .../vite/src/node/server/mixedModuleGraph.ts | 11 +++++ packages/vite/src/node/server/moduleGraph.ts | 25 ++++++++-- 4 files changed, 85 insertions(+), 8 deletions(-) diff --git a/packages/vite/src/node/server/__tests__/moduleGraph.spec.ts b/packages/vite/src/node/server/__tests__/moduleGraph.spec.ts index 721640177e620e..a94e8de376e81c 100644 --- a/packages/vite/src/node/server/__tests__/moduleGraph.spec.ts +++ b/packages/vite/src/node/server/__tests__/moduleGraph.spec.ts @@ -104,5 +104,53 @@ describe('moduleGraph', () => { ssrModule1importersValues[1], ) }) + + it('hard-invalidates importers when dependency is deleted (#23527)', async () => { + const moduleGraph = new EnvironmentModuleGraph('client', async (url) => ({ + id: url, + })) + const depMod = await moduleGraph.ensureEntryFromUrl('/dep.js', false) + const importerMod = await moduleGraph.ensureEntryFromUrl( + '/main.js', + false, + ) + + depMod.importers.add(importerMod) + importerMod.importedModules.add(depMod) + importerMod.staticImportedUrls = new Set([depMod.url]) + + importerMod.transformResult = { + code: 'import { value } from "/dep.js";', + map: null, + etag: '123', + } + + // Normal file change soft-invalidates the importer + moduleGraph.onFileChange(depMod.file!) + expect(importerMod.invalidationState).toEqual({ + code: 'import { value } from "/dep.js";', + map: null, + etag: '123', + }) + expect(depMod.isDeleted).toBe(false) + + // Reset importer to simulate fresh transform result + importerMod.invalidationState = undefined + importerMod.transformResult = { + code: 'import { value } from "/dep.js";', + map: null, + etag: '123', + } + + // File deletion must hard-invalidate the importer + moduleGraph.onFileDelete(depMod.file!) + expect(depMod.isDeleted).toBe(true) + expect(importerMod.invalidationState).toBe('HARD_INVALIDATED') + expect(importerMod.transformResult).toBe(null) + + // Re-ensuring entry resets isDeleted flag + await moduleGraph.ensureEntryFromUrl('/dep.js', false) + expect(depMod.isDeleted).toBe(false) + }) }) }) diff --git a/packages/vite/src/node/server/hmr.ts b/packages/vite/src/node/server/hmr.ts index 5d5eed16112cd1..1f9f62998e556b 100644 --- a/packages/vite/src/node/server/hmr.ts +++ b/packages/vite/src/node/server/hmr.ts @@ -485,6 +485,9 @@ export async function handleHMRUpdate( for (const environment of environments) { const mods = new Set(environment.moduleGraph.getModulesByFile(file)) if (type === 'create') { + for (const mod of mods) { + mod.isDeleted = false + } for (const mod of environment.moduleGraph._hasResolveFailedErrorModules) { mods.add(mod) } @@ -828,7 +831,7 @@ function propagateUpdate( return false } - if (node.isSelfAccepting) { + if (node.isSelfAccepting && !node.isDeleted) { // isSelfAccepting is only true for js and css const boundary = node as EnvironmentModuleNode & { type: 'js' | 'css' } boundaries.push({ @@ -844,7 +847,7 @@ function propagateUpdate( // are used outside of me". // Also, the imported module (this one) must be updated before the importers, // so that they do get the fresh imported module when/if they are reloaded. - if (node.acceptedHmrExports) { + if (node.acceptedHmrExports && !node.isDeleted) { // acceptedHmrExports is only true for js and css const boundary = node as EnvironmentModuleNode & { type: 'js' | 'css' } boundaries.push({ @@ -861,7 +864,7 @@ function propagateUpdate( for (const importer of node.importers) { const subChain = [...currentChain, importer] - if (importer.acceptedHmrDeps.has(node)) { + if (importer.acceptedHmrDeps.has(node) && !node.isDeleted) { // acceptedHmrDeps has value only for js and css const boundary = importer as EnvironmentModuleNode & { type: 'js' | 'css' diff --git a/packages/vite/src/node/server/mixedModuleGraph.ts b/packages/vite/src/node/server/mixedModuleGraph.ts index ae2818f972f69e..afcb485c585d3e 100644 --- a/packages/vite/src/node/server/mixedModuleGraph.ts +++ b/packages/vite/src/node/server/mixedModuleGraph.ts @@ -223,6 +223,17 @@ export class ModuleNode { get ssrInvalidationState(): TransformResult | 'HARD_INVALIDATED' | undefined { return this._ssrModule?.invalidationState } + get isDeleted(): boolean { + return Boolean(this._clientModule?.isDeleted || this._ssrModule?.isDeleted) + } + set isDeleted(value: boolean) { + if (this._clientModule) { + this._clientModule.isDeleted = value + } + if (this._ssrModule) { + this._ssrModule.isDeleted = value + } + } } function mapIterator( diff --git a/packages/vite/src/node/server/moduleGraph.ts b/packages/vite/src/node/server/moduleGraph.ts index 93cb65f5cf11b4..333be5f69b140f 100644 --- a/packages/vite/src/node/server/moduleGraph.ts +++ b/packages/vite/src/node/server/moduleGraph.ts @@ -67,6 +67,11 @@ export class EnvironmentModuleNode { * @internal */ staticImportedUrls?: Set + /** + * Whether the module's file has been deleted from disk. + * @internal + */ + isDeleted?: boolean /** * @param setIsSelfAccepting - set `false` to set `isSelfAccepting` later. e.g. #7870 @@ -147,6 +152,7 @@ export class EnvironmentModuleGraph { if (mods) { const seen = new Set() mods.forEach((mod) => { + mod.isDeleted = false this.invalidateModule(mod, seen) }) } @@ -155,10 +161,13 @@ export class EnvironmentModuleGraph { onFileDelete(file: string): void { const mods = this.getModulesByFile(file) if (mods) { + const seen = new Set() mods.forEach((mod) => { + mod.isDeleted = true mod.importedModules.forEach((importedMod) => { importedMod.importers.delete(mod) }) + this.invalidateModule(mod, seen) }) } } @@ -211,13 +220,16 @@ export class EnvironmentModuleGraph { mod.ssrError = null mod.importers.forEach((importer) => { - if (!importer.acceptedHmrDeps.has(mod)) { + if (!importer.acceptedHmrDeps.has(mod) || mod.isDeleted) { // If the importer statically imports the current module, we can soft-invalidate the importer // to only update the import timestamps. If it's not statically imported, e.g. watched/glob file, // we can only soft invalidate if the current module was also soft-invalidated. A soft-invalidation // doesn't need to trigger a re-load and re-transform of the importer. // But we exclude direct CSS files as those cannot be soft invalidated. + // We also cannot soft-invalidate importers if the current module was deleted, as importers + // must be re-transformed to re-resolve the deleted import specifier. const shouldSoftInvalidateImporter = + !mod.isDeleted && (importer.staticImportedUrls?.has(mod.url) || softInvalidate) && importer.type === 'js' this.invalidateModule( @@ -349,7 +361,7 @@ export class EnvironmentModuleGraph { // Quick path, if we already have a module for this rawUrl (even without extension) rawUrl = removeImportQuery(removeTimestampQuery(rawUrl)) let mod = this._getUnresolvedUrlToModule(rawUrl) - if (mod) { + if (mod && !('then' in mod ? false : mod.isDeleted)) { return mod } const modPromise = (async () => { @@ -375,8 +387,11 @@ export class EnvironmentModuleGraph { } // multiple urls can map to the same module and id, make sure we register // the url to the existing module in that case - else if (!this.urlToModuleMap.has(url)) { - this.urlToModuleMap.set(url, mod) + else { + mod.isDeleted = false + if (!this.urlToModuleMap.has(url)) { + this.urlToModuleMap.set(url, mod) + } } this._setUnresolvedUrlToModule(rawUrl, mod) return mod @@ -421,7 +436,7 @@ export class EnvironmentModuleGraph { async resolveUrl(url: string): Promise { url = removeImportQuery(removeTimestampQuery(url)) const mod = await this._getUnresolvedUrlToModule(url) - if (mod?.id) { + if (mod?.id && !mod.isDeleted) { return [mod.url, mod.id, mod.meta] } return this._resolveUrl(url) From 5e07fb03f48e8b1c3107be305cf6967fcdb34bf1 Mon Sep 17 00:00:00 2001 From: Paul Cameron Date: Sat, 19 Sep 2026 23:06:19 -0400 Subject: [PATCH 2/9] chore: trigger ci From 6988ccc6301dfa867a5566cebec5aff9e669c9f6 Mon Sep 17 00:00:00 2001 From: Paul Cameron Date: Wed, 30 Sep 2026 14:54:52 -0400 Subject: [PATCH 3/9] test: debounce notifyRebuildComplete to fix Windows flakiness --- playground/vitestSetup.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/playground/vitestSetup.ts b/playground/vitestSetup.ts index 7e4aba1e63b426..34dfb73f79bbe0 100644 --- a/playground/vitestSetup.ts +++ b/playground/vitestSetup.ts @@ -403,16 +403,32 @@ export async function notifyRebuildComplete( watcher: RolldownWatcher, ): Promise { let resolveFn: undefined | (() => void) + let timeout: ReturnType | undefined const callback = (event: RolldownWatcherEvent): void => { - if (event.code === 'END') { - resolveFn?.() + if (event.code === 'START' || event.code === 'BUNDLE_START') { + if (timeout) { + clearTimeout(timeout) + timeout = undefined + } + } else if (event.code === 'END') { + if (timeout) { + clearTimeout(timeout) + } + timeout = setTimeout( + () => { + resolveFn?.() + }, + process.platform === 'win32' ? 150 : 50, + ) } } watcher.on('event', callback) await new Promise((resolve) => { resolveFn = resolve }) - + if (timeout) { + clearTimeout(timeout) + } watcher.off('event', callback) } From 751fa89bbe5bdddbc6715b986710e69f91d13d48 Mon Sep 17 00:00:00 2001 From: Paul Cameron Date: Wed, 30 Sep 2026 15:03:38 -0400 Subject: [PATCH 4/9] revert: test: debounce notifyRebuildComplete to fix Windows flakiness --- playground/vitestSetup.ts | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/playground/vitestSetup.ts b/playground/vitestSetup.ts index 34dfb73f79bbe0..7e4aba1e63b426 100644 --- a/playground/vitestSetup.ts +++ b/playground/vitestSetup.ts @@ -403,32 +403,16 @@ export async function notifyRebuildComplete( watcher: RolldownWatcher, ): Promise { let resolveFn: undefined | (() => void) - let timeout: ReturnType | undefined const callback = (event: RolldownWatcherEvent): void => { - if (event.code === 'START' || event.code === 'BUNDLE_START') { - if (timeout) { - clearTimeout(timeout) - timeout = undefined - } - } else if (event.code === 'END') { - if (timeout) { - clearTimeout(timeout) - } - timeout = setTimeout( - () => { - resolveFn?.() - }, - process.platform === 'win32' ? 150 : 50, - ) + if (event.code === 'END') { + resolveFn?.() } } watcher.on('event', callback) await new Promise((resolve) => { resolveFn = resolve }) - if (timeout) { - clearTimeout(timeout) - } + watcher.off('event', callback) } From d1270a0c57974154bd75efa5f240b5688b3dd0dc Mon Sep 17 00:00:00 2001 From: Paul Cameron Date: Wed, 30 Sep 2026 15:09:56 -0400 Subject: [PATCH 5/9] test: debounce notifyRebuildComplete to fix flakiness without OS check --- playground/vitestSetup.ts | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/playground/vitestSetup.ts b/playground/vitestSetup.ts index 7e4aba1e63b426..78b7f76ca1e764 100644 --- a/playground/vitestSetup.ts +++ b/playground/vitestSetup.ts @@ -402,18 +402,26 @@ export async function startDefaultServe(): Promise { export async function notifyRebuildComplete( watcher: RolldownWatcher, ): Promise { - let resolveFn: undefined | (() => void) - const callback = (event: RolldownWatcherEvent): void => { - if (event.code === 'END') { - resolveFn?.() + return new Promise((resolve) => { + let timeout: ReturnType | undefined + const callback = (event: RolldownWatcherEvent): void => { + if (event.code === 'START' || event.code === 'BUNDLE_START') { + if (timeout) { + clearTimeout(timeout) + timeout = undefined + } + } else if (event.code === 'END' || event.code === 'ERROR') { + if (timeout) { + clearTimeout(timeout) + } + timeout = setTimeout(() => { + watcher.off('event', callback) + resolve() + }, 100) + } } - } - watcher.on('event', callback) - await new Promise((resolve) => { - resolveFn = resolve + watcher.on('event', callback) }) - - watcher.off('event', callback) } export function createInMemoryLogger(logs: string[]): Logger { From d0f2d2e740ecaf96c9f2e5a89d3156f505d6ed35 Mon Sep 17 00:00:00 2001 From: Paul Cameron Date: Wed, 30 Sep 2026 15:14:44 -0400 Subject: [PATCH 6/9] revert: test: debounce notifyRebuildComplete to fix flakiness without OS check --- playground/vitestSetup.ts | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/playground/vitestSetup.ts b/playground/vitestSetup.ts index 78b7f76ca1e764..7e4aba1e63b426 100644 --- a/playground/vitestSetup.ts +++ b/playground/vitestSetup.ts @@ -402,26 +402,18 @@ export async function startDefaultServe(): Promise { export async function notifyRebuildComplete( watcher: RolldownWatcher, ): Promise { - return new Promise((resolve) => { - let timeout: ReturnType | undefined - const callback = (event: RolldownWatcherEvent): void => { - if (event.code === 'START' || event.code === 'BUNDLE_START') { - if (timeout) { - clearTimeout(timeout) - timeout = undefined - } - } else if (event.code === 'END' || event.code === 'ERROR') { - if (timeout) { - clearTimeout(timeout) - } - timeout = setTimeout(() => { - watcher.off('event', callback) - resolve() - }, 100) - } + let resolveFn: undefined | (() => void) + const callback = (event: RolldownWatcherEvent): void => { + if (event.code === 'END') { + resolveFn?.() } - watcher.on('event', callback) + } + watcher.on('event', callback) + await new Promise((resolve) => { + resolveFn = resolve }) + + watcher.off('event', callback) } export function createInMemoryLogger(logs: string[]): Logger { From d55fc9e1e0ea0435e5157096fdbea71be51d9b48 Mon Sep 17 00:00:00 2001 From: Paul Cameron Date: Wed, 30 Sep 2026 15:59:35 -0400 Subject: [PATCH 7/9] test: workaround windows watch flakiness in assets.spec --- playground/assets/__tests__/assets.spec.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/playground/assets/__tests__/assets.spec.ts b/playground/assets/__tests__/assets.spec.ts index 318d32158b0080..0ba8c8428a1670 100644 --- a/playground/assets/__tests__/assets.spec.ts +++ b/playground/assets/__tests__/assets.spec.ts @@ -834,6 +834,9 @@ describe.runIf(isBuild)('css and assets in css in build watch', () => { editFile('asset/update.js', (code) => code.replace('hello', 'world2')) await notifyRebuildComplete(watcher) + if (process.platform === 'win32') { + await new Promise((r) => setTimeout(r, 150)) + } await page.reload() await expect.poll(() => page.textContent('.update-content')).toBe('world2') @@ -848,6 +851,9 @@ describe.runIf(isBuild)('css and assets in css in build watch', () => { expect(await getColor('#foo')).toBe('red') editFile('css/foo.module.css', (code) => code.replace('red', 'blue')) await notifyRebuildComplete(watcher) + if (process.platform === 'win32') { + await new Promise((r) => setTimeout(r, 150)) + } await page.reload() expect(await getColor('#foo')).toBe('blue') }) @@ -856,6 +862,9 @@ describe.runIf(isBuild)('css and assets in css in build watch', () => { expect(await page.textContent('.raw-query')).toBe('foo') editFile('static/foo.txt', (code) => code.replace('foo', 'zoo2')) await notifyRebuildComplete(watcher) + if (process.platform === 'win32') { + await new Promise((r) => setTimeout(r, 150)) + } await page.reload() expect(await page.textContent('.raw-query')).toBe('zoo2') }) From b57583fc00e0a3f205641b6ecdd8546fcf6a50d5 Mon Sep 17 00:00:00 2001 From: Paul Cameron Date: Thu, 1 Oct 2026 13:35:29 -0400 Subject: [PATCH 8/9] test: increase timeouts to fix flakiness in CI watch tests --- playground/lazy-compilation/__tests__/lazy-compilation.spec.ts | 2 +- .../__tests__/watch/legacy-styles-only-entry-watch.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/playground/lazy-compilation/__tests__/lazy-compilation.spec.ts b/playground/lazy-compilation/__tests__/lazy-compilation.spec.ts index 3b0dd27caeff7c..f8a7eeb05a9a29 100644 --- a/playground/lazy-compilation/__tests__/lazy-compilation.spec.ts +++ b/playground/lazy-compilation/__tests__/lazy-compilation.spec.ts @@ -100,7 +100,7 @@ describe.runIf(isServe)('lazy compilation', () => { // The client reports A and B over the websocket after evaluating them; // the next lazy request is a separate HTTP request, so give the reports // a moment to land before C is compiled. - await new Promise((resolve) => setTimeout(resolve, 300)) + await new Promise((resolve) => setTimeout(resolve, 1000)) await page.click('#route-c-btn') await expect diff --git a/playground/legacy/__tests__/watch/legacy-styles-only-entry-watch.spec.ts b/playground/legacy/__tests__/watch/legacy-styles-only-entry-watch.spec.ts index ad8444c80a103f..f406350840d3b2 100644 --- a/playground/legacy/__tests__/watch/legacy-styles-only-entry-watch.spec.ts +++ b/playground/legacy/__tests__/watch/legacy-styles-only-entry-watch.spec.ts @@ -25,7 +25,7 @@ test.runIf(isBuild)('rebuilds styles only entry on change', async () => { ) await notifyRebuildComplete(watcher) // wait for both "output" to complete, workaround for https://github.com/rolldown/rolldown/issues/10613 - await Promise.race([notifyRebuildComplete(watcher), setTimeout(100)]) + await Promise.race([notifyRebuildComplete(watcher), setTimeout(1000)]) const updatedManifest = readManifest('watch') expect(Object.keys(updatedManifest)).toHaveLength(numberOfManifestEntries) From 610a376bfd475d2fdfff48e882f73f4134cf94c3 Mon Sep 17 00:00:00 2001 From: Paul Cameron Date: Fri, 2 Oct 2026 07:42:52 -0400 Subject: [PATCH 9/9] test: fix watch flakiness on ubuntu in assets.spec --- playground/assets/__tests__/assets.spec.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/playground/assets/__tests__/assets.spec.ts b/playground/assets/__tests__/assets.spec.ts index 53ab481d6b23d4..6e0ef1074f1238 100644 --- a/playground/assets/__tests__/assets.spec.ts +++ b/playground/assets/__tests__/assets.spec.ts @@ -842,9 +842,7 @@ describe.runIf(isBuild)('css and assets in css in build watch', () => { editFile('asset/update.js', (code) => code.replace('hello', 'world2')) await notifyRebuildComplete(watcher) - if (process.platform === 'win32') { - await new Promise((r) => setTimeout(r, 150)) - } + await new Promise((r) => setTimeout(r, 500)) await page.reload() await expect.poll(() => page.textContent('.update-content')).toBe('world2') @@ -859,9 +857,7 @@ describe.runIf(isBuild)('css and assets in css in build watch', () => { expect(await getColor('#foo')).toBe('red') editFile('css/foo.module.css', (code) => code.replace('red', 'blue')) await notifyRebuildComplete(watcher) - if (process.platform === 'win32') { - await new Promise((r) => setTimeout(r, 150)) - } + await new Promise((r) => setTimeout(r, 500)) await page.reload() expect(await getColor('#foo')).toBe('blue') }) @@ -870,9 +866,7 @@ describe.runIf(isBuild)('css and assets in css in build watch', () => { expect(await page.textContent('.raw-query')).toBe('foo') editFile('static/foo.txt', (code) => code.replace('foo', 'zoo2')) await notifyRebuildComplete(watcher) - if (process.platform === 'win32') { - await new Promise((r) => setTimeout(r, 150)) - } + await new Promise((r) => setTimeout(r, 500)) await page.reload() expect(await page.textContent('.raw-query')).toBe('zoo2') })