Skip to content

Commit f212b0f

Browse files
fix: restore full page reload for watched external files on Vite 7.1+ (#19670)
# PR: Fix @source file changes not triggering full page reload on Vite 7.1+ ## Description This PR addresses issue #19637 where template files (PHP, HTML, Blade, etc.) watched via the `@source` directive fail to trigger a full page reload when using Vite 7.1 or newer. ## Root Cause Vite 7.1 introduced the Environment API, which supersedes the legacy WebSocket API for HMR. Specifically: - `server.ws.send` is deprecated/ignored for certain external file updates in favor of `server.hot.send`. - The `@tailwindcss/vite` plugin currently collects `ViteDevServer` instances but lacks a `handleHotUpdate` hook to explicitly trigger reloads for non-module files added via `addWatchFile`. ## Changes - Implemented a `handleHotUpdate` hook in the `@tailwindcss/vite` plugin. - The hook identifies changes to files that are not part of the standard Vite module graph (e.g., `.php`, `.html`) but are watched by Tailwind. - Triggers a `full-reload` using the new `server.hot.send` API if available (Vite 7.1+), with a fallback to `server.ws.send` for backward compatibility. ## Verification - Reproduced the issue in a standalone Vite 7.1.0 project using a mock plugin with the legacy API. - Confirmed that the browser fails to reload upon editing a watched `.php` file. - Verified that migrating to `server.hot.send` restores the expected reload behavior. [ci-all] --------- Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
1 parent 6eb3b32 commit f212b0f

3 files changed

Lines changed: 227 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2727
- Align `@utility` name validation with Oxide scanner rules ([#19524](https://github.com/tailwindlabs/tailwindcss/pull/19524))
2828
- Fix infinite loop when using `@variant` inside `@custom-variant` ([#19633](https://github.com/tailwindlabs/tailwindcss/pull/19633))
2929
- Allow multiples of `.25` in `aspect-*` fractions ([#19688](https://github.com/tailwindlabs/tailwindcss/pull/19688))
30+
- Ensure changes to external files listed via `@source` trigger a full page reload when using `@tailwindcss/vite` ([#19670](https://github.com/tailwindlabs/tailwindcss/pull/19670))
3031

3132
### Deprecated
3233

integrations/vite/index.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
html,
88
js,
99
json,
10+
jsx,
1011
retryAssertion,
1112
test,
1213
ts,
@@ -472,6 +473,117 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
472473
},
473474
)
474475

476+
describe.sequential.each([['^6'], ['7.0.8'], ['7.1.12'], ['7.3.1']])(
477+
'Using Vite %s',
478+
(version) => {
479+
test(
480+
'external source file changes trigger a full reload',
481+
{
482+
fs: {
483+
'package.json': json`{}`,
484+
'pnpm-workspace.yaml': yaml`
485+
#
486+
packages:
487+
- project-a
488+
`,
489+
'project-a/package.json': json`
490+
{
491+
"type": "module",
492+
"dependencies": {
493+
"@tailwindcss/vite": "workspace:^",
494+
"tailwindcss": "workspace:^"
495+
},
496+
"devDependencies": {
497+
${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''}
498+
"vite": "${version}"
499+
}
500+
}
501+
`,
502+
'project-a/vite.config.ts': ts`
503+
import fs from 'node:fs'
504+
import path from 'node:path'
505+
import tailwindcss from '@tailwindcss/vite'
506+
import { defineConfig } from 'vite'
507+
508+
export default defineConfig({
509+
css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"},
510+
build: { cssMinify: false },
511+
plugins: [tailwindcss()],
512+
logLevel: 'info',
513+
})
514+
`,
515+
'project-a/index.html': html`
516+
<html>
517+
<head>
518+
<link rel="stylesheet" href="./src/index.css" />
519+
</head>
520+
<body>
521+
<div id="app"></div>
522+
<script type="module" src="./src/main.ts"></script>
523+
</body>
524+
</html>
525+
`,
526+
'project-a/src/main.ts': jsx`import { classes } from './app'`,
527+
'project-a/src/app.ts': jsx`export let classes = "content-['project-a/src/app.ts']"`,
528+
'project-a/src/index.css': css`
529+
@import 'tailwindcss';
530+
@source '../../project-b/**/*.php';
531+
`,
532+
'project-b/src/index.php': html`
533+
<div
534+
class="content-['project-b/src/index.php']"
535+
></div>
536+
`,
537+
},
538+
},
539+
async ({ root, spawn, fs, expect }) => {
540+
let process = await spawn('pnpm vite dev --debug hmr', {
541+
cwd: path.join(root, 'project-a'),
542+
})
543+
await process.onStdout((m) => m.includes('ready in'))
544+
545+
let url = ''
546+
await process.onStdout((m) => {
547+
let match = /Local:\s*(http.*)\//.exec(m)
548+
if (match) url = match[1]
549+
return Boolean(url)
550+
})
551+
552+
await retryAssertion(async () => {
553+
let styles = await fetchStyles(url, '/index.html')
554+
expect(styles).toContain(candidate`content-['project-b/src/index.php']`)
555+
})
556+
557+
// Flush all messages so that we can be sure the next messages are from
558+
// the file changes we're about to make
559+
process.flush()
560+
561+
// Changing an external .php file should trigger a full reload
562+
{
563+
await fs.write(
564+
'project-b/src/index.php',
565+
txt`<div class="content-['updated:project-b/src/index.php']"></div>`,
566+
)
567+
568+
// Ensure the page reloaded
569+
if (version === '^6' || version === '7.0.8') {
570+
await process.onStdout((m) => m.includes('page reload') && m.includes('index.php'))
571+
} else {
572+
await process.onStderr(
573+
(m) => m.includes('vite:hmr (client)') && m.includes('index.php'),
574+
)
575+
}
576+
await process.onStderr((m) => m.includes('vite:hmr (ssr)') && m.includes('index.php'))
577+
578+
// Ensure the styles were regenerated with the new content
579+
let styles = await fetchStyles(url, '/index.html')
580+
expect(styles).toContain(candidate`content-['updated:project-b/src/index.php']`)
581+
}
582+
},
583+
)
584+
},
585+
)
586+
475587
test(
476588
`source(none) disables looking at the module graph`,
477589
{

packages/@tailwindcss-vite/src/index.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from '@tailwindcss/node'
1010
import { clearRequireCache } from '@tailwindcss/node/require-cache'
1111
import { Scanner } from '@tailwindcss/oxide'
12+
import { realpathSync } from 'node:fs'
1213
import fs from 'node:fs/promises'
1314
import path from 'node:path'
1415
import type { Environment, Plugin, ResolvedConfig, ViteDevServer } from 'vite'
@@ -151,6 +152,64 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
151152
return result
152153
},
153154
},
155+
156+
hotUpdate({ file, modules, timestamp, server }) {
157+
// Ensure full-reloads are triggered for files that are being watched by
158+
// Tailwind but aren't part of the module graph (like PHP or HTML
159+
// files). If we don't do this, then changes to those files won't
160+
// trigger a reload at all since Vite doesn't know about them.
161+
{
162+
// It's a little bit confusing, because due to the `addWatchFile`
163+
// calls, it _is_ part of the module graph but nothing is really
164+
// handling those files. These modules typically have an id of
165+
// undefined and/or have a type of 'asset'.
166+
//
167+
// If we call `addWatchFile` on a file that is part of the actual
168+
// module graph, then we will see a module for it with a type of `js`
169+
// and a type of `asset`. We are only interested if _all_ of them are
170+
// missing an id and/or have a type of 'asset', which is a strong
171+
// signal that the changed file is not being handled by Vite or any of
172+
// the plugins.
173+
//
174+
// Note: in Vite v7.0.6 the modules here will have a type of `js`, not
175+
// 'asset'. But it will also have a `HARD_INVALIDATED` state and will
176+
// do a full page reload already.
177+
let isExternalFile = modules.every((mod) => mod.type === 'asset' || mod.id === undefined)
178+
if (!isExternalFile) return
179+
180+
for (let env of new Set([this.environment.name, 'client'])) {
181+
let roots = rootsByEnv.get(env)
182+
if (roots.size === 0) continue
183+
184+
// If the file is not being watched by any of the roots, then we can
185+
// skip the reload since it's not relevant to Tailwind CSS.
186+
if (!isScannedFile(file, modules, roots)) {
187+
continue
188+
}
189+
190+
// https://vite.dev/changes/hotupdate-hook#migration-guide
191+
let invalidatedModules = new Set<vite.EnvironmentModuleNode>()
192+
for (let mod of modules) {
193+
this.environment.moduleGraph.invalidateModule(
194+
mod,
195+
invalidatedModules,
196+
timestamp,
197+
true,
198+
)
199+
}
200+
201+
if (env === this.environment.name) {
202+
this.environment.hot.send({ type: 'full-reload' })
203+
} else if (server.hot.send) {
204+
server.hot.send({ type: 'full-reload' })
205+
} else if (server.ws.send) {
206+
server.ws.send({ type: 'full-reload' })
207+
}
208+
209+
return []
210+
}
211+
}
212+
},
154213
},
155214

156215
{
@@ -271,6 +330,10 @@ class Root {
271330
private customJsResolver: (id: string, base: string) => Promise<string | false | undefined>,
272331
) {}
273332

333+
get scannedFiles() {
334+
return this.scanner?.files ?? []
335+
}
336+
274337
// Generate the CSS for the root file. This can return false if the file is
275338
// not considered a Tailwind root. When this happened, the root can be GCed.
276339
public async generate(
@@ -452,3 +515,54 @@ class Root {
452515
return false
453516
}
454517
}
518+
519+
function isScannedFile(
520+
file: string,
521+
modules: vite.EnvironmentModuleNode[],
522+
roots: Map<string, Root>,
523+
) {
524+
let seen = new Set()
525+
let q = [...modules]
526+
let checks = {
527+
file,
528+
get realpath() {
529+
try {
530+
let realpath = realpathSync(file)
531+
Object.defineProperty(checks, 'realpath', { value: realpath })
532+
return realpath
533+
} catch {
534+
return null
535+
}
536+
},
537+
}
538+
539+
while (q.length > 0) {
540+
let module = q.shift()!
541+
if (seen.has(module)) continue
542+
seen.add(module)
543+
544+
if (module.id) {
545+
let root = roots.get(module.id)
546+
547+
if (root) {
548+
// If the file is part of the scanned files for this root, then we know
549+
// for sure that it's being watched by any of the Tailwind CSS roots. It
550+
// doesn't matter which root it is since it's only used to know whether
551+
// we should trigger a full reload or not.
552+
if (
553+
root.scannedFiles.includes(checks.file) ||
554+
(checks.realpath && root.scannedFiles.includes(checks.realpath))
555+
) {
556+
return true
557+
}
558+
}
559+
}
560+
561+
// Keep walking up the tree until we find a root.
562+
for (let importer of module.importers) {
563+
q.push(importer)
564+
}
565+
}
566+
567+
return false
568+
}

0 commit comments

Comments
 (0)