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:
parent
6def8205b5
commit
50daebded0
4 changed files with 201 additions and 0 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
164
integrations/vite/bundled-dev.test.ts
Normal file
164
integrations/vite/bundled-dev.test.ts
Normal 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([])
|
||||
},
|
||||
)
|
||||
28
packages/@tailwindcss-vite/src/index.test.ts
Normal file
28
packages/@tailwindcss-vite/src/index.test.ts
Normal 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()
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue