Skip to content

Commit 88b762b

Browse files
Vite: Remove module-graph scanner (#16631)
Alternative to #16425 Fixes #16585 Fixes #16389 Fixes #16252 Fixes #15794 Fixes #16646 Fixes #16358 This PR changes the Vite plugin to use the file-system to discover potential class names instead of relying on the module-graph. This comes after a lot of testing and various issue reports where builds that span different Vite instances were missing class names. Because we now scan for candidates using the file-system, we can also remove a lot of the bookkeeping necessary to make production builds and development builds work as we no longer have to change the resulting stylesheet based on the `transform` callbacks of other files that might happen later. This change comes at a small performance penalty that is noticeable especially on very large projects with many files to scan. However, we offset that change by fixing an issue that I found in the current Vite integration that did a needless rebuild of the whole Tailwind root whenever any source file changed. Because of how impactful this change is, I expect many normal to medium sized projects to actually see a performance improvement after these changes. Furthermore we do plan to continue to use the module-graph to further improve the performance in dev mode. ## Test plan - Added new integration tests with cases found across the issues above. - Manual testing by adding a local version of the Vite plugin to repos from the issue list above and the [tailwindcss playgrounds](https://github.com/philipp-spiess/tailwindcss-playgrounds).
1 parent b9af722 commit 88b762b

File tree

6 files changed

+313
-351
lines changed

6 files changed

+313
-351
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
### Fixed
1515

1616
- Remove invalid `!important` on CSS variable declarations ([#16668](https://github.com/tailwindlabs/tailwindcss/pull/16668))
17+
- Vite: Automatic source detection now ignores files and directories specified in your `.gitignore` file ([#16631](https://github.com/tailwindlabs/tailwindcss/pull/16631))
18+
- Vite: Ensure setups with multiple Vite builds work as expected ([#16631](https://github.com/tailwindlabs/tailwindcss/pull/16631))
19+
- Vite: Ensure Astro production builds contain classes for client-only components ([#16631](https://github.com/tailwindlabs/tailwindcss/pull/16631))
20+
- Vite: Ensure utility classes are read without escaping special characters ([#16631](https://github.com/tailwindlabs/tailwindcss/pull/16631))
1721

1822
## [4.0.7] - 2025-02-18
1923

integrations/vite/astro.test.ts

+57-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { candidate, fetchStyles, html, json, retryAssertion, test, ts } from '../utils'
1+
import { candidate, fetchStyles, html, js, json, retryAssertion, test, ts } from '../utils'
22

33
test(
44
'dev mode',
@@ -19,11 +19,7 @@ test(
1919
import { defineConfig } from 'astro/config'
2020
2121
// https://astro.build/config
22-
export default defineConfig({
23-
vite: {
24-
plugins: [tailwindcss()],
25-
},
26-
})
22+
export default defineConfig({ vite: { plugins: [tailwindcss()] } })
2723
`,
2824
'src/pages/index.astro': html`
2925
<div class="underline">Hello, world!</div>
@@ -70,3 +66,58 @@ test(
7066
})
7167
},
7268
)
69+
70+
test(
71+
'build mode',
72+
{
73+
fs: {
74+
'package.json': json`
75+
{
76+
"type": "module",
77+
"dependencies": {
78+
"astro": "^4.15.2",
79+
"react": "^19",
80+
"react-dom": "^19",
81+
"@astrojs/react": "^4",
82+
"@tailwindcss/vite": "workspace:^",
83+
"tailwindcss": "workspace:^"
84+
}
85+
}
86+
`,
87+
'astro.config.mjs': ts`
88+
import tailwindcss from '@tailwindcss/vite'
89+
import react from '@astrojs/react'
90+
import { defineConfig } from 'astro/config'
91+
92+
// https://astro.build/config
93+
export default defineConfig({ vite: { plugins: [tailwindcss()] }, integrations: [react()] })
94+
`,
95+
'src/pages/index.astro': html`
96+
---
97+
import ClientOnly from './client-only';
98+
---
99+
100+
<div class="underline">Hello, world!</div>
101+
102+
<ClientOnly client:only="react" />
103+
104+
<style is:global>
105+
@import 'tailwindcss';
106+
</style>
107+
`,
108+
'src/pages/client-only.jsx': js`
109+
export default function ClientOnly() {
110+
return <div className="overline">Hello, world!</div>
111+
}
112+
`,
113+
},
114+
},
115+
async ({ fs, exec, expect }) => {
116+
await exec('pnpm astro build')
117+
118+
let files = await fs.glob('dist/**/*.css')
119+
expect(files).toHaveLength(1)
120+
121+
await fs.expectFileToContain(files[0][0], [candidate`underline`, candidate`overline`])
122+
},
123+
)

integrations/vite/index.test.ts

+1-22
Original file line numberDiff line numberDiff line change
@@ -174,21 +174,10 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
174174
return Boolean(url)
175175
})
176176

177-
// Candidates are resolved lazily, so the first visit of index.html
178-
// will only have candidates from this file.
179177
await retryAssertion(async () => {
180178
let styles = await fetchStyles(url, '/index.html')
181179
expect(styles).toContain(candidate`underline`)
182180
expect(styles).toContain(candidate`flex`)
183-
expect(styles).not.toContain(candidate`font-bold`)
184-
})
185-
186-
// Going to about.html will extend the candidate list to include
187-
// candidates from about.html.
188-
await retryAssertion(async () => {
189-
let styles = await fetchStyles(url, '/about.html')
190-
expect(styles).toContain(candidate`underline`)
191-
expect(styles).toContain(candidate`flex`)
192181
expect(styles).toContain(candidate`font-bold`)
193182
})
194183

@@ -696,7 +685,7 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
696685
})
697686

698687
test(
699-
`demote Tailwind roots to regular CSS files and back to Tailwind roots while restoring all candidates`,
688+
`demote Tailwind roots to regular CSS files and back to Tailwind roots`,
700689
{
701690
fs: {
702691
'package.json': json`
@@ -750,19 +739,9 @@ test(
750739
return Boolean(url)
751740
})
752741

753-
// Candidates are resolved lazily, so the first visit of index.html
754-
// will only have candidates from this file.
755742
await retryAssertion(async () => {
756743
let styles = await fetchStyles(url, '/index.html')
757744
expect(styles).toContain(candidate`underline`)
758-
expect(styles).not.toContain(candidate`font-bold`)
759-
})
760-
761-
// Going to about.html will extend the candidate list to include
762-
// candidates from about.html.
763-
await retryAssertion(async () => {
764-
let styles = await fetchStyles(url, '/about.html')
765-
expect(styles).toContain(candidate`underline`)
766745
expect(styles).toContain(candidate`font-bold`)
767746
})
768747

+178
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { candidate, css, fetchStyles, json, retryAssertion, test, ts, txt } from '../utils'
2+
3+
const WORKSPACE = {
4+
'package.json': json`
5+
{
6+
"type": "module",
7+
"dependencies": {
8+
"@react-router/dev": "^7",
9+
"@react-router/node": "^7",
10+
"@react-router/serve": "^7",
11+
"@tailwindcss/vite": "workspace:^",
12+
"@types/node": "^20",
13+
"@types/react-dom": "^19",
14+
"@types/react": "^19",
15+
"isbot": "^5",
16+
"react-dom": "^19",
17+
"react-router": "^7",
18+
"react": "^19",
19+
"tailwindcss": "workspace:^",
20+
"vite": "^5"
21+
}
22+
}
23+
`,
24+
'react-router.config.ts': ts`
25+
import type { Config } from '@react-router/dev/config'
26+
export default { ssr: true } satisfies Config
27+
`,
28+
'vite.config.ts': ts`
29+
import { defineConfig } from 'vite'
30+
import { reactRouter } from '@react-router/dev/vite'
31+
import tailwindcss from '@tailwindcss/vite'
32+
33+
export default defineConfig({
34+
plugins: [tailwindcss(), reactRouter()],
35+
})
36+
`,
37+
'app/routes/home.tsx': ts`
38+
export default function Home() {
39+
return <h1 className="font-bold">Welcome to React Router</h1>
40+
}
41+
`,
42+
'app/app.css': css`@import 'tailwindcss';`,
43+
'app/routes.ts': ts`
44+
import { type RouteConfig, index } from '@react-router/dev/routes'
45+
export default [index('routes/home.tsx')] satisfies RouteConfig
46+
`,
47+
'app/root.tsx': ts`
48+
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'
49+
import './app.css'
50+
export function Layout({ children }: { children: React.ReactNode }) {
51+
return (
52+
<html lang="en">
53+
<head>
54+
<meta charSet="utf-8" />
55+
<meta name="viewport" content="width=device-width, initial-scale=1" />
56+
<Meta />
57+
<Links />
58+
</head>
59+
<body>
60+
{children}
61+
<ScrollRestoration />
62+
<Scripts />
63+
</body>
64+
</html>
65+
)
66+
}
67+
68+
export default function App() {
69+
return <Outlet />
70+
}
71+
`,
72+
}
73+
74+
test('dev mode', { fs: WORKSPACE }, async ({ fs, spawn, expect }) => {
75+
let process = await spawn('pnpm react-router dev')
76+
77+
let url = ''
78+
await process.onStdout((m) => {
79+
let match = /Local:\s*(http.*)\//.exec(m)
80+
if (match) url = match[1]
81+
return Boolean(url)
82+
})
83+
84+
await retryAssertion(async () => {
85+
let css = await fetchStyles(url)
86+
expect(css).toContain(candidate`font-bold`)
87+
})
88+
89+
await retryAssertion(async () => {
90+
await fs.write(
91+
'app/routes/home.tsx',
92+
ts`
93+
export default function Home() {
94+
return <h1 className="font-bold underline">Welcome to React Router</h1>
95+
}
96+
`,
97+
)
98+
99+
let css = await fetchStyles(url)
100+
expect(css).toContain(candidate`underline`)
101+
expect(css).toContain(candidate`font-bold`)
102+
})
103+
})
104+
105+
test('build mode', { fs: WORKSPACE }, async ({ spawn, exec, expect }) => {
106+
await exec('pnpm react-router build')
107+
let process = await spawn('pnpm react-router-serve ./build/server/index.js')
108+
109+
let url = ''
110+
await process.onStdout((m) => {
111+
let match = /\[react-router-serve\]\s*(http.*)\ \/?/.exec(m)
112+
if (match) url = match[1]
113+
return url != ''
114+
})
115+
116+
await retryAssertion(async () => {
117+
let css = await fetchStyles(url)
118+
expect(css).toContain(candidate`font-bold`)
119+
})
120+
})
121+
122+
test(
123+
'build mode using ?url stylesheet imports should only build one stylesheet (requires `file-system` scanner)',
124+
{
125+
fs: {
126+
...WORKSPACE,
127+
'app/root.tsx': ts`
128+
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'
129+
import styles from './app.css?url'
130+
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: styles }]
131+
export function Layout({ children }: { children: React.ReactNode }) {
132+
return (
133+
<html lang="en">
134+
<head>
135+
<meta charSet="utf-8" />
136+
<meta name="viewport" content="width=device-width, initial-scale=1" />
137+
<Meta />
138+
<Links />
139+
</head>
140+
<body class="dark">
141+
{children}
142+
<ScrollRestoration />
143+
<Scripts />
144+
</body>
145+
</html>
146+
)
147+
}
148+
149+
export default function App() {
150+
return <Outlet />
151+
}
152+
`,
153+
'vite.config.ts': ts`
154+
import { defineConfig } from 'vite'
155+
import { reactRouter } from '@react-router/dev/vite'
156+
import tailwindcss from '@tailwindcss/vite'
157+
158+
export default defineConfig({
159+
plugins: [tailwindcss(), reactRouter()],
160+
})
161+
`,
162+
'.gitignore': txt`
163+
node_modules/
164+
build/
165+
`,
166+
},
167+
},
168+
async ({ fs, exec, expect }) => {
169+
await exec('pnpm react-router build')
170+
171+
let files = await fs.glob('build/client/assets/**/*.css')
172+
173+
expect(files).toHaveLength(1)
174+
let [filename] = files[0]
175+
176+
await fs.expectFileToContain(filename, [candidate`font-bold`])
177+
},
178+
)

0 commit comments

Comments
 (0)