Do not force full page reloads when using @tailwindcss/vite (#20414)

This PR removes all of the custom HMR handling we had in the
`@tailwindcss/vite` plugin.

When Vite 7.1 was introduced, Vite stopped performing a full page reload
for unknown files and instead started performing normal `hmr` updates.
This resulted in this issue:
https://github.com/tailwindlabs/tailwindcss/issues/19637

At the time, it felt like something we could easily re-add: if a file is
not covered by Vite, we can perform a `full-reload`. This meant that a
`.php` file would trigger a full page reload as expected.

The reason the `.php` file triggered Vite in the first place is because
those files were scanned by us (`@tailwindcss/vite`) so it made sense.

However, this then resulted in a plethora of issues, and it feels a bit
like a game of whac-a-mole.

- https://github.com/tailwindlabs/tailwindcss/issues/19744
- https://github.com/tailwindlabs/tailwindcss/issues/19903
- https://github.com/tailwindlabs/tailwindcss/issues/20320
- https://github.com/tailwindlabs/tailwindcss/issues/20378
- https://github.com/tailwindlabs/tailwindcss/issues/20411

Fixes: #19744
Fixes: #19903
Fixes: #20320
Fixes: #20378
Fixes: #20411

We kept updating the logic by safelisting certain extensions, checking
different servers and/or environments, handling the fact that `server`
in the callback could be absent in `experimental.bundledDev` mode, etc.
etc.

Now, when investigating the last issue
(https://github.com/tailwindlabs/tailwindcss/issues/20411), I can
trigger full reloads by changing `.json`, `.yaml` or `.svg` files. This
makes sense since they aren't handled by default.

So thinking about this more, I think it's just not Tailwind's
responsibility to tell Vite to reload the browser or not. Yes, we use
the `addWatchFile` API, so files are being watched because of us.
However, our only goal is to update the `.css` file (and HMR that).

This means that we can just drop all the custom HMR handling we have in
`@tailwindcss/vite`.

This also means that
https://github.com/tailwindlabs/tailwindcss/issues/19637 would regress
and won't trigger full page reloads. But this can be easily handled by a
plugin responsible for this behavior:

- https://github.com/ElMassimo/vite-plugin-full-reload

## Test plan

1. All tests pass
2. Manually tested and changing unknown files don't result in a full
page reload
This commit is contained in:
Robin Malfait 2026-08-13 17:00:08 +02:00 • committed by GitHub
parent b9286a7346
commit 00ef99df3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 195 additions and 269 deletions

View file

@ -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`<div class="content-['updated:project-b/src/index.php']"></div>`,
)
// 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`<div class="content-['updated:project-b/src/index.php']"></div>`,
)
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')

View file

@ -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`
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>
`,
'src/main.tsx': ts`
import { render } from 'preact'
import { App } from './app'
render(<App />, document.getElementById('app')!)
`,
'src/app.tsx': ts`
import { useState } from 'preact/hooks'
export function App() {
const [count, setCount] = useState(0)
return (
<button className="underline" onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
)
}
`,
'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 (
<button className="underline flex" onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
)
}
`,
)
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')
}
},
)