From bdcd7087b332d263353d46ed366b7b08040ded7a Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 15 Jul 2026 17:49:05 +0200 Subject: [PATCH] Don't trigger a full page reload for scanned files that Vite processes as modules (#20336) This PR fixes an issue where editing a scanned file that Vite (or one of its plugins) can process as a module, but that isn't currently loaded, caused `@tailwindcss/vite` to force a full page reload, throwing away all client state. The `hotUpdate` hook has a fallback that sends a `full-reload` for files that Tailwind scans but that Vite knows nothing about (e.g. `.php` or `.blade.php` templates rendered by a backend). Without it, edits to those files wouldn't refresh the page at all. To detect those files we check whether every module for the changed file is an `asset` and/or has no id, because the scanner's `addWatchFile` calls create exactly such placeholder nodes for every scanned file. The problem is that a source file that Vite _can_ process, but that isn't loaded yet, looks exactly the same. The realistic way to get into that state is code splitting: with route-level splitting (e.g. `React.lazy`, TanStack Router's `autoCodeSplitting`, lazy routes in `vue-router`), every component behind an un-visited split boundary only exists as a scan placeholder in the module graph. Editing any of them reloaded the whole app. The same happens for component stylesheets that a framework plugin compiles into the component (e.g. Angular via Analog), which never show up as their own module. A full reload is never useful for these files: if the file is loaded, Vite's own HMR handles it, and if it isn't loaded, reloading the page won't load it either. Any new candidates still apply through the regular `css-update` flow because the file is registered via `addWatchFile`. So instead, we now skip the fallback when the changed file is handled by Vite's module pipeline: - The file exists as a real module in another environment (e.g. an SSR-only module). This check already existed and is folded into the same code path. - The file is part of the JS/TS or CSS families, which Vite transforms natively. - For any other file type (e.g. `.vue`, `.svelte`, or `.md` with an SSG plugin), a file with the same extension exists as a real module in some environment's module graph, then a plugin does handle this file type and the changed file just isn't loaded (yet). External templates like `.php` files still trigger a full reload exactly like before. Fixes: #20320 Fixes: #19903 Closes: #20323 ## Test plan 1. Added integration tests to ensure extensions handled by default rely on HMR 2. Added integration tests to make sure that unknown extensions that have been handled already will also use HMR 3. Manually tested that changing a `.php` file still triggers a `full-reload` 4. Manually tested the reproduction where local client state isn't thrown away file-14a86a90a1e4b810c2b80338ea688572 file-a5121da2ad77b95fdd1703560ef1ff41 --- CHANGELOG.md | 1 + integrations/vite/index.test.ts | 227 ++++++++++++++++++++++++ integrations/vite/vue.test.ts | 132 +++++++++++++- packages/@tailwindcss-vite/src/index.ts | 78 +++++++- 4 files changed, 431 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01106b585..7077c53cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use explicit platform fonts instead of `system-ui` and `ui-sans-serif` so CJK text respects the page's `lang` attribute on Windows ([#19767](https://github.com/tailwindlabs/tailwindcss/issues/19767), [#19768](https://github.com/tailwindlabs/tailwindcss/issues/19768)) - Prevent `@tailwindcss/upgrade` from rewriting ignored files when run from a subdirectory ([#20328](https://github.com/tailwindlabs/tailwindcss/issues/20328)) - Ensure earlier `@source` rules pointing to nested files are scanned when later `@source` rules point to files in parent folders ([#20335](https://github.com/tailwindlabs/tailwindcss/pull/20335)) +- Prevent `@tailwindcss/vite` from triggering full page reloads when scanned files are processed by Vite but haven't been loaded as modules yet ([#20336](https://github.com/tailwindlabs/tailwindcss/pull/20336)) ## [4.3.2] - 2026-06-26 diff --git a/integrations/vite/index.test.ts b/integrations/vite/index.test.ts index 34940c74e..eba315e4c 100644 --- a/integrations/vite/index.test.ts +++ b/integrations/vite/index.test.ts @@ -584,6 +584,233 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { }, ) + // https://github.com/tailwindlabs/tailwindcss/issues/20320 + // https://github.com/tailwindlabs/tailwindcss/issues/19903 + test( + 'editing scanned files that Vite can process as modules does not trigger a full reload', + { + 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 path from 'node:path' + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' + + export default defineConfig({ + css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, + build: { cssMinify: false }, + plugins: [ + tailwindcss(), + { + // A plugin that processes a custom file type into a JS + // module, similar to e.g. \`.vue\` or \`.svelte\` files + name: 'custom-file-type', + transform(code, id) { + if (id.endsWith('.custom')) { + return { code: 'export default ' + JSON.stringify(code), map: null } + } + }, + }, + { + // Log all HMR payloads to a file so the test can assert on + // them + name: 'hmr-wiretap', + configureServer(server) { + let logFile = path.resolve('hmr.log') + fs.writeFileSync(logFile, '') + for (let environment of Object.values(server.environments)) { + let send = environment.hot.send.bind(environment.hot) + environment.hot.send = (payload) => { + fs.appendFileSync(logFile, JSON.stringify(payload) + '\\n') + return send(payload) + } + } + }, + }, + ], + }) + `, + 'project-a/index.html': html` + + + + + +
+ + + + `, + 'project-a/src/main.ts': ts` + import compA from './comp-a.custom' + import snippet from './snippet.php?raw' + console.log(compA, snippet) + `, + 'project-a/src/snippet.php': html` +
+ `, + 'project-a/src/comp-a.custom': html` +
+ `, + 'project-a/src/comp-b.custom': html` +
+ `, + 'project-a/src/lazy.tsx': jsx` + export default function Lazy() { + return
+ } + `, + 'project-a/src/unimported.css': css` + .unimported { + color: red; + } + `, + 'project-a/src/index.css': css` + @import 'tailwindcss'; + @source '../../project-b/**/*.php'; + `, + 'project-b/src/index.php': html` +
+ `, + }, + }, + async ({ root, spawn, fs, expect }) => { + let process = await spawn('pnpm vite dev', { + 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`content-['src/lazy.tsx']`) + expect(styles).toContain(candidate`content-['src/comp-b.custom']`) + expect(styles).toContain(candidate`content-['project-b/src/index.php']`) + }) + + // Load `main.ts`, `comp-a.custom`, and `snippet.php?raw` as real + // modules, like a browser visiting the page would + await fetch(`${url}/src/main.ts`) + await fetch(`${url}/src/comp-a.custom?import`) + await fetch(`${url}/src/snippet.php?raw`) + + // Changing a scanned `.tsx` file that is not part of the module graph + // (e.g. a lazily-loaded route that hasn't been visited yet) should not + // trigger a full reload, but new classes should still apply + // + // https://github.com/tailwindlabs/tailwindcss/issues/20320 + { + await fs.write( + 'project-a/src/lazy.tsx', + jsx` + export default function Lazy() { + return
+ } + `, + ) + + await retryAssertion(async () => { + let styles = await fetchStyles(url, '/index.html') + expect(styles).toContain(candidate`content-['updated:src/lazy.tsx']`) + }) + expect(await fs.read('project-a/hmr.log')).not.toContain('full-reload') + } + + // The same holds for a custom file type as long as some file of the + // same type was processed as a module before + { + await fs.write( + 'project-a/src/comp-b.custom', + html`
`, + ) + + await retryAssertion(async () => { + let styles = await fetchStyles(url, '/index.html') + expect(styles).toContain(candidate`content-['updated:src/comp-b.custom']`) + }) + expect(await fs.read('project-a/hmr.log')).not.toContain('full-reload') + } + + // Changing a scanned stylesheet that is not part of the module graph + // (e.g. a component stylesheet that a framework plugin compiles into + // the component) should not trigger a full reload either + // + // https://github.com/tailwindlabs/tailwindcss/issues/19903 + { + let updates = (await fs.read('project-a/hmr.log')).split('"type":"update"').length + + await fs.write( + 'project-a/src/unimported.css', + css` + .unimported { + color: blue; + } + `, + ) + + // Wait until the change was handled and an update was pushed + await retryAssertion(async () => { + let log = await fs.read('project-a/hmr.log') + expect(log.split('"type":"update"').length).toBeGreaterThan(updates) + }) + expect(await fs.read('project-a/hmr.log')).not.toContain('full-reload') + } + + // Changing an external file (e.g. a PHP template) should still trigger + // a full reload. This must work even though `snippet.php` is part of + // the module graph via the `?raw` import: a query import only pulls + // the file's contents into the graph (and creates an untransformed + // module node for the underlying file), it is not evidence that Vite + // processes `.php` files as modules. + { + await fs.write( + 'project-b/src/index.php', + html`
`, + ) + + await retryAssertion(async () => { + expect(await fs.read('project-a/hmr.log')).toContain('full-reload') + }) + + let styles = await fetchStyles(url, '/index.html') + expect(styles).toContain(candidate`content-['updated:project-b/src/index.php']`) + } + }, + ) + test( `source(none) disables looking at the module graph`, { diff --git a/integrations/vite/vue.test.ts b/integrations/vite/vue.test.ts index 6bcd3f799..b04f57c6b 100644 --- a/integrations/vite/vue.test.ts +++ b/integrations/vite/vue.test.ts @@ -1,5 +1,5 @@ import { stripVTControlCharacters } from 'node:util' -import { candidate, css, html, json, test, ts } from '../utils' +import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts } from '../utils' test( 'production build', @@ -248,3 +248,133 @@ test( } }, ) + +// https://github.com/tailwindlabs/tailwindcss/issues/20320 +test( + 'editing a scanned `.vue` file that is not loaded as a module does not trigger a full reload', + { + fs: { + 'package.json': json` + { + "type": "module", + "dependencies": { + "vue": "^3.4.37", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6", + "@tailwindcss/vite": "workspace:^", + "vite": "^8" + } + } + `, + 'vite.config.ts': ts` + import fs from 'node:fs' + import path from 'node:path' + import { defineConfig } from 'vite' + import vue from '@vitejs/plugin-vue' + import tailwindcss from '@tailwindcss/vite' + + export default defineConfig({ + plugins: [ + vue(), + tailwindcss(), + { + // Log update and full-reload HMR payloads to a file so the + // test can assert on them. Custom events are not logged + // because \`@vitejs/plugin-vue\` sends a \`file-changed\` event + // for every file change, including changes to the log file + // itself, which would cause an infinite feedback loop. + name: 'hmr-wiretap', + configureServer(server) { + let logFile = path.resolve('hmr.log') + fs.writeFileSync(logFile, '') + for (let environment of Object.values(server.environments)) { + let send = environment.hot.send.bind(environment.hot) + environment.hot.send = (payload) => { + if (payload.type === 'update' || payload.type === 'full-reload') { + fs.appendFileSync(logFile, JSON.stringify(payload) + '\\n') + } + return send(payload) + } + } + }, + }, + ], + }) + `, + 'index.html': html` + + + + + + +
+ + + + `, + 'src/index.css': css`@import 'tailwindcss';`, + 'src/main.ts': ts` + import { createApp } from 'vue' + import App from './App.vue' + + createApp(App).mount('#app') + `, + 'src/App.vue': html` + + `, + + // This file is scanned by Tailwind but never imported, so it is not + // part of the loaded module graph (e.g. a lazy route that hasn't been + // visited yet) + 'src/LazyRoute.vue': html` + + `, + }, + }, + async ({ spawn, fs, expect }) => { + let process = await spawn('pnpm vite dev') + 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`content-['src/App.vue']`) + expect(styles).toContain(candidate`content-['src/LazyRoute.vue']`) + }) + + // Load `main.ts` and `App.vue` as real modules, like a browser visiting + // the page would + await fetch(`${url}/src/main.ts`) + await fetch(`${url}/src/App.vue`) + + // Changing the scanned but unloaded `.vue` file should not trigger a + // full reload, but new classes should still apply + await fs.write( + 'src/LazyRoute.vue', + html` + + `, + ) + + await retryAssertion(async () => { + let styles = await fetchStyles(url, '/index.html') + expect(styles).toContain(candidate`content-['updated:src/LazyRoute.vue']`) + }) + expect(await fs.read('hmr.log')).not.toContain('full-reload') + }, +) diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index 9796ff0d0..bd38f5272 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -25,6 +25,7 @@ const DEBUG = env.DEBUG const SPECIAL_QUERY_RE = /[?&](?:worker|sharedworker|raw|url)\b/ const COMMON_JS_PROXY_RE = /\?commonjs-proxy/ const INLINE_STYLE_ID_RE = /[?&]index=\d+\.css$/ +const JS_EXTENSIONS_RE = /^\.[cm]?[jt]sx?$/ export type PluginOptions = { /** @@ -76,6 +77,13 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { let config: ResolvedConfig | null = null let rootsByEnv = new DefaultMap>((env: string) => new Map()) + // File extensions that Vite (or one of its plugins) has been seen to process + // as a module. Plugins don't get added or removed while the dev server is + // running (changing the Vite config restarts the server), so once we've seen + // evidence for a file type we don't need to scan the module graphs for it + // again. + let viteProcessedExtensions = new Set() + let isSSR = false let shouldOptimize = true let minify = true @@ -271,29 +279,87 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { // 'asset'. But it will also have a `HARD_INVALIDATED` state and will // do a full page reload already. // - // Empty modules can be skipped since it means it's not `addWatchFile`d and thus irrelevant to Tailwind. + // Empty modules can be skipped since it means it's not + // `addWatchFile`d and thus irrelevant to Tailwind. let isExternalFile = modules.length > 0 && modules.every((mod) => mod.type === 'asset' || mod.id === undefined) if (!isExternalFile) 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 + // Skip files that Vite (or one of its plugins) processes as a + // module — in this environment (e.g. a lazily-loaded route that + // hasn't been visited yet) or in another one (e.g. an SSR-only + // module). Such a file can only affect the page through Vite's own + // pipeline, so a full reload would only destroy client state. Any + // changes to the generated CSS still go through the regular + // `css-update` flow because the file is registered via + // `addWatchFile`. + // + // If the file exists as a real module in another environment, then + // that environment is responsible for it. E.g. an SSR framework + // has its own server side hmr/reload mechanism when handling + // server only modules. See https://v6.vite.dev/guide/migration.html // > Updates to an SSR-only module no longer triggers a full page reload in the client. ... for (let environment of Object.values(server.environments)) { if (environment.name === this.environment.name) continue let modules = environment.moduleGraph.getModulesByFile(file) if (modules) { - for (let module of modules) { - if (module.type !== 'asset') { + for (let mod of modules) { + if (mod.type !== 'asset') { return } } } } + // Otherwise the file is not loaded as a module anywhere, so + // determine whether its file _type_ would be processed by Vite + // when requested by the browser (in which case the file just isn't + // loaded yet, e.g. a lazily-loaded route that hasn't been visited). + // Vite has no API to answer this without actually running the + // plugin pipeline, so instead: + // + // Files Vite handles natively (the JS/TS and CSS families) are always + // processed by Vite. This includes stylesheets that never show up as + // their own module because a framework plugin compiles them into a + // component (e.g. Angular), in which case that plugin owns their HMR. + let extension = path.extname(file) + if (JS_EXTENSIONS_RE.test(extension) || vite.isCSSRequest(file)) return + + // For any other file type (e.g. `.vue`, `.svelte`, or `.md` with an + // SSG plugin), if a file with the same extension exists as a real + // module in any environment's module graph, then a plugin evidently + // handles this file type and the changed file just isn't loaded + // (yet). + if (extension !== '') { + if (viteProcessedExtensions.has(extension)) return + + for (let environment of Object.values(server.environments)) { + for (let mod of environment.moduleGraph.idToModuleMap.values()) { + if (!mod.file?.endsWith(extension)) continue + if (mod.type === 'asset') continue + + // Only count modules that the plugin pipeline actually + // transformed. Vite also creates untransformed placeholder + // nodes (e.g. for the file underlying a `?raw` import) that + // are not evidence that a plugin handles this file type. + if (mod.transformResult == null) continue + + // Similarly, ignore query imports (e.g. `./template.html?raw`, + // or the `?html-proxy` modules Vite creates for inline + // scripts): they pull a file's _contents_ into the graph + // without a plugin processing the file type. A scanned + // `.html` template must still trigger a full reload even if + // some other `.html` file is imported with `?raw`. + if (!mod.id || mod.id.includes('?')) continue + + viteProcessedExtensions.add(extension) + return + } + } + } + for (let env of new Set([this.environment.name, 'client'])) { let roots = rootsByEnv.get(env) if (roots.size === 0) continue