diff --git a/CHANGELOG.md b/CHANGELOG.md index 22868c5c4863..eb128f4d539e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - _Experimental_: Add `user-valid` and `user-invalid` variants ([#12370](https://github.com/tailwindlabs/tailwindcss/pull/12370)) +- Vite: Add a new `scanner` option to disable module-graph based scanning ([#16425](https://github.com/tailwindlabs/tailwindcss/pull/16425)) ### Fixed @@ -22,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Export backwards compatible config and plugin types from `tailwindcss/plugin` ([#16505](https://github.com/tailwindlabs/tailwindcss/pull/16505)) - Upgrade: Report errors when updating dependencies ([#16504](https://github.com/tailwindlabs/tailwindcss/pull/16504)) - Upgrade: Ensure a `darkMode` JS config setting with block syntax converts to use `@slot` ([#16507](https://github.com/tailwindlabs/tailwindcss/pull/16507)) +- Vite: Ensure that Astro builds with client-only components are always scanned ([#16425](https://github.com/tailwindlabs/tailwindcss/pull/16425)) ## [4.0.6] - 2025-02-10 diff --git a/integrations/vite/astro.test.ts b/integrations/vite/astro.test.ts index 5b7407e4714b..81db3e82c6d5 100644 --- a/integrations/vite/astro.test.ts +++ b/integrations/vite/astro.test.ts @@ -1,4 +1,4 @@ -import { candidate, fetchStyles, html, json, retryAssertion, test, ts } from '../utils' +import { candidate, fetchStyles, html, js, json, retryAssertion, test, ts } from '../utils' test( 'dev mode', @@ -19,11 +19,7 @@ test( import { defineConfig } from 'astro/config' // https://astro.build/config - export default defineConfig({ - vite: { - plugins: [tailwindcss()], - }, - }) + export default defineConfig({ vite: { plugins: [tailwindcss()] } }) `, 'src/pages/index.astro': html`
Hello, world!
@@ -70,3 +66,58 @@ test( }) }, ) + +test( + 'build mode', + { + fs: { + 'package.json': json` + { + "type": "module", + "dependencies": { + "astro": "^4.15.2", + "react": "^19", + "react-dom": "^19", + "@astrojs/react": "^4", + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + } + } + `, + 'astro.config.mjs': ts` + import tailwindcss from '@tailwindcss/vite' + import react from '@astrojs/react' + import { defineConfig } from 'astro/config' + + // https://astro.build/config + export default defineConfig({ vite: { plugins: [tailwindcss()] }, integrations: [react()] }) + `, + 'src/pages/index.astro': html` + --- + import ClientOnly from './client-only'; + --- + +
Hello, world!
+ + + + + `, + 'src/pages/client-only.jsx': js` + export default function ClientOnly() { + return
Hello, world!
+ } + `, + }, + }, + async ({ fs, exec, expect }) => { + await exec('pnpm astro build') + + let files = await fs.glob('dist/**/*.css') + expect(files).toHaveLength(1) + + await fs.expectFileToContain(files[0][0], [candidate`underline`, candidate`overline`]) + }, +) diff --git a/integrations/vite/index.test.ts b/integrations/vite/index.test.ts index c0bee2b878cc..1b22fb7a8f36 100644 --- a/integrations/vite/index.test.ts +++ b/integrations/vite/index.test.ts @@ -14,7 +14,12 @@ import { yaml, } from '../utils' -describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { +describe.each([ + ['postcss', 'module-graph'], + ['postcss', 'file-system'], + ['lightningcss', 'module-graph'], + ['lightningcss', 'file-system'], +])('using %s via %s', (transformer, scanner) => { test( `production build`, { @@ -45,7 +50,7 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { export default defineConfig({ css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, build: { cssMinify: false }, - plugins: [tailwindcss()], + plugins: [tailwindcss({ scanner: '${scanner}' })], }) `, 'project-a/index.html': html` @@ -57,9 +62,7 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { `, 'project-a/tailwind.config.js': js` - export default { - content: ['../project-b/src/**/*.js'], - } + export default { content: ['../project-b/src/**/*.js'] } `, 'project-a/src/index.css': css` @import 'tailwindcss/theme' theme(reference); @@ -122,7 +125,7 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { export default defineConfig({ css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, build: { cssMinify: false }, - plugins: [tailwindcss()], + plugins: [tailwindcss({ scanner: '${scanner}' })], }) `, 'project-a/index.html': html` @@ -142,9 +145,7 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { `, 'project-a/tailwind.config.js': js` - export default { - content: ['../project-b/src/**/*.js'], - } + export default { content: ['../project-b/src/**/*.js'] } `, 'project-a/src/index.css': css` @import 'tailwindcss/theme' theme(reference); @@ -162,9 +163,7 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { }, }, async ({ root, spawn, fs, expect }) => { - let process = await spawn('pnpm vite dev', { - cwd: path.join(root, 'project-a'), - }) + let process = await spawn('pnpm vite dev', { cwd: path.join(root, 'project-a') }) await process.onStdout((m) => m.includes('ready in')) let url = '' @@ -174,17 +173,19 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { return Boolean(url) }) - // Candidates are resolved lazily, so the first visit of index.html - // will only have candidates from this file. + // Candidates are resolved lazily in module-graph mode, so the first visit of index.html will + // only have candidates from this file. await retryAssertion(async () => { let styles = await fetchStyles(url, '/index.html') expect(styles).toContain(candidate`underline`) expect(styles).toContain(candidate`flex`) - expect(styles).not.toContain(candidate`font-bold`) + + if (scanner === 'module-graph') { + expect(styles).not.toContain(candidate`font-bold`) + } }) - // Going to about.html will extend the candidate list to include - // candidates from about.html. + // Going to about.html will extend the candidate list to include candidates from about.html. await retryAssertion(async () => { let styles = await fetchStyles(url, '/about.html') expect(styles).toContain(candidate`underline`) @@ -232,8 +233,8 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { }) await retryAssertion(async () => { - // After updates to the CSS file, all previous candidates should still be in - // the generated CSS + // After updates to the CSS file, all previous candidates should still be in the generated + // stylesheet. await fs.write( 'project-a/src/index.css', css` @@ -283,10 +284,7 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { import tailwindcss from '@tailwindcss/vite' import { defineConfig } from 'vite' - export default defineConfig({ - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) + export default defineConfig({ build: { cssMinify: false }, plugins: [tailwindcss()] }) `, 'project-a/index.html': html` @@ -297,9 +295,7 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { `, 'project-a/tailwind.config.js': js` - export default { - content: ['../project-b/src/**/*.js'], - } + export default { content: ['../project-b/src/**/*.js'] } `, 'project-a/src/index.css': css` @import 'tailwindcss/theme' theme(reference); @@ -324,9 +320,7 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { }, }, async ({ root, spawn, fs, expect }) => { - let process = await spawn('pnpm vite build --watch', { - cwd: path.join(root, 'project-a'), - }) + let process = await spawn('pnpm vite build --watch', { cwd: path.join(root, 'project-a') }) await process.onStdout((m) => m.includes('built in')) let filename = '' @@ -465,7 +459,7 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { export default defineConfig({ css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, build: { cssMinify: false }, - plugins: [tailwindcss()], + plugins: [tailwindcss({ scanner: '${scanner}' })], }) `, 'project-a/index.html': html` @@ -551,7 +545,7 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { export default defineConfig({ css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, build: { cssMinify: false }, - plugins: [tailwindcss()], + plugins: [tailwindcss({ scanner: '${scanner}' })], }) `, 'project-a/index.html': html` @@ -653,7 +647,7 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { export default defineConfig({ css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, build: { cssMinify: false }, - plugins: [tailwindcss()], + plugins: [tailwindcss({ scanner: '${scanner}' })], }) `, 'project-a/index.html': html` @@ -702,23 +696,15 @@ test( 'package.json': json` { "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "vite": "^6" - } + "dependencies": { "@tailwindcss/vite": "workspace:^", "tailwindcss": "workspace:^" }, + "devDependencies": { "vite": "^6" } } `, 'vite.config.ts': ts` import tailwindcss from '@tailwindcss/vite' import { defineConfig } from 'vite' - export default defineConfig({ - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) + export default defineConfig({ build: { cssMinify: false }, plugins: [tailwindcss()] }) `, 'index.html': html` @@ -784,23 +770,15 @@ test( 'package.json': json` { "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "vite": "^6" - } + "dependencies": { "@tailwindcss/vite": "workspace:^", "tailwindcss": "workspace:^" }, + "devDependencies": { "vite": "^6" } } `, 'vite.config.ts': ts` import tailwindcss from '@tailwindcss/vite' import { defineConfig } from 'vite' - export default defineConfig({ - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) + export default defineConfig({ build: { cssMinify: false }, plugins: [tailwindcss()] }) `, 'index.html': html` @@ -860,10 +838,7 @@ test( import tailwindcss from '@tailwindcss/vite' import { defineConfig } from 'vite' - export default defineConfig({ - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) + export default defineConfig({ build: { cssMinify: false }, plugins: [tailwindcss()] }) `, 'index.html': html` diff --git a/integrations/vite/react-router.test.ts b/integrations/vite/react-router.test.ts new file mode 100644 index 000000000000..436947680099 --- /dev/null +++ b/integrations/vite/react-router.test.ts @@ -0,0 +1,178 @@ +import { candidate, css, fetchStyles, json, retryAssertion, test, ts, txt } from '../utils' + +const WORKSPACE = { + 'package.json': json` + { + "type": "module", + "dependencies": { + "@react-router/dev": "^7", + "@react-router/node": "^7", + "@react-router/serve": "^7", + "@tailwindcss/vite": "workspace:^", + "@types/node": "^20", + "@types/react-dom": "^19", + "@types/react": "^19", + "isbot": "^5", + "react-dom": "^19", + "react-router": "^7", + "react": "^19", + "tailwindcss": "workspace:^", + "vite": "^5" + } + } + `, + 'react-router.config.ts': ts` + import type { Config } from '@react-router/dev/config' + export default { ssr: true } satisfies Config + `, + 'vite.config.ts': ts` + import { defineConfig } from 'vite' + import { reactRouter } from '@react-router/dev/vite' + import tailwindcss from '@tailwindcss/vite' + + export default defineConfig({ + plugins: [tailwindcss(), reactRouter()], + }) + `, + 'app/routes/home.tsx': ts` + export default function Home() { + return

Welcome to React Router

+ } + `, + 'app/app.css': css`@import 'tailwindcss';`, + 'app/routes.ts': ts` + import { type RouteConfig, index } from '@react-router/dev/routes' + export default [index('routes/home.tsx')] satisfies RouteConfig + `, + 'app/root.tsx': ts` + import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router' + import './app.css' + export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ) + } + + export default function App() { + return + } + `, +} + +test('dev mode', { fs: WORKSPACE }, async ({ fs, spawn, expect }) => { + let process = await spawn('pnpm react-router dev') + + 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 css = await fetchStyles(url) + expect(css).toContain(candidate`font-bold`) + }) + + await retryAssertion(async () => { + await fs.write( + 'app/routes/home.tsx', + ts` + export default function Home() { + return

Welcome to React Router

+ } + `, + ) + + let css = await fetchStyles(url) + expect(css).toContain(candidate`underline`) + expect(css).toContain(candidate`font-bold`) + }) +}) + +test('build mode', { fs: WORKSPACE }, async ({ spawn, exec, expect }) => { + await exec('pnpm react-router build') + let process = await spawn('pnpm react-router-serve ./build/server/index.js') + + let url = '' + await process.onStdout((m) => { + let match = /\[react-router-serve\]\s*(http.*)\ \/?/.exec(m) + if (match) url = match[1] + return url != '' + }) + + await retryAssertion(async () => { + let css = await fetchStyles(url) + expect(css).toContain(candidate`font-bold`) + }) +}) + +test( + 'build mode using ?url stylesheet imports should only build one stylesheet (requires `file-system` scanner)', + { + fs: { + ...WORKSPACE, + 'app/root.tsx': ts` + import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router' + import styles from './app.css?url' + export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: styles }] + export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ) + } + + export default function App() { + return + } + `, + 'vite.config.ts': ts` + import { defineConfig } from 'vite' + import { reactRouter } from '@react-router/dev/vite' + import tailwindcss from '@tailwindcss/vite' + + export default defineConfig({ + plugins: [tailwindcss({ scanner: 'file-system' }), reactRouter()], + }) + `, + '.gitignore': txt` + node_modules/ + build/ + `, + }, + }, + async ({ fs, exec, expect }) => { + await exec('pnpm react-router build') + + let files = await fs.glob('build/client/assets/**/*.css') + + expect(files).toHaveLength(1) + let [filename] = files[0] + + await fs.expectFileToContain(filename, [candidate`font-bold`]) + }, +) diff --git a/integrations/vite/ssr.test.ts b/integrations/vite/ssr.test.ts index 3c3f1cc4beec..b0bf92126c23 100644 --- a/integrations/vite/ssr.test.ts +++ b/integrations/vite/ssr.test.ts @@ -1,5 +1,30 @@ import { candidate, css, html, json, test, ts } from '../utils' +const WORKSPACE = { + 'index.html': html` + +
+ + + `, + 'src/index.css': css`@import 'tailwindcss';`, + 'src/index.ts': ts` + import './index.css' + + document.querySelector('#app').innerHTML = \` +
Hello, world!
+ \` + `, + 'server.ts': ts` + import css from './src/index.css?url' + + document.querySelector('#app').innerHTML = \` + +
Hello, world!
+ \` + `, +} + test( 'Vite 5', { @@ -26,32 +51,10 @@ test( cssMinify: false, ssrEmitAssets: true, }, - plugins: [tailwindcss()], - ssr: { resolve: { conditions: [] } }, + plugins: [tailwindcss({ scanner: 'file-system' })], }) `, - 'index.html': html` - -
- - - `, - 'src/index.css': css`@import 'tailwindcss';`, - 'src/index.ts': ts` - import './index.css' - - document.querySelector('#app').innerHTML = \` -
Hello, world!
- \` - `, - 'server.ts': ts` - import css from './src/index.css?url' - - document.querySelector('#app').innerHTML = \` - -
Hello, world!
- \` - `, + ...WORKSPACE, }, }, async ({ fs, exec, expect }) => { @@ -62,9 +65,10 @@ test( let [filename] = files[0] await fs.expectFileToContain(filename, [ - // candidate`underline`, candidate`m-2`, + candidate`overline`, + candidate`m-3`, ]) }, ) @@ -94,32 +98,10 @@ test( cssMinify: false, ssrEmitAssets: true, }, - plugins: [tailwindcss()], - ssr: { resolve: { conditions: [] } }, + plugins: [tailwindcss({ scanner: 'file-system' })], }) `, - 'index.html': html` - -
- - - `, - 'src/index.css': css`@import 'tailwindcss';`, - 'src/index.ts': ts` - import './index.css' - - document.querySelector('#app').innerHTML = \` -
Hello, world!
- \` - `, - 'server.ts': ts` - import css from './src/index.css?url' - - document.querySelector('#app').innerHTML = \` - -
Hello, world!
- \` - `, + ...WORKSPACE, }, }, async ({ fs, exec, expect }) => { @@ -130,9 +112,10 @@ test( let [filename] = files[0] await fs.expectFileToContain(filename, [ - // candidate`underline`, candidate`m-2`, + candidate`overline`, + candidate`m-3`, ]) }, ) diff --git a/packages/@tailwindcss-vite/README.md b/packages/@tailwindcss-vite/README.md index 95ec9d87ddcc..e42242103744 100644 --- a/packages/@tailwindcss-vite/README.md +++ b/packages/@tailwindcss-vite/README.md @@ -21,10 +21,45 @@ --- +# `@tailwindcss/vite` + ## Documentation For full documentation, visit [tailwindcss.com](https://tailwindcss.com). +--- + +## Advanced topics + +### API reference + +The Vite plugin can be configured by passing an object to the `tailwindcss()`. Here is a full list of available options: + +| Property | Values | +| --------------------------------------------- | ------------------------------------------------------ | +| [`scanner`](#disabling-module-graph-scanning) | `automatic` _(default)_, `module-graph`, `file-system` | + +### Disabling module-graph scanning + +Our Vite plugin is designed to take the Vite module graph into account when scanning for utilities used in your project. This will work well in most cases since the module graph contains all markup that will be in your final build. + +However, sometimes your Vite setup is split across different build steps (e.g. when using SSR builds). If that is the case, you might find that the client build might contain more utilities since it traverses all components while the server build doesn't. + +When the `scanner` is set to `automatic`, we will automatically use the `file-system` scanner for Astro builds. + +To ensure that both builds read all components from your project in other cases, set the `scanner` option to `file-system`: + +```js +import { defineConfig } from 'vite' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [tailwindcss({ scanner: 'file-system' })], +}) +``` + +--- + ## Community For help, discussion about best practices, or any other conversation that would benefit from being searchable: diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index ce61fdf9b64c..b2dfe44a9ea9 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -13,7 +13,13 @@ const INLINE_STYLE_ID_RE = /[?&]index\=\d+\.css$/ const IGNORED_DEPENDENCIES = ['tailwind-merge'] -export default function tailwindcss(): Plugin[] { +type ScannerMode = 'automatic' | 'module-graph' | 'file-system' + +export default function tailwindcss( + { scanner: scannerMode = 'automatic' }: { scanner: ScannerMode } = { + scanner: 'automatic', + }, +): Plugin[] { let servers: ViteDevServer[] = [] let config: ResolvedConfig | null = null @@ -59,13 +65,18 @@ export default function tailwindcss(): Plugin[] { return new Root( id, () => moduleGraphCandidates, - config!.base, + scannerMode, + config!.root, customCssResolver, customJsResolver, ) }) function scanFile(id: string, content: string, extension: string) { + if (scannerMode === 'file-system') { + return + } + for (let dependency of IGNORED_DEPENDENCIES) { // We validated that Vite IDs always use posix style path separators, even on Windows. // In dev build, Vite precompiles dependencies @@ -197,6 +208,14 @@ export default function tailwindcss(): Plugin[] { config = _config minify = config.build.cssMinify !== false isSSR = config.build.ssr !== false && config.build.ssr !== undefined + + if (scannerMode === 'automatic') { + if (shouldDisableModuleGraph(config)) { + scannerMode = 'file-system' + return + } + scannerMode = 'module-graph' + } }, // Scan all non-CSS files for candidates @@ -416,6 +435,7 @@ class Root { constructor( private id: string, private getSharedCandidates: () => Map>, + private scannerMode: ScannerMode, private base: string, private customCssResolver: (id: string, base: string) => Promise, @@ -458,9 +478,13 @@ class Root { return [] } - // No root specified, use the module graph + // No root specified, use the module graph unless we are in file-based scanner mode if (this.compiler.root === null) { - return [] + if (this.scannerMode === 'file-system') { + return [{ base: this.base, pattern: '**/*' }] + } else { + return [] + } } // Use the specified root @@ -547,8 +571,9 @@ class Root { } private sharedCandidates(): Set { - if (!this.compiler) return new Set() - if (this.compiler.root === 'none') return new Set() + if (!this.compiler || this.scannerMode === 'file-system' || this.compiler.root === 'none') { + return new Set() + } const HAS_DRIVE_LETTER = /^[A-Z]:/ @@ -581,3 +606,7 @@ class Root { return shared } } + +function shouldDisableModuleGraph(config: ResolvedConfig) { + return config.plugins.some((p) => p.name === 'astro:scripts:page-ssr') +}