Skip to content

Commit 50daebd

Browse files
lazergRobinMalfait
andauthored
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>
1 parent 6def820 commit 50daebd

4 files changed

Lines changed: 201 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919
- Fix standalone declarations in `@scope`, wrap them in `:where(:scope)` ([#20369](https://github.com/tailwindlabs/tailwindcss/pull/20369))
2020
- 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))
2121
- 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))
22+
- Prevent `@tailwindcss/vite` from crashing on every edit under Vite's experimental `bundledDev` mode ([#20379](https://github.com/tailwindlabs/tailwindcss/pull/20379))
2223

2324
## [4.3.3] - 2026-07-16
2425

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import path from 'node:path'
2+
import { candidate, css, html, json, retryAssertion, test, ts, txt, yaml } from '../utils'
3+
4+
// Vite's experimental `bundledDev` mode invokes the `hotUpdate` hook without a
5+
// `server`, which used to crash the plugin on every file edit.
6+
//
7+
// - https://github.com/tailwindlabs/tailwindcss/issues/20378
8+
// - https://vite.dev/blog/announcing-vite8-1#experimental-bundled-dev-mode
9+
test(
10+
'dev mode (experimental `bundledDev`)',
11+
{
12+
fs: {
13+
'package.json': json`{}`,
14+
'pnpm-workspace.yaml': yaml`
15+
#
16+
packages:
17+
- project-a
18+
`,
19+
'project-a/package.json': txt`
20+
{
21+
"type": "module",
22+
"dependencies": {
23+
"@tailwindcss/vite": "workspace:^",
24+
"tailwindcss": "workspace:^"
25+
},
26+
"devDependencies": {
27+
"vite": "^8.1"
28+
}
29+
}
30+
`,
31+
'project-a/vite.config.ts': ts`
32+
import tailwindcss from '@tailwindcss/vite'
33+
import { defineConfig } from 'vite'
34+
35+
export default defineConfig({
36+
experimental: {
37+
bundledDev: true,
38+
},
39+
plugins: [tailwindcss()],
40+
})
41+
`,
42+
'project-a/index.html': html`
43+
<head>
44+
<link rel="stylesheet" href="./src/index.css" />
45+
</head>
46+
<body>
47+
<div class="underline">Hello, world!</div>
48+
</body>
49+
`,
50+
'project-a/src/index.css': css`
51+
@reference 'tailwindcss/theme';
52+
@import 'tailwindcss/utilities';
53+
@source '../../project-b/src/**/*.html';
54+
`,
55+
'project-b/src/index.html': html`
56+
<div class="flex" />
57+
`,
58+
},
59+
},
60+
async ({ root, spawn, fs, expect }) => {
61+
let process = await spawn('pnpm vite dev', {
62+
cwd: path.join(root, 'project-a'),
63+
})
64+
65+
// `hotUpdate` errors don't kill the dev server, they are only printed to
66+
// stderr. Track them explicitly so a crash fails the test even if the
67+
// rebuild happens to succeed anyway.
68+
let pluginErrors: string[] = []
69+
process.onStderr((message) => {
70+
if (message.includes('@tailwindcss/vite')) pluginErrors.push(message)
71+
return false
72+
})
73+
74+
await process.onStdout((m) => m.includes('ready in'))
75+
76+
let url = ''
77+
await process.onStdout((m) => {
78+
let match = /Local:\s*(http.*)\//.exec(m)
79+
if (match) url = match[1]
80+
return Boolean(url)
81+
})
82+
83+
// In `bundledDev` mode the stylesheet is not served separately. Instead the
84+
// generated CSS is embedded in the bundled JS and injected at runtime, so
85+
// extract the bundle from the served HTML. While the bundle is being built,
86+
// Vite serves a temporary fallback page instead.
87+
//
88+
// The bundle can be split into multiple chunks (e.g. the HMR client runtime
89+
// and the app itself), and the chunk containing the CSS is not always the
90+
// first one, so fetch every referenced script and stylesheet.
91+
async function fetchBundledStyles(): Promise<string> {
92+
let index = await fetch(`${url}/`)
93+
let html = await index.text()
94+
if (html.includes('__vite_is_fallback_page__')) {
95+
throw new Error('Bundling still in progress')
96+
}
97+
98+
let sources = [
99+
...html.matchAll(/<script[^>]*\ssrc="([^"]+)"/g),
100+
...html.matchAll(/<link[^>]*\srel="stylesheet"[^>]*\shref="([^"]+)"/g),
101+
].map((match) => match[1])
102+
if (sources.length === 0) throw new Error(`No scripts or stylesheets found in:\n\n${html}`)
103+
104+
let contents = await Promise.all(
105+
sources.map(async (src) => {
106+
let response = await fetch(new URL(src, `${url}/`))
107+
return await response.text()
108+
}),
109+
)
110+
return contents.join('\n')
111+
}
112+
113+
await retryAssertion(async () => {
114+
let styles = await fetchBundledStyles()
115+
expect(styles).toContain(candidate`underline`)
116+
expect(styles).toContain(candidate`flex`)
117+
})
118+
119+
// A file change is only picked up once rolldown's watcher is fully set up,
120+
// which races with the first write on slow machines. Retried writes must
121+
// also produce _different_ content each time, because rolldown compares
122+
// module contents and treats a write of identical content as a no-op — so a
123+
// lost first change could never be recovered by re-writing the same file.
124+
let iteration = 0
125+
126+
await retryAssertion(async () => {
127+
// Updates are additive and cause new candidates to be added.
128+
await fs.write(
129+
'project-a/index.html',
130+
html`
131+
<head>
132+
<link rel="stylesheet" href="./src/index.css" />
133+
</head>
134+
<body>
135+
<div class="underline m-2">Hello, world! (${++iteration})</div>
136+
</body>
137+
`,
138+
)
139+
140+
let styles = await fetchBundledStyles()
141+
expect(styles).toContain(candidate`underline`)
142+
expect(styles).toContain(candidate`flex`)
143+
expect(styles).toContain(candidate`m-2`)
144+
})
145+
146+
await retryAssertion(async () => {
147+
// Manually added `@source`s are watched and trigger a rebuild
148+
await fs.write(
149+
'project-b/src/index.html',
150+
html`
151+
<div class="flex font-bold" data-iteration="${++iteration}" />
152+
`,
153+
)
154+
155+
let styles = await fetchBundledStyles()
156+
expect(styles).toContain(candidate`underline`)
157+
expect(styles).toContain(candidate`flex`)
158+
expect(styles).toContain(candidate`m-2`)
159+
expect(styles).toContain(candidate`font-bold`)
160+
})
161+
162+
expect(pluginErrors).toEqual([])
163+
},
164+
)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { expect, test } from 'vitest'
2+
import tailwindcss from './index'
3+
4+
// Vite's experimental `bundledDev` mode calls `hotUpdate` without a `server`,
5+
// so the handler must not dereference it.
6+
//
7+
// - https://github.com/vitejs/vite/discussions/22746
8+
// - https://github.com/tailwindlabs/tailwindcss/issues/20378
9+
// - https://vite.dev/blog/announcing-vite8-1#experimental-bundled-dev-mode
10+
test('hotUpdate does not crash when Vite omits the server (bundledDev)', () => {
11+
let plugin = tailwindcss().find((plugin) => plugin.name === '@tailwindcss/vite:generate:serve')!
12+
13+
let hotUpdate = plugin.hotUpdate as unknown as (options: {
14+
file: string
15+
modules: unknown[]
16+
timestamp: number
17+
server: undefined
18+
}) => unknown
19+
20+
expect(() =>
21+
hotUpdate.call(plugin, {
22+
file: '/app/template.html',
23+
modules: [{ type: 'asset', id: undefined }],
24+
timestamp: Date.now(),
25+
server: undefined,
26+
}),
27+
).not.toThrow()
28+
})

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,14 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] {
258258
},
259259

260260
hotUpdate({ file, modules, timestamp, server }) {
261+
// Vite's experimental `bundledDev` mode invokes `hotUpdate` without a
262+
// `server`, so there are no sibling environments to inspect and no
263+
// server-level `hot`/`ws` channel to reload through. Bail out early
264+
// rather than dereferencing `undefined`.
265+
//
266+
// https://github.com/tailwindlabs/tailwindcss/issues/20378
267+
if (!server) return
268+
261269
// Ensure full-reloads are triggered for files that are being watched by
262270
// Tailwind but aren't part of the module graph (like PHP or HTML
263271
// files). If we don't do this, then changes to those files won't

0 commit comments

Comments
 (0)