Skip to content

Commit bdcd708

Browse files
authored
Don't trigger a full page reload for scanned files that Vite processes as modules (#20336)
This PR fixes an issue where editing a scanned file that Vite (or one of its plugins) can process as a module, but that isn't currently loaded, caused `@tailwindcss/vite` to force a full page reload, throwing away all client state. The `hotUpdate` hook has a fallback that sends a `full-reload` for files that Tailwind scans but that Vite knows nothing about (e.g. `.php` or `.blade.php` templates rendered by a backend). Without it, edits to those files wouldn't refresh the page at all. To detect those files we check whether every module for the changed file is an `asset` and/or has no id, because the scanner's `addWatchFile` calls create exactly such placeholder nodes for every scanned file. The problem is that a source file that Vite _can_ process, but that isn't loaded yet, looks exactly the same. The realistic way to get into that state is code splitting: with route-level splitting (e.g. `React.lazy`, TanStack Router's `autoCodeSplitting`, lazy routes in `vue-router`), every component behind an un-visited split boundary only exists as a scan placeholder in the module graph. Editing any of them reloaded the whole app. The same happens for component stylesheets that a framework plugin compiles into the component (e.g. Angular via Analog), which never show up as their own module. A full reload is never useful for these files: if the file is loaded, Vite's own HMR handles it, and if it isn't loaded, reloading the page won't load it either. Any new candidates still apply through the regular `css-update` flow because the file is registered via `addWatchFile`. So instead, we now skip the fallback when the changed file is handled by Vite's module pipeline: - The file exists as a real module in another environment (e.g. an SSR-only module). This check already existed and is folded into the same code path. - The file is part of the JS/TS or CSS families, which Vite transforms natively. - For any other file type (e.g. `.vue`, `.svelte`, or `.md` with an SSG plugin), a file with the same extension exists as a real module in some environment's module graph, then a plugin does handle this file type and the changed file just isn't loaded (yet). External templates like `.php` files still trigger a full reload exactly like before. Fixes: #20320 Fixes: #19903 Closes: #20323 ## Test plan 1. Added integration tests to ensure extensions handled by default rely on HMR 2. Added integration tests to make sure that unknown extensions that have been handled already will also use HMR 3. Manually tested that changing a `.php` file still triggers a `full-reload` 4. Manually tested the reproduction where local client state isn't thrown away <img width="594" height="100" alt="file-14a86a90a1e4b810c2b80338ea688572" src="https://github.com/user-attachments/assets/a4507502-4a5d-43ee-93b9-14c793a25891" /> <img width="1122" height="1376" alt="file-a5121da2ad77b95fdd1703560ef1ff41" src="https://github.com/user-attachments/assets/cc5c67b0-b5ad-481f-8823-a3266d75357d" />
1 parent 7811d74 commit bdcd708

4 files changed

Lines changed: 431 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2323
- 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))
2424
- Prevent `@tailwindcss/upgrade` from rewriting ignored files when run from a subdirectory ([#20328](https://github.com/tailwindlabs/tailwindcss/issues/20328))
2525
- 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))
26+
- 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))
2627

2728
## [4.3.2] - 2026-06-26
2829

integrations/vite/index.test.ts

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,233 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
584584
},
585585
)
586586

587+
// https://github.com/tailwindlabs/tailwindcss/issues/20320
588+
// https://github.com/tailwindlabs/tailwindcss/issues/19903
589+
test(
590+
'editing scanned files that Vite can process as modules does not trigger a full reload',
591+
{
592+
fs: {
593+
'package.json': json`{}`,
594+
'pnpm-workspace.yaml': yaml`
595+
#
596+
packages:
597+
- project-a
598+
`,
599+
'project-a/package.json': json`
600+
{
601+
"type": "module",
602+
"dependencies": {
603+
"@tailwindcss/vite": "workspace:^",
604+
"tailwindcss": "workspace:^"
605+
},
606+
"devDependencies": {
607+
${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''}
608+
"vite": "^8"
609+
}
610+
}
611+
`,
612+
'project-a/vite.config.ts': ts`
613+
import fs from 'node:fs'
614+
import path from 'node:path'
615+
import tailwindcss from '@tailwindcss/vite'
616+
import { defineConfig } from 'vite'
617+
618+
export default defineConfig({
619+
css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"},
620+
build: { cssMinify: false },
621+
plugins: [
622+
tailwindcss(),
623+
{
624+
// A plugin that processes a custom file type into a JS
625+
// module, similar to e.g. \`.vue\` or \`.svelte\` files
626+
name: 'custom-file-type',
627+
transform(code, id) {
628+
if (id.endsWith('.custom')) {
629+
return { code: 'export default ' + JSON.stringify(code), map: null }
630+
}
631+
},
632+
},
633+
{
634+
// Log all HMR payloads to a file so the test can assert on
635+
// them
636+
name: 'hmr-wiretap',
637+
configureServer(server) {
638+
let logFile = path.resolve('hmr.log')
639+
fs.writeFileSync(logFile, '')
640+
for (let environment of Object.values(server.environments)) {
641+
let send = environment.hot.send.bind(environment.hot)
642+
environment.hot.send = (payload) => {
643+
fs.appendFileSync(logFile, JSON.stringify(payload) + '\\n')
644+
return send(payload)
645+
}
646+
}
647+
},
648+
},
649+
],
650+
})
651+
`,
652+
'project-a/index.html': html`
653+
<html>
654+
<head>
655+
<link rel="stylesheet" href="./src/index.css" />
656+
</head>
657+
<body>
658+
<div id="app"></div>
659+
<script type="module" src="./src/main.ts"></script>
660+
</body>
661+
</html>
662+
`,
663+
'project-a/src/main.ts': ts`
664+
import compA from './comp-a.custom'
665+
import snippet from './snippet.php?raw'
666+
console.log(compA, snippet)
667+
`,
668+
'project-a/src/snippet.php': html`
669+
<div
670+
class="content-['src/snippet.php']"
671+
></div>
672+
`,
673+
'project-a/src/comp-a.custom': html`
674+
<div
675+
class="content-['src/comp-a.custom']"
676+
></div>
677+
`,
678+
'project-a/src/comp-b.custom': html`
679+
<div
680+
class="content-['src/comp-b.custom']"
681+
></div>
682+
`,
683+
'project-a/src/lazy.tsx': jsx`
684+
export default function Lazy() {
685+
return <div className="content-['src/lazy.tsx']" />
686+
}
687+
`,
688+
'project-a/src/unimported.css': css`
689+
.unimported {
690+
color: red;
691+
}
692+
`,
693+
'project-a/src/index.css': css`
694+
@import 'tailwindcss';
695+
@source '../../project-b/**/*.php';
696+
`,
697+
'project-b/src/index.php': html`
698+
<div
699+
class="content-['project-b/src/index.php']"
700+
></div>
701+
`,
702+
},
703+
},
704+
async ({ root, spawn, fs, expect }) => {
705+
let process = await spawn('pnpm vite dev', {
706+
cwd: path.join(root, 'project-a'),
707+
})
708+
await process.onStdout((m) => m.includes('ready in'))
709+
710+
let url = ''
711+
await process.onStdout((m) => {
712+
let match = /Local:\s*(http.*)\//.exec(m)
713+
if (match) url = match[1]
714+
return Boolean(url)
715+
})
716+
717+
await retryAssertion(async () => {
718+
let styles = await fetchStyles(url, '/index.html')
719+
expect(styles).toContain(candidate`content-['src/lazy.tsx']`)
720+
expect(styles).toContain(candidate`content-['src/comp-b.custom']`)
721+
expect(styles).toContain(candidate`content-['project-b/src/index.php']`)
722+
})
723+
724+
// Load `main.ts`, `comp-a.custom`, and `snippet.php?raw` as real
725+
// modules, like a browser visiting the page would
726+
await fetch(`${url}/src/main.ts`)
727+
await fetch(`${url}/src/comp-a.custom?import`)
728+
await fetch(`${url}/src/snippet.php?raw`)
729+
730+
// Changing a scanned `.tsx` file that is not part of the module graph
731+
// (e.g. a lazily-loaded route that hasn't been visited yet) should not
732+
// trigger a full reload, but new classes should still apply
733+
//
734+
// https://github.com/tailwindlabs/tailwindcss/issues/20320
735+
{
736+
await fs.write(
737+
'project-a/src/lazy.tsx',
738+
jsx`
739+
export default function Lazy() {
740+
return <div className="content-['updated:src/lazy.tsx']" />
741+
}
742+
`,
743+
)
744+
745+
await retryAssertion(async () => {
746+
let styles = await fetchStyles(url, '/index.html')
747+
expect(styles).toContain(candidate`content-['updated:src/lazy.tsx']`)
748+
})
749+
expect(await fs.read('project-a/hmr.log')).not.toContain('full-reload')
750+
}
751+
752+
// The same holds for a custom file type as long as some file of the
753+
// same type was processed as a module before
754+
{
755+
await fs.write(
756+
'project-a/src/comp-b.custom',
757+
html`<div class="content-['updated:src/comp-b.custom']"></div>`,
758+
)
759+
760+
await retryAssertion(async () => {
761+
let styles = await fetchStyles(url, '/index.html')
762+
expect(styles).toContain(candidate`content-['updated:src/comp-b.custom']`)
763+
})
764+
expect(await fs.read('project-a/hmr.log')).not.toContain('full-reload')
765+
}
766+
767+
// Changing a scanned stylesheet that is not part of the module graph
768+
// (e.g. a component stylesheet that a framework plugin compiles into
769+
// the component) should not trigger a full reload either
770+
//
771+
// https://github.com/tailwindlabs/tailwindcss/issues/19903
772+
{
773+
let updates = (await fs.read('project-a/hmr.log')).split('"type":"update"').length
774+
775+
await fs.write(
776+
'project-a/src/unimported.css',
777+
css`
778+
.unimported {
779+
color: blue;
780+
}
781+
`,
782+
)
783+
784+
// Wait until the change was handled and an update was pushed
785+
await retryAssertion(async () => {
786+
let log = await fs.read('project-a/hmr.log')
787+
expect(log.split('"type":"update"').length).toBeGreaterThan(updates)
788+
})
789+
expect(await fs.read('project-a/hmr.log')).not.toContain('full-reload')
790+
}
791+
792+
// Changing an external file (e.g. a PHP template) should still trigger
793+
// a full reload. This must work even though `snippet.php` is part of
794+
// the module graph via the `?raw` import: a query import only pulls
795+
// the file's contents into the graph (and creates an untransformed
796+
// module node for the underlying file), it is not evidence that Vite
797+
// processes `.php` files as modules.
798+
{
799+
await fs.write(
800+
'project-b/src/index.php',
801+
html`<div class="content-['updated:project-b/src/index.php']"></div>`,
802+
)
803+
804+
await retryAssertion(async () => {
805+
expect(await fs.read('project-a/hmr.log')).toContain('full-reload')
806+
})
807+
808+
let styles = await fetchStyles(url, '/index.html')
809+
expect(styles).toContain(candidate`content-['updated:project-b/src/index.php']`)
810+
}
811+
},
812+
)
813+
587814
test(
588815
`source(none) disables looking at the module graph`,
589816
{

integrations/vite/vue.test.ts

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { stripVTControlCharacters } from 'node:util'
2-
import { candidate, css, html, json, test, ts } from '../utils'
2+
import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts } from '../utils'
33

44
test(
55
'production build',
@@ -248,3 +248,133 @@ test(
248248
}
249249
},
250250
)
251+
252+
// https://github.com/tailwindlabs/tailwindcss/issues/20320
253+
test(
254+
'editing a scanned `.vue` file that is not loaded as a module does not trigger a full reload',
255+
{
256+
fs: {
257+
'package.json': json`
258+
{
259+
"type": "module",
260+
"dependencies": {
261+
"vue": "^3.4.37",
262+
"tailwindcss": "workspace:^"
263+
},
264+
"devDependencies": {
265+
"@vitejs/plugin-vue": "^6",
266+
"@tailwindcss/vite": "workspace:^",
267+
"vite": "^8"
268+
}
269+
}
270+
`,
271+
'vite.config.ts': ts`
272+
import fs from 'node:fs'
273+
import path from 'node:path'
274+
import { defineConfig } from 'vite'
275+
import vue from '@vitejs/plugin-vue'
276+
import tailwindcss from '@tailwindcss/vite'
277+
278+
export default defineConfig({
279+
plugins: [
280+
vue(),
281+
tailwindcss(),
282+
{
283+
// Log update and full-reload HMR payloads to a file so the
284+
// test can assert on them. Custom events are not logged
285+
// because \`@vitejs/plugin-vue\` sends a \`file-changed\` event
286+
// for every file change, including changes to the log file
287+
// itself, which would cause an infinite feedback loop.
288+
name: 'hmr-wiretap',
289+
configureServer(server) {
290+
let logFile = path.resolve('hmr.log')
291+
fs.writeFileSync(logFile, '')
292+
for (let environment of Object.values(server.environments)) {
293+
let send = environment.hot.send.bind(environment.hot)
294+
environment.hot.send = (payload) => {
295+
if (payload.type === 'update' || payload.type === 'full-reload') {
296+
fs.appendFileSync(logFile, JSON.stringify(payload) + '\\n')
297+
}
298+
return send(payload)
299+
}
300+
}
301+
},
302+
},
303+
],
304+
})
305+
`,
306+
'index.html': html`
307+
<!doctype html>
308+
<html>
309+
<head>
310+
<link rel="stylesheet" href="./src/index.css" />
311+
</head>
312+
<body>
313+
<div id="app"></div>
314+
<script type="module" src="./src/main.ts"></script>
315+
</body>
316+
</html>
317+
`,
318+
'src/index.css': css`@import 'tailwindcss';`,
319+
'src/main.ts': ts`
320+
import { createApp } from 'vue'
321+
import App from './App.vue'
322+
323+
createApp(App).mount('#app')
324+
`,
325+
'src/App.vue': html`
326+
<template>
327+
<div class="content-['src/App.vue']">Hello Vue!</div>
328+
</template>
329+
`,
330+
331+
// This file is scanned by Tailwind but never imported, so it is not
332+
// part of the loaded module graph (e.g. a lazy route that hasn't been
333+
// visited yet)
334+
'src/LazyRoute.vue': html`
335+
<template>
336+
<div class="content-['src/LazyRoute.vue']">Lazy</div>
337+
</template>
338+
`,
339+
},
340+
},
341+
async ({ spawn, fs, expect }) => {
342+
let process = await spawn('pnpm vite dev')
343+
await process.onStdout((m) => m.includes('ready in'))
344+
345+
let url = ''
346+
await process.onStdout((m) => {
347+
let match = /Local:\s*(http.*)\//.exec(m)
348+
if (match) url = match[1]
349+
return Boolean(url)
350+
})
351+
352+
await retryAssertion(async () => {
353+
let styles = await fetchStyles(url, '/index.html')
354+
expect(styles).toContain(candidate`content-['src/App.vue']`)
355+
expect(styles).toContain(candidate`content-['src/LazyRoute.vue']`)
356+
})
357+
358+
// Load `main.ts` and `App.vue` as real modules, like a browser visiting
359+
// the page would
360+
await fetch(`${url}/src/main.ts`)
361+
await fetch(`${url}/src/App.vue`)
362+
363+
// Changing the scanned but unloaded `.vue` file should not trigger a
364+
// full reload, but new classes should still apply
365+
await fs.write(
366+
'src/LazyRoute.vue',
367+
html`
368+
<template>
369+
<div class="content-['updated:src/LazyRoute.vue']">Lazy</div>
370+
</template>
371+
`,
372+
)
373+
374+
await retryAssertion(async () => {
375+
let styles = await fetchStyles(url, '/index.html')
376+
expect(styles).toContain(candidate`content-['updated:src/LazyRoute.vue']`)
377+
})
378+
expect(await fs.read('hmr.log')).not.toContain('full-reload')
379+
},
380+
)

0 commit comments

Comments
 (0)