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
-}