Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Ensure CSS nesting is handled even when Lightning CSS isn't run, like in `@tailwindcss/browser` and Tailwind Play ([#20124](https://github.com/tailwindlabs/tailwindcss/pull/20124))
- Prevent achromatic theme colors from shifting hue in `color-mix(…)` with polar color spaces like `oklch` ([#19830](https://github.com/tailwindlabs/tailwindcss/issues/19830))
- Ensure `--spacing(0)` is optimized to `0px` instead of `0` so it remains a `<length>` when used in `calc(…)` ([#20319](https://github.com/tailwindlabs/tailwindcss/pull/20319))
- Prevent `@tailwindcss/vite` from forcing a full page reload when editing a scanned JS/TS file that is not part of the loaded module graph, like a lazy route or an unvisited chunk ([#20323](https://github.com/tailwindlabs/tailwindcss/pull/20323))

## [4.3.2] - 2026-06-26

Expand Down
122 changes: 122 additions & 0 deletions integrations/vite/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,128 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
},
)

test(
'scanned module files that are not loaded do not trigger a full reload',
{
fs: {
'package.json': json`{}`,
'pnpm-workspace.yaml': yaml`
#
packages:
- project-a
`,
'project-a/package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''}
"vite": "^8"
}
}
`,
'project-a/vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'

export default defineConfig({
css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"},
build: { cssMinify: false },
plugins: [
tailwindcss(),
{
// Log every payload sent over the HMR channels so the test
// can observe (the absence of) full reloads
name: 'hot-send-logger',
configureServer(server) {
for (let environment of Object.values(server.environments)) {
let send = environment.hot.send.bind(environment.hot)
environment.hot.send = (...args) => {
console.log('HOT_SEND ' + JSON.stringify(args[0]))
return send(...args)
}
}
},
},
],
})
`,
'project-a/index.html': 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>
`,
'project-a/src/main.ts': ts`import { classes } from './app'`,
'project-a/src/app.ts': ts`export let classes = "content-['src/app.ts']"`,

// This file is scanned by Tailwind but never imported, so it is not
// part of the loaded module graph (e.g. a lazy route or an unvisited
// chunk)
'project-a/src/unloaded.ts': ts`export let classes = "content-['src/unloaded.ts']"`,
'project-a/src/index.css': css`@import 'tailwindcss';`,
},
},
async ({ root, spawn, fs, expect }) => {
let process = await spawn('pnpm vite dev', {
cwd: path.join(root, 'project-a'),
})
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/unloaded.ts']`)
})

// Flush all messages so that we can be sure the next messages are from
// the file change we're about to make
process.flush()

// Changing a scanned but not loaded module file should regenerate the
// CSS through a regular CSS update instead of a full reload
{
await fs.write(
'project-a/src/unloaded.ts',
ts`export let classes = "content-['updated:src/unloaded.ts']"`,
)

let payload = ''
await process.onStdout((m) => {
if (!m.includes('HOT_SEND')) return false
if (m.includes('full-reload')) {
payload = 'full-reload'
return true
}
if (m.includes('"type":"update"')) {
payload = 'update'
return true
}
return false
})
expect(payload).toBe('update')

// Ensure the styles were regenerated with the new content
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`content-['updated:src/unloaded.ts']`)
}
},
)

test(
`source(none) disables looking at the module graph`,
{
Expand Down
9 changes: 9 additions & 0 deletions packages/@tailwindcss-vite/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ 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_MODULE_RE = /\.[cm]?[jt]sx?$/

export type PluginOptions = {
/**
Expand Down Expand Up @@ -277,6 +278,14 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
modules.every((mod) => mod.type === 'asset' || mod.id === undefined)
if (!isExternalFile) return

// JS and TS source files are handled by Vite itself whenever they
// are loaded. If one only shows up as an external file here, it
// means it is scanned by Tailwind but not currently loaded as a
// module (e.g. a lazy route or an unvisited chunk). New candidates
// in the file are picked up through the regular CSS update, so a
// full reload is not needed and would only throw away client state.
if (JS_MODULE_RE.test(file)) return

// Skip if the module exists in other environments. SSR framework has
// its own server side hmr/reload mechanism when handling server
// only modules. See https://v6.vite.dev/guide/migration.html
Expand Down