tailwindcss/integrations/vite/vue.test.ts
Robin Malfait bdcd7087b3
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"
/>
2026-07-15 17:49:05 +02:00

380 lines
11 KiB
TypeScript

import { stripVTControlCharacters } from 'node:util'
import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts } from '../utils'
test(
'production build',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"vue": "^3.4.37",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.2",
"@tailwindcss/vite": "workspace:^",
"vite": "^7"
}
}
`,
'vite.config.ts': ts`
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [vue(), tailwindcss()],
})
`,
'index.html': html`
<!doctype html>
<html>
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
`,
'src/main.ts': ts`
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
`,
'src/App.vue': html`
<style>
@import 'tailwindcss';
.foo {
@apply text-red-500;
}
</style>
<style scoped>
@import 'tailwindcss' reference;
:deep(.bar) {
color: red;
}
</style>
<template>
<div class="underline foo bar">Hello Vue!</div>
</template>
`,
},
},
async ({ fs, exec, expect }) => {
await exec('pnpm vite build')
let files = await fs.glob('dist/**/*.css')
expect(files).toHaveLength(1)
await fs.expectFileToContain(files[0][0], [candidate`underline`, candidate`foo`])
await fs.expectFileToContain(files[0][0], ['.bar{'])
},
)
{
const VUE_COMPONENT_COUNT = 1_000
let vueComponentsWithReferences = Object.fromEntries(
Array.from({ length: VUE_COMPONENT_COUNT }, (_, idx) => [
`src/components/Component${idx}.vue`,
html`
<template>
<div class="content-['component-${idx}']">Component ${idx}</div>
</template>
<style>
@reference '../main.css';
.component-${idx} {
@apply text-red-500;
}
</style>
`,
]),
)
let vueComponentImports = Array.from(
{ length: VUE_COMPONENT_COUNT },
(_, idx) => `import Component${idx} from './components/Component${idx}.vue'`,
).join('\n')
let vueComponentUsages = Array.from(
{ length: VUE_COMPONENT_COUNT },
(_, idx) => `<Component${idx} class="component-${idx}" />`,
).join('\n')
test(
'production build with many Vue style blocks referencing the main stylesheet',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"vue": "^3.4.37",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.2",
"@tailwindcss/vite": "workspace:^",
"vite": "^7"
}
}
`,
'vite.config.ts': ts`
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [vue(), tailwindcss()],
})
`,
'index.html': html`
<!doctype html>
<html>
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
`,
'src/main.css': css`@import 'tailwindcss';`,
'src/main.ts': ts`
import { createApp } from 'vue'
import './main.css'
import App from './App.vue'
createApp(App).mount('#app')
`,
'src/App.vue': html`
<script setup>
${vueComponentImports}
</script>
<template>${vueComponentUsages}</template>
`,
...vueComponentsWithReferences,
},
},
async ({ fs, exec, expect }) => {
await exec('pnpm vite build')
let files = await fs.glob('dist/**/*.css')
expect(files).toHaveLength(1)
await fs.expectFileToContain(files[0][0], [
candidate`content-['component-0']`,
candidate`component-0`,
candidate`content-['component-99']`,
candidate`component-99`,
candidate`content-['component-999']`,
candidate`component-999`,
])
},
)
}
test(
'error when using `@apply` without `@reference`',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"vue": "^3.4.37",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.2",
"@tailwindcss/vite": "workspace:^",
"vite": "^7"
}
}
`,
'vite.config.ts': ts`
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [vue(), tailwindcss()],
})
`,
'index.html': html`
<!doctype html>
<html>
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
`,
'src/main.ts': ts`
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
`,
'src/App.vue': html`
<template>
<div class="foo">Hello Vue!</div>
</template>
<style>
.foo {
@apply text-red-500;
}
</style>
`,
},
},
async ({ exec, expect }) => {
expect.assertions(1)
try {
await exec('pnpm vite build', {}, { ignoreStdErr: true })
} catch (error) {
let [, message] =
/error during build:([\s\S]*?)file:/g.exec(
stripVTControlCharacters(error.message.replace(/\r?\n/g, '\n')),
) ?? []
expect(message.trim()).toMatchInlineSnapshot(
`"[@tailwindcss/vite:generate:build] Cannot apply unknown utility class \`text-red-500\`. Are you using CSS modules or similar and missing \`@reference\`? https://tailwindcss.com/docs/functions-and-directives#reference-directive"`,
)
}
},
)
// https://github.com/tailwindlabs/tailwindcss/issues/20320
test(
'editing a scanned `.vue` file that is not loaded as a module does not trigger a full reload',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"vue": "^3.4.37",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6",
"@tailwindcss/vite": "workspace:^",
"vite": "^8"
}
}
`,
'vite.config.ts': ts`
import fs from 'node:fs'
import path from 'node:path'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
vue(),
tailwindcss(),
{
// Log update and full-reload HMR payloads to a file so the
// test can assert on them. Custom events are not logged
// because \`@vitejs/plugin-vue\` sends a \`file-changed\` event
// for every file change, including changes to the log file
// itself, which would cause an infinite feedback loop.
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) => {
if (payload.type === 'update' || payload.type === 'full-reload') {
fs.appendFileSync(logFile, JSON.stringify(payload) + '\\n')
}
return send(payload)
}
}
},
},
],
})
`,
'index.html': html`
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
`,
'src/index.css': css`@import 'tailwindcss';`,
'src/main.ts': ts`
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
`,
'src/App.vue': html`
<template>
<div class="content-['src/App.vue']">Hello Vue!</div>
</template>
`,
// This file is scanned by Tailwind but never imported, so it is not
// part of the loaded module graph (e.g. a lazy route that hasn't been
// visited yet)
'src/LazyRoute.vue': html`
<template>
<div class="content-['src/LazyRoute.vue']">Lazy</div>
</template>
`,
},
},
async ({ spawn, fs, 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, '/index.html')
expect(styles).toContain(candidate`content-['src/App.vue']`)
expect(styles).toContain(candidate`content-['src/LazyRoute.vue']`)
})
// Load `main.ts` and `App.vue` as real modules, like a browser visiting
// the page would
await fetch(`${url}/src/main.ts`)
await fetch(`${url}/src/App.vue`)
// Changing the scanned but unloaded `.vue` file should not trigger a
// full reload, but new classes should still apply
await fs.write(
'src/LazyRoute.vue',
html`
<template>
<div class="content-['updated:src/LazyRoute.vue']">Lazy</div>
</template>
`,
)
await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`content-['updated:src/LazyRoute.vue']`)
})
expect(await fs.read('hmr.log')).not.toContain('full-reload')
},
)