Vite: Don't track candidate changes for Svelte <style> tags (#14981)
Closes #14965 This PR changes the way we register Tailwind CSS as a Svelte preprocessor when using the Vite plugin. The idea is to reduce the bookkeeping for interacting with CSS inside `<style>` tags so that we have a more consistent behavior and make sure the Svelte-specific post-processing (e.g. local class mangling) works as expected. Prior to this change, we were running Tailwind CSS as a Svelte preprocessor and then we would transform the file again when necessary inside the Vite `transform` hook. This is necessary to have the right list of candidates when we build the final CSS, but it did cause some situation to not apply the Svelte post-processors anymore. The repro for this seemed to indicate a timing specific issue and I did notice that specifically the code where we invalidate modules in Vite would cause unexpected processing orders. We do, however, not officially support rendering utilities (`@tailwind utilities;`) inside `<style>` tag. This is because the `<style>` block is scoped by default and emitting utilities will always include utilities for all classes in your whole project. For this case, we highly recommend creating as separate `.css` file and importing it explicitly. With this limitation in place, the additional bookkeeping where we need to invalidate modules because the candidate list has changed is no longer necessary and removing it allows us to reduce the complexity of the Svelte integration. ## Test Plan https://github.com/user-attachments/assets/32c8e91f-ab21-48c6-aeaf-2582273b9bac Not seen in the test plan above I also tested the `pnpm build --watch` step of the Vite project. This does require the `pnpm preview` server to restart but the build artifact are updated as expected.
This commit is contained in:
parent
13f05e2711
commit
dda181b833
4 changed files with 197 additions and 75 deletions
|
|
@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- Support opacity values in increments of `0.25` by default ([#14980](https://github.com/tailwindlabs/tailwindcss/pull/14980))
|
||||
- Support specifying the color interpolation method for gradients via modifier ([#14984](https://github.com/tailwindlabs/tailwindcss/pull/14984))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Ensure that CSS inside Svelte `<style>` blocks always run the expected Svelte processors when using the Vite extension ([#14981](https://github.com/tailwindlabs/tailwindcss/pull/14981))
|
||||
|
||||
## [4.0.0-alpha.33] - 2024-11-11
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ interface TestContext {
|
|||
dumpFiles(pattern: string): Promise<string>
|
||||
expectFileToContain(
|
||||
filePath: string,
|
||||
contents: string | string[] | RegExp | RegExp[],
|
||||
contents: string | RegExp | (string | RegExp)[],
|
||||
): Promise<void>
|
||||
expectFileNotToContain(filePath: string, contents: string | string[]): Promise<void>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,22 +48,50 @@ test(
|
|||
target: document.body,
|
||||
})
|
||||
`,
|
||||
'src/index.css': css`
|
||||
@import 'tailwindcss/theme' theme(reference);
|
||||
@import 'tailwindcss/utilities';
|
||||
`,
|
||||
'src/App.svelte': html`
|
||||
<script>
|
||||
import './index.css'
|
||||
let name = 'world'
|
||||
</script>
|
||||
|
||||
<h1 class="foo underline">Hello {name}!</h1>
|
||||
<h1 class="global local underline">Hello {name}!</h1>
|
||||
|
||||
<style global>
|
||||
@import 'tailwindcss/utilities';
|
||||
<style>
|
||||
@import 'tailwindcss/theme' theme(reference);
|
||||
@import './components.css';
|
||||
@import './other.css';
|
||||
</style>
|
||||
`,
|
||||
'src/components.css': css`
|
||||
.foo {
|
||||
'src/other.css': css`
|
||||
.local {
|
||||
@apply text-red-500;
|
||||
animation: 2s ease-in-out 0s infinite localKeyframes;
|
||||
}
|
||||
|
||||
:global(.global) {
|
||||
@apply text-green-500;
|
||||
animation: 2s ease-in-out 0s infinite globalKeyframes;
|
||||
}
|
||||
|
||||
@keyframes -global-globalKeyframes {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes localKeyframes {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 100%;
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
|
|
@ -74,7 +102,13 @@ test(
|
|||
let files = await fs.glob('dist/**/*.css')
|
||||
expect(files).toHaveLength(1)
|
||||
|
||||
await fs.expectFileToContain(files[0][0], [candidate`underline`, candidate`foo`])
|
||||
await fs.expectFileToContain(files[0][0], [
|
||||
candidate`underline`,
|
||||
'.global{color:var(--color-green-500);animation:2s ease-in-out 0s infinite globalKeyframes}',
|
||||
/\.local.svelte-.*\{color:var\(--color-red-500\);animation:2s ease-in-out 0s infinite svelte-.*-localKeyframes\}/,
|
||||
/@keyframes globalKeyframes\{/,
|
||||
/@keyframes svelte-.*-localKeyframes\{/,
|
||||
])
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -127,20 +161,48 @@ test(
|
|||
`,
|
||||
'src/App.svelte': html`
|
||||
<script>
|
||||
import './index.css'
|
||||
let name = 'world'
|
||||
</script>
|
||||
|
||||
<h1 class="foo underline">Hello {name}!</h1>
|
||||
<h1 class="local global underline">Hello {name}!</h1>
|
||||
|
||||
<style global>
|
||||
@import 'tailwindcss/utilities';
|
||||
<style>
|
||||
@import 'tailwindcss/theme' theme(reference);
|
||||
@import './components.css';
|
||||
@import './other.css';
|
||||
</style>
|
||||
`,
|
||||
'src/components.css': css`
|
||||
.foo {
|
||||
'src/index.css': css`
|
||||
@import 'tailwindcss/theme' theme(reference);
|
||||
@import 'tailwindcss/utilities';
|
||||
`,
|
||||
'src/other.css': css`
|
||||
.local {
|
||||
@apply text-red-500;
|
||||
animation: 2s ease-in-out 0s infinite localKeyframes;
|
||||
}
|
||||
|
||||
:global(.global) {
|
||||
@apply text-green-500;
|
||||
animation: 2s ease-in-out 0s infinite globalKeyframes;
|
||||
}
|
||||
|
||||
@keyframes -global-globalKeyframes {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes localKeyframes {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 100%;
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
|
|
@ -148,30 +210,45 @@ test(
|
|||
async ({ fs, spawn }) => {
|
||||
await spawn(`pnpm vite build --watch`)
|
||||
|
||||
let filename = ''
|
||||
await retryAssertion(async () => {
|
||||
let files = await fs.glob('dist/**/*.css')
|
||||
expect(files).toHaveLength(1)
|
||||
filename = files[0][0]
|
||||
})
|
||||
|
||||
await fs.expectFileToContain(filename, [candidate`foo`, candidate`underline`])
|
||||
|
||||
await fs.write(
|
||||
'src/components.css',
|
||||
css`
|
||||
.bar {
|
||||
@apply text-green-500;
|
||||
}
|
||||
`,
|
||||
)
|
||||
await retryAssertion(async () => {
|
||||
let files = await fs.glob('dist/**/*.css')
|
||||
expect(files).toHaveLength(1)
|
||||
let [, css] = files[0]
|
||||
expect(css).toContain(candidate`underline`)
|
||||
expect(css).toContain(candidate`bar`)
|
||||
expect(css).not.toContain(candidate`foo`)
|
||||
expect(css).toContain(
|
||||
'.global{color:var(--color-green-500);animation:2s ease-in-out 0s infinite globalKeyframes}',
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/\.local.svelte-.*\{color:var\(--color-red-500\);animation:2s ease-in-out 0s infinite svelte-.*-localKeyframes\}/,
|
||||
)
|
||||
expect(css).toMatch(/@keyframes globalKeyframes\{/)
|
||||
expect(css).toMatch(/@keyframes svelte-.*-localKeyframes\{/)
|
||||
})
|
||||
|
||||
await fs.write(
|
||||
'src/App.svelte',
|
||||
(await fs.read('src/App.svelte')).replace('underline', 'font-bold bar'),
|
||||
)
|
||||
|
||||
await fs.write(
|
||||
'src/other.css',
|
||||
`${await fs.read('src/other.css')}\n.bar { @apply text-pink-500; }`,
|
||||
)
|
||||
|
||||
await retryAssertion(async () => {
|
||||
let files = await fs.glob('dist/**/*.css')
|
||||
expect(files).toHaveLength(1)
|
||||
let [, css] = files[0]
|
||||
expect(css).toContain(candidate`font-bold`)
|
||||
expect(css).toContain(
|
||||
'.global{color:var(--color-green-500);animation:2s ease-in-out 0s infinite globalKeyframes}',
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/\.local.svelte-.*\{color:var\(--color-red-500\);animation:2s ease-in-out 0s infinite svelte-.*-localKeyframes\}/,
|
||||
)
|
||||
expect(css).toMatch(/@keyframes globalKeyframes\{/)
|
||||
expect(css).toMatch(/@keyframes svelte-.*-localKeyframes\{/)
|
||||
expect(css).toMatch(/\.bar.svelte-.*\{color:var\(--color-pink-500\)\}/)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export default function tailwindcss(): Plugin[] {
|
|||
if (!module) {
|
||||
// The module for this root might not exist yet
|
||||
if (root.builtBeforeTransform) {
|
||||
return
|
||||
continue
|
||||
}
|
||||
|
||||
// Note: Removing this during SSR is not safe and will produce
|
||||
|
|
@ -196,17 +196,17 @@ export default function tailwindcss(): Plugin[] {
|
|||
|
||||
let root = roots.get(id)
|
||||
|
||||
// If the root was built outside of the transform hook (e.g. in the
|
||||
// Svelte preprocessor), we still want to mark all dependencies of the
|
||||
// root as watched files.
|
||||
if (root.builtBeforeTransform) {
|
||||
root.builtBeforeTransform.forEach((file) => this.addWatchFile(file))
|
||||
root.builtBeforeTransform = undefined
|
||||
// When a root was built before this transform hook, the candidate
|
||||
// list might be outdated already by the time the transform hook is
|
||||
// called.
|
||||
//
|
||||
// This requires us to build the CSS file again. However, we do not
|
||||
// expect dependencies to have changed, so we can avoid a full
|
||||
// rebuild.
|
||||
root.requiresRebuild = false
|
||||
}
|
||||
|
||||
// We only process Svelte `<style>` tags in the `sveltePreprocessor`
|
||||
if (isSvelteStyle(id)) {
|
||||
return src
|
||||
}
|
||||
|
||||
if (!options?.ssr) {
|
||||
|
|
@ -240,16 +240,17 @@ export default function tailwindcss(): Plugin[] {
|
|||
|
||||
let root = roots.get(id)
|
||||
|
||||
// If the root was built outside of the transform hook (e.g. in the
|
||||
// Svelte preprocessor), we still want to mark all dependencies of the
|
||||
// root as watched files.
|
||||
if (root.builtBeforeTransform) {
|
||||
root.builtBeforeTransform.forEach((file) => this.addWatchFile(file))
|
||||
root.builtBeforeTransform = undefined
|
||||
// When a root was built before this transform hook, the candidate
|
||||
// list might be outdated already by the time the transform hook is
|
||||
// called.
|
||||
//
|
||||
// Since we already do a second render pass in build mode, we don't
|
||||
// need to do any more work here.
|
||||
return
|
||||
}
|
||||
|
||||
// We only process Svelte `<style>` tags in the `sveltePreprocessor`
|
||||
if (isSvelteStyle(id)) {
|
||||
return src
|
||||
}
|
||||
|
||||
// We do a first pass to generate valid CSS for the downstream plugins.
|
||||
|
|
@ -268,6 +269,9 @@ export default function tailwindcss(): Plugin[] {
|
|||
// by vite:css-post.
|
||||
async renderStart() {
|
||||
for (let [id, root] of roots.entries()) {
|
||||
// Do not do a second render pass on Svelte `<style>` tags.
|
||||
if (isSvelteStyle(id)) continue
|
||||
|
||||
let generated = await regenerateOptimizedCss(
|
||||
root,
|
||||
// During the renderStart phase, we can not add watch files since
|
||||
|
|
@ -304,13 +308,20 @@ function isPotentialCssRootFile(id: string) {
|
|||
(extension === 'css' ||
|
||||
(extension === 'vue' && id.includes('&lang.css')) ||
|
||||
(extension === 'astro' && id.includes('&lang.css')) ||
|
||||
(extension === 'svelte' && id.includes('&lang.css'))) &&
|
||||
// We want to process Svelte `<style>` tags to properly add dependency
|
||||
// tracking for imported files.
|
||||
isSvelteStyle(id)) &&
|
||||
// Don't intercept special static asset resources
|
||||
!SPECIAL_QUERY_RE.test(id)
|
||||
|
||||
return isCssFile
|
||||
}
|
||||
|
||||
function isSvelteStyle(id: string) {
|
||||
let extension = getExtension(id)
|
||||
return extension === 'svelte' && id.includes('&lang.css')
|
||||
}
|
||||
|
||||
function optimizeCss(
|
||||
input: string,
|
||||
{ file = 'input.css', minify = false }: { file?: string; minify?: boolean } = {},
|
||||
|
|
@ -403,6 +414,8 @@ class Root {
|
|||
// The resolved path given to `source(…)`. When not given this is `null`.
|
||||
private basePath: string | null = null
|
||||
|
||||
public overwriteCandidates: string[] | null = null
|
||||
|
||||
constructor(
|
||||
private id: string,
|
||||
private getSharedCandidates: () => Map<string, Set<string>>,
|
||||
|
|
@ -453,14 +466,16 @@ class Root {
|
|||
this.scanner = new Scanner({ sources })
|
||||
}
|
||||
|
||||
// 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).
|
||||
env.DEBUG && console.time('[@tailwindcss/vite] Scan for candidates')
|
||||
for (let candidate of this.scanner.scan()) {
|
||||
this.candidates.add(candidate)
|
||||
if (!this.overwriteCandidates) {
|
||||
// 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).
|
||||
env.DEBUG && console.time('[@tailwindcss/vite] Scan for candidates')
|
||||
for (let candidate of this.scanner.scan()) {
|
||||
this.candidates.add(candidate)
|
||||
}
|
||||
env.DEBUG && console.timeEnd('[@tailwindcss/vite] Scan for candidates')
|
||||
}
|
||||
env.DEBUG && console.timeEnd('[@tailwindcss/vite] Scan for candidates')
|
||||
|
||||
// Watch individual files found via custom `@source` paths
|
||||
for (let file of this.scanner.files) {
|
||||
|
|
@ -506,7 +521,11 @@ class Root {
|
|||
this.requiresRebuild = true
|
||||
|
||||
env.DEBUG && console.time('[@tailwindcss/vite] Build CSS')
|
||||
let result = this.compiler.build([...this.sharedCandidates(), ...this.candidates])
|
||||
let result = this.compiler.build(
|
||||
this.overwriteCandidates
|
||||
? this.overwriteCandidates
|
||||
: [...this.sharedCandidates(), ...this.candidates],
|
||||
)
|
||||
env.DEBUG && console.timeEnd('[@tailwindcss/vite] Build CSS')
|
||||
|
||||
return result
|
||||
|
|
@ -552,50 +571,72 @@ class Root {
|
|||
// enabled. This allows us to transform CSS in `<style>` tags and create a
|
||||
// stricter version of CSS that passes the Svelte compiler.
|
||||
//
|
||||
// Note that these files will undergo a second pass through the vite transpiler
|
||||
// later. This is necessary to compute `@tailwind utilities;` with the right
|
||||
// candidate list.
|
||||
// Note that these files will not undergo a second pass through the vite
|
||||
// transpiler later. This means that `@tailwind utilities;` will not be up to
|
||||
// date.
|
||||
//
|
||||
// In practice, it is not recommended to use `@tailwind utilities;` inside
|
||||
// Svelte components. Use an external `.css` file instead.
|
||||
// In practice, it is discouraged to use `@tailwind utilities;` inside Svelte
|
||||
// components, as the styles it create would be scoped anyways. Use an external
|
||||
// `.css` file instead.
|
||||
function svelteProcessor(roots: DefaultMap<string, Root>) {
|
||||
let preprocessor = sveltePreprocess()
|
||||
|
||||
return {
|
||||
name: '@tailwindcss/svelte',
|
||||
api: {
|
||||
sveltePreprocess: sveltePreprocess({
|
||||
aliases: [
|
||||
['postcss', 'tailwindcss'],
|
||||
['css', 'tailwindcss'],
|
||||
],
|
||||
async tailwindcss({
|
||||
sveltePreprocess: {
|
||||
markup: preprocessor.markup,
|
||||
script: preprocessor.script,
|
||||
async style({
|
||||
content,
|
||||
attributes,
|
||||
filename,
|
||||
markup,
|
||||
...rest
|
||||
}: {
|
||||
content: string
|
||||
attributes: Record<string, string>
|
||||
filename?: string
|
||||
attributes: Record<string, string | boolean>
|
||||
markup: string
|
||||
}) {
|
||||
if (!filename) return
|
||||
if (!filename) return preprocessor.style?.({ ...rest, content, filename, markup })
|
||||
|
||||
// Create the ID used by Vite to identify the `<style>` contents. This
|
||||
// way, the Vite `transform` hook can find the right root and thus
|
||||
// track the right dependencies.
|
||||
let id = filename + '?svelte&type=style&lang.css'
|
||||
|
||||
let root = roots.get(id)
|
||||
|
||||
// Since a Svelte pre-processor call means that the CSS has changed,
|
||||
// we need to trigger a rebuild.
|
||||
root.requiresRebuild = true
|
||||
|
||||
// Mark this root as being built before the Vite transform hook is
|
||||
// called. We capture all eventually added dependencies so that we can
|
||||
// connect them to the vite module graph later, when the transform
|
||||
// hook is called.
|
||||
root.builtBeforeTransform = []
|
||||
|
||||
// We only want to consider candidates from the current template file,
|
||||
// this ensures that no one can depend on this having the full candidate
|
||||
// list in some builds (as this is undefined behavior).
|
||||
let scanner = new Scanner({})
|
||||
root.overwriteCandidates = scanner.scanFiles([
|
||||
{ content: markup, file: filename, extension: 'svelte' },
|
||||
])
|
||||
|
||||
let generated = await root.generate(content, (file) =>
|
||||
root?.builtBeforeTransform?.push(file),
|
||||
)
|
||||
|
||||
if (!generated) {
|
||||
roots.delete(id)
|
||||
return { code: content, attributes }
|
||||
return preprocessor.style?.({ ...rest, content, filename, markup })
|
||||
}
|
||||
return { code: generated, attributes }
|
||||
|
||||
return preprocessor.style?.({ ...rest, content: generated, filename, markup })
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue