Prevent @tailwindcss/vite crash under Vite's experimental bundledDev (#20379)

## Summary

Under Vite's experimental `bundledDev` mode, the `hotUpdate` hook in
`@tailwindcss/vite` gets called without a `server`. Vite only passes `{
type, file, modules }` here, but the hook loops over
`Object.values(server.environments)`, so editing any file (JS, CSS, or
HTML) throws `TypeError: Cannot read properties of undefined (reading
'environments')` and the dev server build fails.

The fix returns early when `server` is missing. Those environment loops
only look at environments other than the current one, and the
server-level `hot`/`ws` reload channels don't exist in this mode, so
bailing out leaves the classic (non-`bundledDev`) dev path untouched.

Fixes #20378

## Test plan

- Added a unit test that calls `hotUpdate` without a `server` and checks
it doesn't throw. It fails on the current code and passes with the
guard.
- Reproduced with a Vite 8 project using `experimental.bundledDev:
true`: before the change, editing any JS/CSS/HTML file crashed the dev
server; after it, edits work.
- `pnpm run test` and the `@tailwindcss/vite` integration suite both
pass.


[ci-all]

---------

Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
This commit is contained in:
Lazizbek Ergashev 2026-08-03 18:10:06 +05:00 • committed by GitHub
parent 6def8205b5
commit 50daebded0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 201 additions and 0 deletions

View file

@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix standalone declarations in `@scope`, wrap them in `:where(:scope)` ([#20369](https://github.com/tailwindlabs/tailwindcss/pull/20369))
- Always emit a space for empty fallback values in CSS variables (e.g. `var(--tw-blur,)` → `var(--tw-blur, )`) ([#20373](https://github.com/tailwindlabs/tailwindcss/pull/20373))
- Canonicalization: convert arbitrary breakpoint and container query variants to named equivalents (e.g. `max-[64rem]` → `max-lg`) ([#20380](https://github.com/tailwindlabs/tailwindcss/pull/20380))
- Prevent `@tailwindcss/vite` from crashing on every edit under Vite's experimental `bundledDev` mode ([#20379](https://github.com/tailwindlabs/tailwindcss/pull/20379))
## [4.3.3] - 2026-07-16

View file

@ -0,0 +1,164 @@
import path from 'node:path'
import { candidate, css, html, json, retryAssertion, test, ts, txt, yaml } from '../utils'
// Vite's experimental `bundledDev` mode invokes the `hotUpdate` hook without a
// `server`, which used to crash the plugin on every file edit.
//
// - https://github.com/tailwindlabs/tailwindcss/issues/20378
// - https://vite.dev/blog/announcing-vite8-1#experimental-bundled-dev-mode
test(
'dev mode (experimental `bundledDev`)',
{
fs: {
'package.json': json`{}`,
'pnpm-workspace.yaml': yaml`
#
packages:
- project-a
`,
'project-a/package.json': txt`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^8.1"
}
}
`,
'project-a/vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
experimental: {
bundledDev: true,
},
plugins: [tailwindcss()],
})
`,
'project-a/index.html': html`
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="underline">Hello, world!</div>
</body>
`,
'project-a/src/index.css': css`
@reference 'tailwindcss/theme';
@import 'tailwindcss/utilities';
@source '../../project-b/src/**/*.html';
`,
'project-b/src/index.html': html`
<div class="flex" />
`,
},
},
async ({ root, spawn, fs, expect }) => {
let process = await spawn('pnpm vite dev', {
cwd: path.join(root, 'project-a'),
})
// `hotUpdate` errors don't kill the dev server, they are only printed to
// stderr. Track them explicitly so a crash fails the test even if the
// rebuild happens to succeed anyway.
let pluginErrors: string[] = []
process.onStderr((message) => {
if (message.includes('@tailwindcss/vite')) pluginErrors.push(message)
return false
})
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)
})
// In `bundledDev` mode the stylesheet is not served separately. Instead the
// generated CSS is embedded in the bundled JS and injected at runtime, so
// extract the bundle from the served HTML. While the bundle is being built,
// Vite serves a temporary fallback page instead.
//
// The bundle can be split into multiple chunks (e.g. the HMR client runtime
// and the app itself), and the chunk containing the CSS is not always the
// first one, so fetch every referenced script and stylesheet.
async function fetchBundledStyles(): Promise<string> {
let index = await fetch(`${url}/`)
let html = await index.text()
if (html.includes('__vite_is_fallback_page__')) {
throw new Error('Bundling still in progress')
}
let sources = [
...html.matchAll(/<script[^>]*\ssrc="([^"]+)"/g),
...html.matchAll(/<link[^>]*\srel="stylesheet"[^>]*\shref="([^"]+)"/g),
].map((match) => match[1])
if (sources.length === 0) throw new Error(`No scripts or stylesheets found in:\n\n${html}`)
let contents = await Promise.all(
sources.map(async (src) => {
let response = await fetch(new URL(src, `${url}/`))
return await response.text()
}),
)
return contents.join('\n')
}
await retryAssertion(async () => {
let styles = await fetchBundledStyles()
expect(styles).toContain(candidate`underline`)
expect(styles).toContain(candidate`flex`)
})
// A file change is only picked up once rolldown's watcher is fully set up,
// which races with the first write on slow machines. Retried writes must
// also produce _different_ content each time, because rolldown compares
// module contents and treats a write of identical content as a no-op — so a
// lost first change could never be recovered by re-writing the same file.
let iteration = 0
await retryAssertion(async () => {
// Updates are additive and cause new candidates to be added.
await fs.write(
'project-a/index.html',
html`
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="underline m-2">Hello, world! (${++iteration})</div>
</body>
`,
)
let styles = await fetchBundledStyles()
expect(styles).toContain(candidate`underline`)
expect(styles).toContain(candidate`flex`)
expect(styles).toContain(candidate`m-2`)
})
await retryAssertion(async () => {
// Manually added `@source`s are watched and trigger a rebuild
await fs.write(
'project-b/src/index.html',
html`
<div class="flex font-bold" data-iteration="${++iteration}" />
`,
)
let styles = await fetchBundledStyles()
expect(styles).toContain(candidate`underline`)
expect(styles).toContain(candidate`flex`)
expect(styles).toContain(candidate`m-2`)
expect(styles).toContain(candidate`font-bold`)
})
expect(pluginErrors).toEqual([])
},
)

View file

@ -0,0 +1,28 @@
import { expect, test } from 'vitest'
import tailwindcss from './index'
// Vite's experimental `bundledDev` mode calls `hotUpdate` without a `server`,
// so the handler must not dereference it.
//
// - https://github.com/vitejs/vite/discussions/22746
// - https://github.com/tailwindlabs/tailwindcss/issues/20378
// - https://vite.dev/blog/announcing-vite8-1#experimental-bundled-dev-mode
test('hotUpdate does not crash when Vite omits the server (bundledDev)', () => {
let plugin = tailwindcss().find((plugin) => plugin.name === '@tailwindcss/vite:generate:serve')!
let hotUpdate = plugin.hotUpdate as unknown as (options: {
file: string
modules: unknown[]
timestamp: number
server: undefined
}) => unknown
expect(() =>
hotUpdate.call(plugin, {
file: '/app/template.html',
modules: [{ type: 'asset', id: undefined }],
timestamp: Date.now(),
server: undefined,
}),
).not.toThrow()
})

View file

@ -258,6 +258,14 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
},
hotUpdate({ file, modules, timestamp, server }) {
// Vite's experimental `bundledDev` mode invokes `hotUpdate` without a
// `server`, so there are no sibling environments to inspect and no
// server-level `hot`/`ws` channel to reload through. Bail out early
// rather than dereferencing `undefined`.
//
// https://github.com/tailwindlabs/tailwindcss/issues/20378
if (!server) return
// Ensure full-reloads are triggered for files that are being watched by
// Tailwind but aren't part of the module graph (like PHP or HTML
// files). If we don't do this, then changes to those files won't