diff --git a/packages/vite/src/node/server/hmr.ts b/packages/vite/src/node/server/hmr.ts index 3d5aedb23..f67778387 100644 --- a/packages/vite/src/node/server/hmr.ts +++ b/packages/vite/src/node/server/hmr.ts @@ -533,6 +533,7 @@ export function handlePrunedModules( const t = Date.now() mods.forEach((mod) => { mod.lastHMRTimestamp = t + mod.lastHMRInvalidationReceived = false debugHmr?.(`[dispose] ${colors.dim(mod.file)}`) }) hot.send({ diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index 5cd066ffc..d36b4c309 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -787,7 +787,13 @@ export async function _createServer( hot.on('vite:invalidate', async ({ path, message }) => { const mod = moduleGraph.urlToModuleMap.get(path) - if (mod && mod.isSelfAccepting && mod.lastHMRTimestamp > 0) { + if ( + mod && + mod.isSelfAccepting && + mod.lastHMRTimestamp > 0 && + !mod.lastHMRInvalidationReceived + ) { + mod.lastHMRInvalidationReceived = true config.logger.info( colors.yellow(`hmr invalidate `) + colors.dim(path) + diff --git a/packages/vite/src/node/server/moduleGraph.ts b/packages/vite/src/node/server/moduleGraph.ts index c0554dd15..442ece308 100644 --- a/packages/vite/src/node/server/moduleGraph.ts +++ b/packages/vite/src/node/server/moduleGraph.ts @@ -35,6 +35,13 @@ export class ModuleNode { ssrModule: Record | null = null ssrError: Error | null = null lastHMRTimestamp = 0 + /** + * `import.meta.hot.invalidate` is called by the client. + * If there's multiple clients, multiple `invalidate` request is received. + * This property is used to dedupe those request to avoid multiple updates happening. + * @internal + */ + lastHMRInvalidationReceived = false lastInvalidationTimestamp = 0 /** * If the module only needs to update its imports timestamp (e.g. within an HMR chain), @@ -199,6 +206,7 @@ export class ModuleGraph { if (isHmr) { mod.lastHMRTimestamp = timestamp + mod.lastHMRInvalidationReceived = false } else { // Save the timestamp for this invalidation, so we can avoid caching the result of possible already started // processing being done for this module diff --git a/playground/hmr/__tests__/hmr.spec.ts b/playground/hmr/__tests__/hmr.spec.ts index 138cff2cb..27590bd60 100644 --- a/playground/hmr/__tests__/hmr.spec.ts +++ b/playground/hmr/__tests__/hmr.spec.ts @@ -1,7 +1,9 @@ import { beforeAll, describe, expect, it, test } from 'vitest' +import type { Page } from 'playwright-chromium' import { hasWindowsUnicodeFsBug } from '../../hasWindowsUnicodeFsBug' import { addFile, + browser, browserLogs, editFile, getBg, @@ -175,6 +177,38 @@ if (!isBuild) { await untilUpdated(() => el.textContent(), 'child updated') }) + test('invalidate works with multiple tabs', async () => { + let page2: Page + try { + page2 = await browser.newPage() + await page2.goto(viteTestUrl) + + const el = await page.$('.invalidation') + await untilBrowserLogAfter( + () => + editFile('invalidation/child.js', (code) => + code.replace('child', 'child updated'), + ), + [ + '>>> vite:beforeUpdate -- update', + '>>> vite:invalidate -- /invalidation/child.js', + '[vite] invalidate /invalidation/child.js', + '[vite] hot updated: /invalidation/child.js', + '>>> vite:afterUpdate -- update', + // if invalidate dedupe doesn't work correctly, this beforeUpdate will be called twice + '>>> vite:beforeUpdate -- update', + '(invalidation) parent is executing', + '[vite] hot updated: /invalidation/parent.js', + '>>> vite:afterUpdate -- update', + ], + true, + ) + await untilUpdated(() => el.textContent(), 'child updated') + } finally { + await page2.close() + } + }) + test('soft invalidate', async () => { const el = await page.$('.soft-invalidation') expect(await el.textContent()).toBe(