Skip to content

Commit

Permalink
Vite: Remove module-graph scanner (#16631)
Browse files Browse the repository at this point in the history
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).
  • Loading branch information
philipp-spiess authored Feb 20, 2025
1 parent b9af722 commit 88b762b
Show file tree
Hide file tree
Showing 6 changed files with 313 additions and 351 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

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

## [4.0.7] - 2025-02-18

Expand Down
63 changes: 57 additions & 6 deletions integrations/vite/astro.test.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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`
<div class="underline">Hello, world!</div>
Expand Down Expand Up @@ -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';
---
<div class="underline">Hello, world!</div>
<ClientOnly client:only="react" />
<style is:global>
@import 'tailwindcss';
</style>
`,
'src/pages/client-only.jsx': js`
export default function ClientOnly() {
return <div className="overline">Hello, world!</div>
}
`,
},
},
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`])
},
)
23 changes: 1 addition & 22 deletions integrations/vite/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,21 +174,10 @@ 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.
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`)
})

// 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`)
expect(styles).toContain(candidate`flex`)
expect(styles).toContain(candidate`font-bold`)
})

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

test(
`demote Tailwind roots to regular CSS files and back to Tailwind roots while restoring all candidates`,
`demote Tailwind roots to regular CSS files and back to Tailwind roots`,
{
fs: {
'package.json': json`
Expand Down Expand Up @@ -750,19 +739,9 @@ test(
return Boolean(url)
})

// Candidates are resolved lazily, 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).not.toContain(candidate`font-bold`)
})

// 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`)
expect(styles).toContain(candidate`font-bold`)
})

Expand Down
178 changes: 178 additions & 0 deletions integrations/vite/react-router.test.ts
Original file line number Diff line number Diff line change
@@ -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 <h1 className="font-bold">Welcome to React Router</h1>
}
`,
'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 (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
)
}
export default function App() {
return <Outlet />
}
`,
}

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 <h1 className="font-bold underline">Welcome to React Router</h1>
}
`,
)

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 (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body class="dark">
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
)
}
export default function App() {
return <Outlet />
}
`,
'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()],
})
`,
'.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`])
},
)
Loading

0 comments on commit 88b762b

Please sign in to comment.