From 50daebded0a7c0fc529fe2fd5ff138cae7229bde Mon Sep 17 00:00:00 2001 From: Lazizbek Ergashev Date: Mon, 3 Aug 2026 18:10:06 +0500 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + integrations/vite/bundled-dev.test.ts | 164 +++++++++++++++++++ packages/@tailwindcss-vite/src/index.test.ts | 28 ++++ packages/@tailwindcss-vite/src/index.ts | 8 + 4 files changed, 201 insertions(+) create mode 100644 integrations/vite/bundled-dev.test.ts create mode 100644 packages/@tailwindcss-vite/src/index.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 59642fd84..3d61b76ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/integrations/vite/bundled-dev.test.ts b/integrations/vite/bundled-dev.test.ts new file mode 100644 index 000000000..959669376 --- /dev/null +++ b/integrations/vite/bundled-dev.test.ts @@ -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` + + + + +
Hello, world!
+ + `, + 'project-a/src/index.css': css` + @reference 'tailwindcss/theme'; + @import 'tailwindcss/utilities'; + @source '../../project-b/src/**/*.html'; + `, + 'project-b/src/index.html': html` +
+ `, + }, + }, + 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 { + 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(/]*\ssrc="([^"]+)"/g), + ...html.matchAll(/]*\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` + + + + +
Hello, world! (${++iteration})
+ + `, + ) + + 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` +
+ `, + ) + + 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([]) + }, +) diff --git a/packages/@tailwindcss-vite/src/index.test.ts b/packages/@tailwindcss-vite/src/index.test.ts new file mode 100644 index 000000000..ee050e833 --- /dev/null +++ b/packages/@tailwindcss-vite/src/index.test.ts @@ -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() +}) diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index bd38f5272..58c69e1eb 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -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