diff --git a/CHANGELOG.md b/CHANGELOG.md index d4f19db5b..25c0c9c0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure root `theme('…')` namespace lookups in JavaScript plugins and config files return the full namespace object instead of the value of its `DEFAULT` key ([#20399](https://github.com/tailwindlabs/tailwindcss/pull/20399)) - Skip ignored directories entirely when computing watch globs (`scanner.globs`), instead of walking their full contents on every rebuild ([#20408](https://github.com/tailwindlabs/tailwindcss/pull/20408)) - Oxide: drop invalid UTF-8 candidates ([#20389](https://github.com/tailwindlabs/tailwindcss/pull/20389)) +- `@tailwindcss/vite` no longer forces a full page reload for external files (e.g.: `.php` files) ([#20414](https://github.com/tailwindlabs/tailwindcss/issues/20414)) ## [4.3.3] - 2026-07-16 diff --git a/integrations/vite/index.test.ts b/integrations/vite/index.test.ts index 9045b7926..c346f8e37 100644 --- a/integrations/vite/index.test.ts +++ b/integrations/vite/index.test.ts @@ -580,11 +580,11 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { }, ) - describe.sequential.each([['^6'], ['7.0.8'], ['7.1.12'], ['7.3.1'], ['8.0.0']])( + describe.each([['^6'], ['7.0.8'], ['7.1.12'], ['7.3.1'], ['8.0.0']])( 'Using Vite %s', (version) => { test( - 'external source file changes trigger a full reload', + 'external source file changes update the CSS', { fs: { 'package.json': json`{}`, @@ -661,26 +661,33 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { expect(styles).toContain(candidate`content-['project-b/src/index.php']`) }) - // Flush all messages so that we can be sure the next messages are from - // the file changes we're about to make + // Flush all messages so that we can be sure the next messages are + // from the file changes we're about to make process.flush() - // Changing an external .php file should trigger a full reload + // Changing an external .php file hot-updates the generated CSS { await fs.write( 'project-b/src/index.php', txt`
`, ) - // Ensure the page reloaded + // On Vite < 7.1, Vite itself hard-invalidates watched files that + // aren't part of the module graph and reloads the page. + // + // On newer versions nothing reloads the page: the CSS hot-updates + // through the regular pipeline because the changed file is a + // watch dependency of the CSS root. + // + // Reloading the page for external template changes is the + // responsibility of the backend integration (e.g. `laravel-vite-plugin`'s `refresh` option, or `vite-plugin-full-reload`). + // + // https://github.com/tailwindlabs/tailwindcss/issues/20411 if (version === '^6' || version === '7.0.8') { await process.onStdout((m) => m.includes('page reload') && m.includes('index.php')) } else { - await process.onStderr( - (m) => m.includes('vite:hmr (client)') && m.includes('index.php'), - ) + await process.onStdout((m) => m.includes('hmr update') && m.includes('index.css')) } - await process.onStderr((m) => m.includes('vite:hmr (ssr)') && m.includes('index.php')) // Ensure the styles were regenerated with the new content let styles = await fetchStyles(url, '/index.html') @@ -853,7 +860,6 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { 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 @@ -868,7 +874,6 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { 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 @@ -893,23 +898,26 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { 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. + // Changing an external file (e.g. a PHP template) hot-updates the + // generated CSS but does not trigger a full reload either. Reloading the + // page for external template changes is the responsibility of the backend + // integration (e.g. `laravel-vite-plugin`'s `refresh` option, or + // `vite-plugin-full-reload`). + // + // https://github.com/tailwindlabs/tailwindcss/issues/20411 { + let updates = (await fs.read('project-a/hmr.log')).split('"type":"update"').length + await fs.write( 'project-b/src/index.php', html`
`, ) await retryAssertion(async () => { - expect(await fs.read('project-a/hmr.log')).toContain('full-reload') + let log = await fs.read('project-a/hmr.log') + expect(log.split('"type":"update"').length).toBeGreaterThan(updates) }) let styles = await fetchStyles(url, '/index.html') diff --git a/integrations/vite/preact.test.ts b/integrations/vite/preact.test.ts new file mode 100644 index 000000000..8e4deec2b --- /dev/null +++ b/integrations/vite/preact.test.ts @@ -0,0 +1,165 @@ +import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts, txt } from '../utils' + +test( + 'dev mode', + { + fs: { + 'package.json': json` + { + "type": "module", + "dependencies": { + "preact": "^10" + }, + "devDependencies": { + "@preact/preset-vite": "^2", + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^", + "vite": "^8" + } + } + `, + 'vite.config.ts': ts` + import fs from 'node:fs' + import path from 'node:path' + import preact from '@preact/preset-vite' + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' + + export default defineConfig({ + plugins: [ + tailwindcss(), + preact(), + { + // 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) + } + } + }, + }, + ], + }) + `, + 'index.html': html` + + + + + +
+ + + + `, + 'src/main.tsx': ts` + import { render } from 'preact' + import { App } from './app' + + render(, document.getElementById('app')!) + `, + 'src/app.tsx': ts` + import { useState } from 'preact/hooks' + + export function App() { + const [count, setCount] = useState(0) + return ( + + ) + } + `, + 'src/index.css': css`@import 'tailwindcss';`, + }, + }, + async ({ fs, spawn, 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) + expect(styles).toContain(candidate`underline`) + }) + + // Load the component modules, like a browser visiting the page would + await fetch(`${url}/src/main.tsx`) + await fetch(`${url}/src/app.tsx`) + + // Editing a component keeps HMR intact: new classes are delivered through + // a regular update, not a full page reload (which would lose all state) + { + await fs.write( + 'src/app.tsx', + ts` + import { useState } from 'preact/hooks' + + export function App() { + const [count, setCount] = useState(0) + return ( + + ) + } + `, + ) + + await retryAssertion(async () => { + let styles = await fetchStyles(url) + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + }) + expect(await fs.read('hmr.log')).toContain('"type":"update"') + expect(await fs.read('hmr.log')).not.toContain('full-reload') + } + + // Changing a scanned file that is not part of the module graph (e.g. + // `package.json`, which package managers and other tooling write to while + // the dev server is running) should not trigger a full reload either — + // that would destroy client state. New candidates should still be picked + // up because the file is a watch dependency of the CSS root, so the CSS + // hot-updates through Vite's regular pipeline. + // + // https://github.com/tailwindlabs/tailwindcss/issues/20411 + { + await fs.write( + 'package.json', + txt` + { + "type": "module", + "description": "content-['package.json']", + "dependencies": { + "preact": "^10" + }, + "devDependencies": { + "@preact/preset-vite": "^2", + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^", + "vite": "^8" + } + } + `, + ) + + await retryAssertion(async () => { + let styles = await fetchStyles(url) + expect(styles).toContain(candidate`content-['package.json']`) + }) + expect(await fs.read('hmr.log')).not.toContain('full-reload') + } + }, +) diff --git a/packages/@tailwindcss-vite/src/index.test.ts b/packages/@tailwindcss-vite/src/index.test.ts deleted file mode 100644 index ee050e833..000000000 --- a/packages/@tailwindcss-vite/src/index.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { expect, test } from 'vitest' -import tailwindcss from './index' - -// Vite's experimental `bundledDev` mode calls `hotUpdate` without a `server`, -// so the handler must not dereference it. -// -// - https://github.com/vitejs/vite/discussions/22746 -// - https://github.com/tailwindlabs/tailwindcss/issues/20378 -// - https://vite.dev/blog/announcing-vite8-1#experimental-bundled-dev-mode -test('hotUpdate does not crash when Vite omits the server (bundledDev)', () => { - let plugin = tailwindcss().find((plugin) => plugin.name === '@tailwindcss/vite:generate:serve')! - - let hotUpdate = plugin.hotUpdate as unknown as (options: { - file: string - modules: unknown[] - timestamp: number - server: undefined - }) => unknown - - expect(() => - hotUpdate.call(plugin, { - file: '/app/template.html', - modules: [{ type: 'asset', id: undefined }], - timestamp: Date.now(), - server: undefined, - }), - ).not.toThrow() -}) diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index 58c69e1eb..8fc660eaa 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -9,23 +9,15 @@ import { } from '@tailwindcss/node' import { clearRequireCache } from '@tailwindcss/node/require-cache' import { Scanner } from '@tailwindcss/oxide' -import { realpathSync } from 'node:fs' import fs from 'node:fs/promises' import path from 'node:path' -import type { - Environment, - InternalResolveOptions, - Plugin, - ResolvedConfig, - ViteDevServer, -} from 'vite' +import type { Environment, InternalResolveOptions, Plugin, ResolvedConfig } from 'vite' import * as vite from 'vite' 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 = { /** @@ -73,17 +65,9 @@ function createCustomResolver( } export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { - let servers: ViteDevServer[] = [] 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 @@ -196,10 +180,6 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { name: '@tailwindcss/vite:scan', enforce: 'pre', - configureServer(server) { - servers.push(server) - }, - async configResolved(_config) { config = _config isSSR = config.build.ssr !== false && config.build.ssr !== undefined @@ -256,151 +236,6 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { return result }, }, - - hotUpdate({ file, modules, timestamp, server }) { - // Vite's experimental `bundledDev` mode invokes `hotUpdate` without a - // `server`, so there are no sibling environments to inspect and no - // server-level `hot`/`ws` channel to reload through. Bail out early - // rather than dereferencing `undefined`. - // - // https://github.com/tailwindlabs/tailwindcss/issues/20378 - if (!server) return - - // Ensure full-reloads are triggered for files that are being watched by - // Tailwind but aren't part of the module graph (like PHP or HTML - // files). If we don't do this, then changes to those files won't - // trigger a reload at all since Vite doesn't know about them. - { - // It's a little bit confusing, because due to the `addWatchFile` - // calls, it _is_ part of the module graph but nothing is really - // handling those files. These modules typically have an id of - // undefined and/or have a type of 'asset'. - // - // If we call `addWatchFile` on a file that is part of the actual - // module graph, then we will see a module for it with a type of `js` - // and a type of `asset`. We are only interested if _all_ of them are - // missing an id and/or have a type of 'asset', which is a strong - // signal that the changed file is not being handled by Vite or any of - // the plugins. - // - // Note: in Vite v7.0.6 the modules here will have a type of `js`, not - // '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. - let isExternalFile = - modules.length > 0 && - modules.every((mod) => mod.type === 'asset' || mod.id === undefined) - if (!isExternalFile) return - - // 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 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 - - // If the file is not being watched by any of the roots, then we can - // skip the reload since it's not relevant to Tailwind CSS. - if (!isScannedFile(file, modules, roots)) { - continue - } - - // https://vite.dev/changes/hotupdate-hook#migration-guide - let invalidatedModules = new Set() - for (let mod of modules) { - this.environment.moduleGraph.invalidateModule( - mod, - invalidatedModules, - timestamp, - true, - ) - } - - if (env === this.environment.name) { - this.environment.hot.send({ type: 'full-reload' }) - } else if (server.hot.send) { - server.hot.send({ type: 'full-reload' }) - } else if (server.ws.send) { - server.ws.send({ type: 'full-reload' }) - } - - return [] - } - } - }, }, { @@ -521,10 +356,6 @@ class Root { private customJsResolver: (id: string, base: string) => Promise, ) {} - get scannedFiles() { - return this.scanner?.files ?? [] - } - // Generate the CSS for the root file. This can return false if the file is // not considered a Tailwind root. When this happened, the root can be GCed. public async generate( @@ -710,54 +541,3 @@ class Root { return false } } - -function isScannedFile( - file: string, - modules: vite.EnvironmentModuleNode[], - roots: Map, -) { - let seen = new Set() - let q = [...modules] - let checks = { - file, - get realpath() { - try { - let realpath = realpathSync(file) - Object.defineProperty(checks, 'realpath', { value: realpath }) - return realpath - } catch { - return null - } - }, - } - - while (q.length > 0) { - let module = q.shift()! - if (seen.has(module)) continue - seen.add(module) - - if (module.id) { - let root = roots.get(module.id) - - if (root) { - // If the file is part of the scanned files for this root, then we know - // for sure that it's being watched by any of the Tailwind CSS roots. It - // doesn't matter which root it is since it's only used to know whether - // we should trigger a full reload or not. - if ( - root.scannedFiles.includes(checks.file) || - (checks.realpath && root.scannedFiles.includes(checks.realpath)) - ) { - return true - } - } - } - - // Keep walking up the tree until we find a root. - for (let importer of module.importers) { - q.push(importer) - } - } - - return false -}