mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
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).
This commit is contained in:
parent
b9af722d13
commit
88b762b539
@ -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
|
||||
|
||||
|
||||
@ -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`
|
||||
<div class="underline">Hello, world!</div>
|
||||
@ -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`])
|
||||
},
|
||||
)
|
||||
|
||||
@ -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`)
|
||||
})
|
||||
|
||||
@ -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`
|
||||
@ -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`)
|
||||
})
|
||||
|
||||
|
||||
178
integrations/vite/react-router.test.ts
Normal file
178
integrations/vite/react-router.test.ts
Normal file
@ -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`])
|
||||
},
|
||||
)
|
||||
@ -1,5 +1,30 @@
|
||||
import { candidate, css, html, json, test, ts } from '../utils'
|
||||
|
||||
const WORKSPACE = {
|
||||
'index.html': html`
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./src/index.ts"></script>
|
||||
</body>
|
||||
`,
|
||||
'src/index.css': css`@import 'tailwindcss';`,
|
||||
'src/index.ts': ts`
|
||||
import './index.css'
|
||||
|
||||
document.querySelector('#app').innerHTML = \`
|
||||
<div class="underline m-2">Hello, world!</div>
|
||||
\`
|
||||
`,
|
||||
'server.ts': ts`
|
||||
import css from './src/index.css?url'
|
||||
|
||||
document.querySelector('#app').innerHTML = \`
|
||||
<link rel="stylesheet" href="\${css}">
|
||||
<div class="overline m-3">Hello, world!</div>
|
||||
\`
|
||||
`,
|
||||
}
|
||||
|
||||
test(
|
||||
'Vite 5',
|
||||
{
|
||||
@ -27,31 +52,9 @@ test(
|
||||
ssrEmitAssets: true,
|
||||
},
|
||||
plugins: [tailwindcss()],
|
||||
ssr: { resolve: { conditions: [] } },
|
||||
})
|
||||
`,
|
||||
'index.html': html`
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./src/index.ts"></script>
|
||||
</body>
|
||||
`,
|
||||
'src/index.css': css`@import 'tailwindcss';`,
|
||||
'src/index.ts': ts`
|
||||
import './index.css'
|
||||
|
||||
document.querySelector('#app').innerHTML = \`
|
||||
<div class="underline m-2">Hello, world!</div>
|
||||
\`
|
||||
`,
|
||||
'server.ts': ts`
|
||||
import css from './src/index.css?url'
|
||||
|
||||
document.querySelector('#app').innerHTML = \`
|
||||
<link rel="stylesheet" href="\${css}">
|
||||
<div class="underline m-2">Hello, world!</div>
|
||||
\`
|
||||
`,
|
||||
...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`,
|
||||
])
|
||||
},
|
||||
)
|
||||
@ -95,31 +99,9 @@ test(
|
||||
ssrEmitAssets: true,
|
||||
},
|
||||
plugins: [tailwindcss()],
|
||||
ssr: { resolve: { conditions: [] } },
|
||||
})
|
||||
`,
|
||||
'index.html': html`
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./src/index.ts"></script>
|
||||
</body>
|
||||
`,
|
||||
'src/index.css': css`@import 'tailwindcss';`,
|
||||
'src/index.ts': ts`
|
||||
import './index.css'
|
||||
|
||||
document.querySelector('#app').innerHTML = \`
|
||||
<div class="underline m-2">Hello, world!</div>
|
||||
\`
|
||||
`,
|
||||
'server.ts': ts`
|
||||
import css from './src/index.css?url'
|
||||
|
||||
document.querySelector('#app').innerHTML = \`
|
||||
<link rel="stylesheet" href="\${css}">
|
||||
<div class="underline m-2">Hello, world!</div>
|
||||
\`
|
||||
`,
|
||||
...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`,
|
||||
])
|
||||
},
|
||||
)
|
||||
|
||||
@ -4,15 +4,13 @@ import { Scanner } from '@tailwindcss/oxide'
|
||||
import { Features as LightningCssFeatures, transform } from 'lightningcss'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import type { Plugin, ResolvedConfig, Rollup, Update, ViteDevServer } from 'vite'
|
||||
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite'
|
||||
|
||||
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 IGNORED_DEPENDENCIES = ['tailwind-merge']
|
||||
|
||||
export default function tailwindcss(): Plugin[] {
|
||||
let servers: ViteDevServer[] = []
|
||||
let config: ResolvedConfig | null = null
|
||||
@ -20,25 +18,6 @@ export default function tailwindcss(): Plugin[] {
|
||||
let isSSR = false
|
||||
let minify = false
|
||||
|
||||
// The Vite extension has two types of sources for candidates:
|
||||
//
|
||||
// 1. The module graph: These are all modules that vite transforms and we want
|
||||
// them to be automatically scanned for candidates.
|
||||
// 2. Root defined `@source`s
|
||||
//
|
||||
// Module graph candidates are global to the Vite extension since we do not
|
||||
// know which CSS roots will be used for the modules. We are using a custom
|
||||
// scanner instance with auto source discovery disabled to parse these.
|
||||
//
|
||||
// For candidates coming from custom `@source` directives of the CSS roots, we
|
||||
// create an individual scanner for each root.
|
||||
//
|
||||
// Note: To improve performance, we do not remove candidates from this set.
|
||||
// This means a longer-ongoing dev mode session might contain candidates that
|
||||
// are no longer referenced in code.
|
||||
let moduleGraphCandidates = new DefaultMap<string, Set<string>>(() => new Set<string>())
|
||||
let moduleGraphScanner = new Scanner({})
|
||||
|
||||
let roots: DefaultMap<string, Root> = new DefaultMap((id) => {
|
||||
let cssResolver = config!.createResolver({
|
||||
...config!.resolve,
|
||||
@ -56,133 +35,9 @@ export default function tailwindcss(): Plugin[] {
|
||||
function customJsResolver(id: string, base: string) {
|
||||
return jsResolver(id, base, true, isSSR)
|
||||
}
|
||||
return new Root(
|
||||
id,
|
||||
() => moduleGraphCandidates,
|
||||
config!.base,
|
||||
customCssResolver,
|
||||
customJsResolver,
|
||||
)
|
||||
return new Root(id, config!.root, customCssResolver, customJsResolver)
|
||||
})
|
||||
|
||||
function scanFile(id: string, content: string, extension: string) {
|
||||
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
|
||||
if (id.includes(`.vite/deps/${dependency}.js`)) {
|
||||
return
|
||||
}
|
||||
// In prod builds, use the node_modules path
|
||||
if (id.includes(`/node_modules/${dependency}/`)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let updated = false
|
||||
for (let candidate of moduleGraphScanner.scanFiles([{ content, extension }])) {
|
||||
updated = true
|
||||
moduleGraphCandidates.get(id).add(candidate)
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
invalidateAllRoots()
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateAllRoots() {
|
||||
for (let server of servers) {
|
||||
let updates: Update[] = []
|
||||
for (let [id] of roots.entries()) {
|
||||
let module = server.moduleGraph.getModuleById(id)
|
||||
if (!module) continue
|
||||
|
||||
roots.get(id).requiresRebuild = false
|
||||
server.moduleGraph.invalidateModule(module)
|
||||
updates.push({
|
||||
type: `${module.type}-update`,
|
||||
path: module.url,
|
||||
acceptedPath: module.url,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
if (updates.length > 0) {
|
||||
server.hot.send({ type: 'update', updates })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerateOptimizedCss(
|
||||
root: Root,
|
||||
addWatchFile: (file: string) => void,
|
||||
I: Instrumentation,
|
||||
) {
|
||||
let content = root.lastContent
|
||||
let generated = await root.generate(content, addWatchFile, I)
|
||||
if (generated === false) {
|
||||
return
|
||||
}
|
||||
DEBUG && I.start('Optimize CSS')
|
||||
let result = optimizeCss(generated, { minify })
|
||||
DEBUG && I.end('Optimize CSS')
|
||||
return result
|
||||
}
|
||||
|
||||
// Manually run the transform functions of non-Tailwind plugins on the given CSS
|
||||
async function transformWithPlugins(context: Rollup.PluginContext, id: string, css: string) {
|
||||
let transformPluginContext = {
|
||||
...context,
|
||||
getCombinedSourcemap: () => {
|
||||
throw new Error('getCombinedSourcemap not implemented')
|
||||
},
|
||||
}
|
||||
|
||||
for (let plugin of config!.plugins) {
|
||||
if (!plugin.transform) continue
|
||||
|
||||
if (plugin.name.startsWith('@tailwindcss/')) {
|
||||
// We do not run any Tailwind transforms anymore
|
||||
continue
|
||||
} else if (
|
||||
plugin.name.startsWith('vite:') &&
|
||||
// Apply the vite:css plugin to generated CSS for transformations like
|
||||
// URL path rewriting and image inlining.
|
||||
plugin.name !== 'vite:css' &&
|
||||
// In build mode, since `renderStart` runs after all transformations, we
|
||||
// need to also apply vite:css-post.
|
||||
plugin.name !== 'vite:css-post' &&
|
||||
// The vite:vue plugin handles CSS specific post-processing for Vue
|
||||
plugin.name !== 'vite:vue'
|
||||
) {
|
||||
continue
|
||||
} else if (plugin.name === 'ssr-styles') {
|
||||
// The Nuxt ssr-styles plugin emits styles from server-side rendered
|
||||
// components, we can't run it in the `renderStart` phase so we're
|
||||
// skipping it.
|
||||
continue
|
||||
}
|
||||
|
||||
let transformHandler =
|
||||
'handler' in plugin.transform! ? plugin.transform.handler : plugin.transform!
|
||||
|
||||
try {
|
||||
// Directly call the plugin's transform function to process the
|
||||
// generated CSS. In build mode, this updates the chunks later used to
|
||||
// generate the bundle. In serve mode, the transformed source should be
|
||||
// applied in transform.
|
||||
let result = await transformHandler.call(transformPluginContext, css, id)
|
||||
if (!result) continue
|
||||
if (typeof result === 'string') {
|
||||
css = result
|
||||
} else if (result.code) {
|
||||
css = result.code
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error running ${plugin.name} on Tailwind CSS output. Skipping.`)
|
||||
}
|
||||
}
|
||||
return css
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
// Step 1: Scan source files for candidates
|
||||
@ -198,19 +53,6 @@ export default function tailwindcss(): Plugin[] {
|
||||
minify = config.build.cssMinify !== false
|
||||
isSSR = config.build.ssr !== false && config.build.ssr !== undefined
|
||||
},
|
||||
|
||||
// Scan all non-CSS files for candidates
|
||||
transformIndexHtml(html, { path }) {
|
||||
// SolidStart emits HTML chunks with an undefined path and the html content of `\`.
|
||||
if (!path) return
|
||||
|
||||
scanFile(path, html, 'html')
|
||||
},
|
||||
transform(src, id, options) {
|
||||
let extension = getExtension(id)
|
||||
if (isPotentialCssRootFile(id)) return
|
||||
scanFile(id, src, extension)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
@ -223,26 +65,17 @@ export default function tailwindcss(): Plugin[] {
|
||||
if (!isPotentialCssRootFile(id)) return
|
||||
|
||||
using I = new Instrumentation()
|
||||
I.start('[@tailwindcss/vite] Generate CSS (serve)')
|
||||
DEBUG && I.start('[@tailwindcss/vite] Generate CSS (serve)')
|
||||
|
||||
let root = roots.get(id)
|
||||
|
||||
if (!options?.ssr) {
|
||||
// Wait until all other files have been processed, so we can extract
|
||||
// all candidates before generating CSS. This must not be called
|
||||
// during SSR or it will block the server.
|
||||
//
|
||||
// The reason why we can not rely on the invalidation here is that the
|
||||
// users would otherwise see a flicker in the styles as the CSS might
|
||||
// be loaded with an invalid set of candidates first.
|
||||
await Promise.all(servers.map((server) => server.waitForRequestsIdle(id)))
|
||||
}
|
||||
|
||||
let generated = await root.generate(src, (file) => this.addWatchFile(file), I)
|
||||
if (!generated) {
|
||||
roots.delete(id)
|
||||
return src
|
||||
}
|
||||
|
||||
DEBUG && I.end('[@tailwindcss/vite] Generate CSS (serve)')
|
||||
return { code: generated }
|
||||
},
|
||||
},
|
||||
@ -257,50 +90,23 @@ export default function tailwindcss(): Plugin[] {
|
||||
if (!isPotentialCssRootFile(id)) return
|
||||
|
||||
using I = new Instrumentation()
|
||||
I.start('[@tailwindcss/vite] Generate CSS (build)')
|
||||
DEBUG && I.start('[@tailwindcss/vite] Generate CSS (build)')
|
||||
|
||||
let root = roots.get(id)
|
||||
|
||||
// We do a first pass to generate valid CSS for the downstream plugins.
|
||||
// However, since not all candidates are guaranteed to be extracted by
|
||||
// this time, we have to re-run a transform for the root later.
|
||||
let generated = await root.generate(src, (file) => this.addWatchFile(file), I)
|
||||
if (!generated) {
|
||||
roots.delete(id)
|
||||
return src
|
||||
}
|
||||
DEBUG && I.end('[@tailwindcss/vite] Generate CSS (build)')
|
||||
|
||||
DEBUG && I.start('[@tailwindcss/vite] Optimize CSS')
|
||||
generated = optimizeCss(generated, { minify })
|
||||
DEBUG && I.end('[@tailwindcss/vite] Optimize CSS')
|
||||
|
||||
return { code: generated }
|
||||
},
|
||||
|
||||
// `renderStart` runs in the bundle generation stage after all transforms.
|
||||
// We must run before `enforce: post` so the updated chunks are picked up
|
||||
// by vite:css-post.
|
||||
async renderStart() {
|
||||
using I = new Instrumentation()
|
||||
I.start('[@tailwindcss/vite] (render start)')
|
||||
|
||||
for (let [id, root] of roots.entries()) {
|
||||
let generated = await regenerateOptimizedCss(
|
||||
root,
|
||||
// During the renderStart phase, we can not add watch files since
|
||||
// those would not be causing a refresh of the right CSS file. This
|
||||
// should not be an issue since we did already process the CSS file
|
||||
// before and the dependencies should not be changed (only the
|
||||
// candidate list might have)
|
||||
() => {},
|
||||
I,
|
||||
)
|
||||
if (!generated) {
|
||||
roots.delete(id)
|
||||
continue
|
||||
}
|
||||
|
||||
// These plugins have side effects which, during build, results in CSS
|
||||
// being written to the output dir. We need to run them here to ensure
|
||||
// the CSS is written before the bundle is generated.
|
||||
await transformWithPlugins(this, id, generated)
|
||||
}
|
||||
},
|
||||
},
|
||||
] satisfies Plugin[]
|
||||
}
|
||||
@ -325,7 +131,7 @@ function optimizeCss(
|
||||
input: string,
|
||||
{ file = 'input.css', minify = false }: { file?: string; minify?: boolean } = {},
|
||||
) {
|
||||
function optimize(code: Buffer | Uint8Array) {
|
||||
function optimize(code: Buffer | Uint8Array | any) {
|
||||
return transform({
|
||||
filename: file,
|
||||
code,
|
||||
@ -383,39 +189,24 @@ class DefaultMap<K, V> extends Map<K, V> {
|
||||
}
|
||||
|
||||
class Root {
|
||||
// Content is only used in serve mode where we need to capture the initial
|
||||
// contents of the root file so that we can restore it during the
|
||||
// `renderStart` hook.
|
||||
public lastContent: string = ''
|
||||
|
||||
// The lazily-initialized Tailwind compiler components. These are persisted
|
||||
// throughout rebuilds but will be re-initialized if the rebuild strategy is
|
||||
// set to `full`.
|
||||
private compiler?: Awaited<ReturnType<typeof compile>>
|
||||
|
||||
public requiresRebuild: boolean = true
|
||||
|
||||
// This is the compiler-specific scanner instance that is used only to scan
|
||||
// files for custom @source paths. All other modules we scan for candidates
|
||||
// will use the shared moduleGraphScanner instance.
|
||||
// The lazily-initialized Tailwind scanner.
|
||||
private scanner?: Scanner
|
||||
|
||||
// List of all candidates that were being returned by the root scanner during
|
||||
// the lifetime of the root.
|
||||
private candidates: Set<string> = new Set<string>()
|
||||
|
||||
// List of all dependencies captured while generating the root. These are
|
||||
// retained so we can clear the require cache when we rebuild the root.
|
||||
private dependencies = new Set<string>()
|
||||
|
||||
// The resolved path given to `source(…)`. When not given this is `null`.
|
||||
private basePath: string | null = null
|
||||
|
||||
public overwriteCandidates: string[] | null = null
|
||||
// List of all build dependencies (e.g. imported stylesheets or plugins) and
|
||||
// their last modification timestamp
|
||||
private buildDependencies = new Map<string, number>()
|
||||
|
||||
constructor(
|
||||
private id: string,
|
||||
private getSharedCandidates: () => Map<string, Set<string>>,
|
||||
private base: string,
|
||||
|
||||
private customCssResolver: (id: string, base: string) => Promise<string | false | undefined>,
|
||||
@ -429,38 +220,43 @@ class Root {
|
||||
addWatchFile: (file: string) => void,
|
||||
I: Instrumentation,
|
||||
): Promise<string | false> {
|
||||
this.lastContent = content
|
||||
|
||||
let requiresBuildPromise = this.requiresBuild()
|
||||
let inputPath = idToPath(this.id)
|
||||
let inputBase = path.dirname(path.resolve(inputPath))
|
||||
|
||||
if (!this.compiler || !this.scanner || this.requiresRebuild) {
|
||||
clearRequireCache(Array.from(this.dependencies))
|
||||
this.dependencies = new Set([idToPath(inputPath)])
|
||||
if (!this.compiler || !this.scanner || (await requiresBuildPromise)) {
|
||||
clearRequireCache(Array.from(this.buildDependencies.keys()))
|
||||
this.buildDependencies.clear()
|
||||
|
||||
this.addBuildDependency(idToPath(inputPath))
|
||||
|
||||
DEBUG && I.start('Setup compiler')
|
||||
let addBuildDependenciesPromises: Promise<void>[] = []
|
||||
this.compiler = await compile(content, {
|
||||
base: inputBase,
|
||||
shouldRewriteUrls: true,
|
||||
onDependency: (path) => {
|
||||
addWatchFile(path)
|
||||
this.dependencies.add(path)
|
||||
addBuildDependenciesPromises.push(this.addBuildDependency(path))
|
||||
},
|
||||
|
||||
customCssResolver: this.customCssResolver,
|
||||
customJsResolver: this.customJsResolver,
|
||||
})
|
||||
await Promise.all(addBuildDependenciesPromises)
|
||||
DEBUG && I.end('Setup compiler')
|
||||
|
||||
DEBUG && I.start('Setup scanner')
|
||||
|
||||
let sources = (() => {
|
||||
// Disable auto source detection
|
||||
if (this.compiler.root === 'none') {
|
||||
return []
|
||||
}
|
||||
|
||||
// No root specified, use the module graph
|
||||
// No root specified, auto-detect based on the `**/*` pattern
|
||||
if (this.compiler.root === null) {
|
||||
return []
|
||||
return [{ base: this.base, pattern: '**/*' }]
|
||||
}
|
||||
|
||||
// Use the specified root
|
||||
@ -468,6 +264,7 @@ class Root {
|
||||
})().concat(this.compiler.globs)
|
||||
|
||||
this.scanner = new Scanner({ sources })
|
||||
DEBUG && I.end('Setup scanner')
|
||||
}
|
||||
|
||||
if (
|
||||
@ -479,7 +276,7 @@ class Root {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!this.overwriteCandidates || this.compiler.features & Features.Utilities) {
|
||||
if (this.compiler.features & Features.Utilities) {
|
||||
// This should not be here, but right now the Vite plugin is setup where we
|
||||
// setup a new scanner and compiler every time we request the CSS file
|
||||
// (regardless whether it actually changed or not).
|
||||
@ -525,59 +322,29 @@ class Root {
|
||||
`The path given to \`source(…)\` must be a directory but got \`source(${basePath})\` instead.`,
|
||||
)
|
||||
}
|
||||
|
||||
this.basePath = basePath
|
||||
} else if (root === null) {
|
||||
this.basePath = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.requiresRebuild = true
|
||||
|
||||
DEBUG && I.start('Build CSS')
|
||||
let result = this.compiler.build(
|
||||
this.overwriteCandidates
|
||||
? this.overwriteCandidates
|
||||
: [...this.sharedCandidates(), ...this.candidates],
|
||||
)
|
||||
let result = this.compiler.build([...this.candidates])
|
||||
DEBUG && I.end('Build CSS')
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private sharedCandidates(): Set<string> {
|
||||
if (!this.compiler) return new Set()
|
||||
if (this.compiler.root === 'none') return new Set()
|
||||
private async addBuildDependency(path: string) {
|
||||
let stat = await fs.stat(path)
|
||||
this.buildDependencies.set(path, stat.mtimeMs)
|
||||
}
|
||||
|
||||
const HAS_DRIVE_LETTER = /^[A-Z]:/
|
||||
|
||||
let shouldIncludeCandidatesFrom = (id: string) => {
|
||||
if (this.basePath === null) return true
|
||||
|
||||
if (id.startsWith(this.basePath)) return true
|
||||
|
||||
// This is a windows absolute path that doesn't match so return false
|
||||
if (HAS_DRIVE_LETTER.test(id)) return false
|
||||
|
||||
// We've got a path that's not absolute and not on Windows
|
||||
// TODO: this is probably a virtual module -- not sure if we need to scan it
|
||||
if (!id.startsWith('/')) return true
|
||||
|
||||
// This is an absolute path on POSIX and it does not match
|
||||
return false
|
||||
}
|
||||
|
||||
let shared = new Set<string>()
|
||||
|
||||
for (let [id, candidates] of this.getSharedCandidates()) {
|
||||
if (!shouldIncludeCandidatesFrom(id)) continue
|
||||
|
||||
for (let candidate of candidates) {
|
||||
shared.add(candidate)
|
||||
private async requiresBuild(): Promise<boolean> {
|
||||
for (let [path, mtime] of this.buildDependencies) {
|
||||
let stat = await fs.stat(path)
|
||||
if (stat.mtimeMs > mtime) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return shared
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user