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