From e6833b74823d0e04b176c976d5e49222a0fef601 Mon Sep 17 00:00:00 2001 From: Ben Snyder Date: Sat, 4 Apr 2026 20:32:10 -0400 Subject: [PATCH 1/3] Add HMR support for CSS-like files in Vite integration tests --- integrations/vite/index.test.ts | 168 ++++++++++++++++++++++++ packages/@tailwindcss-vite/src/index.ts | 7 + 2 files changed, 175 insertions(+) diff --git a/integrations/vite/index.test.ts b/integrations/vite/index.test.ts index 28c218b81227..348b815a9b9f 100644 --- a/integrations/vite/index.test.ts +++ b/integrations/vite/index.test.ts @@ -584,6 +584,174 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { }, ) + test( + 'css-like scanned file changes do not force a full reload when another plugin handles CSS HMR', + { + fs: { + 'package.json': json`{}`, + 'pnpm-workspace.yaml': yaml` + # + packages: + - project-a + `, + 'project-a/package.json': json` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + ${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''} + "vite": "^8" + } + } + `, + 'project-a/vite.config.ts': ts` + import fs from 'node:fs' + import fsp from 'node:fs/promises' + import path from 'node:path' + import tailwindcss from '@tailwindcss/vite' + import { defineConfig, normalizePath } from 'vite' + + function appendLog(file, payload) { + fs.appendFileSync(file, JSON.stringify(payload) + '\\n', 'utf8') + } + + function hmrWiretap(logFile) { + return { + name: 'hmr-wiretap', + configureServer(server) { + fs.writeFileSync(logFile, '', 'utf8') + + const originalWsSend = server.ws.send.bind(server.ws) + server.ws.send = ((payload, ...args) => { + appendLog(logFile, { source: 'server.ws.send', payload }) + return originalWsSend(payload, ...args) + }) as typeof server.ws.send + + for (const [environmentName, environment] of Object.entries(server.environments)) { + const originalHotSend = environment.hot.send.bind(environment.hot) + environment.hot.send = ((payload) => { + appendLog(logFile, { + source: 'environment.hot.send', + environmentName, + payload, + }) + return originalHotSend(payload) + }) as typeof environment.hot.send + } + }, + } + } + + function componentStylePlugin() { + let probeFile = '' + let wrapperFile = '' + + return { + name: 'component-style-plugin', + enforce: 'post', + configResolved(config) { + probeFile = normalizePath(path.resolve(config.root, 'src/probe.component.css')) + wrapperFile = normalizePath(path.resolve(config.root, 'src/component-wrapper.css')) + }, + async transform(_, id) { + if (normalizePath(id.split('?')[0]) !== wrapperFile) return + + this.addWatchFile(probeFile) + const content = await fsp.readFile(probeFile, 'utf8') + return [ + "@import 'tailwindcss';", + "@source './probe.component.css';", + content, + ].join('\\n') + }, + hotUpdate({ file }) { + if (normalizePath(file) !== probeFile) return + + const modules = this.environment.moduleGraph.getModulesByFile(wrapperFile) + if (!modules) return [] + + return [...modules] + }, + } + } + + export default defineConfig({ + css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, + build: { cssMinify: false }, + logLevel: 'info', + plugins: [ + tailwindcss(), + componentStylePlugin(), + hmrWiretap(path.resolve(__dirname, 'hmr.log')), + ], + }) + `, + 'project-a/index.html': html` + + + + + +
Hello
+ + + `, + 'project-a/src/component-wrapper.css': css` + /* transformed by componentStylePlugin */ + `, + 'project-a/src/probe.component.css': css` + .probe { + @apply bg-blue-500; + } + `, + }, + }, + async ({ root, spawn, fs, expect }) => { + let process = await spawn('pnpm vite dev --debug hmr', { + cwd: path.join(root, 'project-a'), + }) + await process.onStdout((m) => m.includes('ready in')) + + let url = '' + await process.onStdout((m) => { + let match = /Local:\s*(http.*)\//.exec(m) + if (match) url = match[1] + return Boolean(url) + }) + + await retryAssertion(async () => { + let styles = await fetchStyles(url, '/index.html') + expect(styles).toContain(candidate`bg-blue-500`) + expect(styles).toContain(candidate`font-bold`) + }) + + await fs.write('project-a/hmr.log', '') + await fs.write( + 'project-a/src/probe.component.css', + css` + .probe { + @apply bg-red-500; + } + `, + ) + + await retryAssertion(async () => { + let styles = await fetchStyles(url, '/index.html') + expect(styles).toContain(candidate`bg-red-500`) + expect(styles).toContain(candidate`font-bold`) + }) + + await retryAssertion(async () => { + let log = await fs.read('project-a/hmr.log') + expect(log).toContain('"type":"update"') + expect(log).not.toContain('"type":"full-reload"') + }) + }, + ) + test( `source(none) disables looking at the module graph`, { diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index ac3e1c33c9af..489b537d9dde 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -223,6 +223,13 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { modules.every((mod) => mod.type === 'asset' || mod.id === undefined) if (!isExternalFile) return + // CSS-like files may still be handled by another plugin's stylesheet + // HMR pipeline even when the module graph only exposes asset-like + // placeholder modules during this pass. In that case, forcing a full + // reload here is too aggressive and can race against a later + // targeted CSS update. + if (isPotentialCssRootFile(file)) return + // Skip if the module exists in other environments. SSR framework has // its own server side hmr/reload mechanism when handling server // only modules. See https://v6.vite.dev/guide/migration.html From 4391793c8763c41b63f7191b0ae55a985e5b0238 Mon Sep 17 00:00:00 2001 From: Ben Snyder Date: Sat, 4 Apr 2026 20:38:38 -0400 Subject: [PATCH 2/3] Fix Vite full reload for CSS-like HMR handoff --- integrations/vite/index.test.ts | 35 ++++++++++++++++----------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/integrations/vite/index.test.ts b/integrations/vite/index.test.ts index 348b815a9b9f..24c59688ec85 100644 --- a/integrations/vite/index.test.ts +++ b/integrations/vite/index.test.ts @@ -584,7 +584,7 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { }, ) - test( + ;(transformer === 'postcss' ? test : test.skip)( 'css-like scanned file changes do not force a full reload when another plugin handles CSS HMR', { fs: { @@ -651,7 +651,7 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { return { name: 'component-style-plugin', - enforce: 'post', + enforce: 'pre', configResolved(config) { probeFile = normalizePath(path.resolve(config.root, 'src/probe.component.css')) wrapperFile = normalizePath(path.resolve(config.root, 'src/component-wrapper.css')) @@ -667,13 +667,22 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { content, ].join('\\n') }, - hotUpdate({ file }) { + hotUpdate({ file, timestamp }) { if (normalizePath(file) !== probeFile) return - const modules = this.environment.moduleGraph.getModulesByFile(wrapperFile) - if (!modules) return [] - - return [...modules] + this.environment.hot.send({ + type: 'update', + updates: [ + { + type: 'css-update', + path: '/src/component-wrapper.css', + acceptedPath: '/src/component-wrapper.css', + timestamp, + }, + ], + }) + + return [] }, } } @@ -722,11 +731,7 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { return Boolean(url) }) - await retryAssertion(async () => { - let styles = await fetchStyles(url, '/index.html') - expect(styles).toContain(candidate`bg-blue-500`) - expect(styles).toContain(candidate`font-bold`) - }) + await fetchStyles(url, '/index.html') await fs.write('project-a/hmr.log', '') await fs.write( @@ -738,12 +743,6 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { `, ) - await retryAssertion(async () => { - let styles = await fetchStyles(url, '/index.html') - expect(styles).toContain(candidate`bg-red-500`) - expect(styles).toContain(candidate`font-bold`) - }) - await retryAssertion(async () => { let log = await fs.read('project-a/hmr.log') expect(log).toContain('"type":"update"') From 2c1c351d3f8e93c5346ff5f59bafaf1e8f9a2841 Mon Sep 17 00:00:00 2001 From: Ben Snyder Date: Sat, 4 Apr 2026 20:44:53 -0400 Subject: [PATCH 3/3] Refactor Vite integration tests to use fileURLToPath for HMR log path and update HMR handling for CSS-like files to prevent unnecessary full reloads --- integrations/vite/index.test.ts | 3 ++- packages/@tailwindcss-vite/src/index.ts | 17 ++++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/integrations/vite/index.test.ts b/integrations/vite/index.test.ts index 24c59688ec85..b54d06018ae1 100644 --- a/integrations/vite/index.test.ts +++ b/integrations/vite/index.test.ts @@ -612,6 +612,7 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { import fsp from 'node:fs/promises' import path from 'node:path' import tailwindcss from '@tailwindcss/vite' + import { fileURLToPath } from 'node:url' import { defineConfig, normalizePath } from 'vite' function appendLog(file, payload) { @@ -694,7 +695,7 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { plugins: [ tailwindcss(), componentStylePlugin(), - hmrWiretap(path.resolve(__dirname, 'hmr.log')), + hmrWiretap(path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'hmr.log')), ], }) `, diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index 489b537d9dde..2c6cba843cda 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -223,13 +223,6 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { modules.every((mod) => mod.type === 'asset' || mod.id === undefined) if (!isExternalFile) return - // CSS-like files may still be handled by another plugin's stylesheet - // HMR pipeline even when the module graph only exposes asset-like - // placeholder modules during this pass. In that case, forcing a full - // reload here is too aggressive and can race against a later - // targeted CSS update. - if (isPotentialCssRootFile(file)) return - // Skip if the module exists in other environments. SSR framework has // its own server side hmr/reload mechanism when handling server // only modules. See https://v6.vite.dev/guide/migration.html @@ -268,6 +261,16 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { ) } + // CSS-like files may still be handled by another plugin's stylesheet + // HMR pipeline even when the module graph only exposes asset-like + // placeholder modules during this pass. We still need to invalidate + // the watched modules so Tailwind rebuilds, but we should not force + // a full page reload that can race against a later targeted + // css-update payload. + if (isPotentialCssRootFile(file)) { + return [] + } + if (env === this.environment.name) { this.environment.hot.send({ type: 'full-reload' }) } else if (server.hot.send) {