diff --git a/CHANGELOG.md b/CHANGELOG.md index e50fcd52932a..97aef106389a 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 - 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 `` 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 diff --git a/integrations/vite/index.test.ts b/integrations/vite/index.test.ts index 34940c74e178..cf12e1ce2dfa 100644 --- a/integrations/vite/index.test.ts +++ b/integrations/vite/index.test.ts @@ -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` + + + + + +
+ + + + `, + '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`, { diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index 9796ff0d013e..3b529ab51498 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -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 = { /** @@ -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