Philipp Spiess b38948337d
Make @reference emit variable fallbacks instead of CSS variable declarations (#16774)
Fixes #16725

When using `@reference "tailwindcss";` inside a separate CSS root (e.g.
Svelte `<style>` components, CSS modules, etc.), we have no guarantee
that the CSS variables will be defined in the main stylesheet (or if
there even is one). To work around potential issues with this we decided
in #16676 that we would emit all used CSS variables from the `@theme`
inside the `@reference` block.

However, this is not only a bit surprising but also unexpected in CSS
modules and Next.js that **requires CSS module files to only create
scope-able declarations**. To fix this issue, we decided to not emit CSS
variables but instead ensure all `var(…)` calls we create for theme
values in reference mode will simply have their fallback value added.

This ensures styles work as-expected even if the root Tailwind file does
not pick up the variable as being used or _if you don't add a root at
all_. Furthermore we do not duplicate any variable declarations across
your stylesheets and you still have the ability to change variables at
runtime.

## Test plan

- Updated snapshots everywhere (see diff)
- New Next.js CSS modules integration test
2025-02-25 11:36:43 +01:00

260 lines
6.9 KiB
TypeScript

import { describe } from 'vitest'
import { candidate, css, fetchStyles, js, json, retryAssertion, test } from '../utils'
test(
'production build',
{
fs: {
'package.json': json`
{
"dependencies": {
"react": "^18",
"react-dom": "^18",
"next": "^14"
},
"devDependencies": {
"@tailwindcss/postcss": "workspace:^",
"tailwindcss": "workspace:^"
}
}
`,
'postcss.config.mjs': js`
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}
`,
'next.config.mjs': js`export default {}`,
'app/layout.js': js`
import './globals.css'
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}
`,
'app/page.js': js`
import styles from './page.module.css'
export default function Page() {
return (
<h1 className={styles.heading + ' text-3xl font-bold underline'}>Hello, Next.js!</h1>
)
}
`,
'app/page.module.css': css`
@reference './globals.css';
.heading {
@apply text-red-500 animate-ping;
}
`,
'app/globals.css': css`
@reference 'tailwindcss/theme';
@import 'tailwindcss/utilities';
`,
},
},
async ({ fs, exec, expect }) => {
await exec('pnpm next build')
let files = await fs.glob('.next/static/css/**/*.css')
expect(files).toHaveLength(2)
let globalCss: string | null = null
let moduleCss: string | null = null
for (let [filename, content] of files) {
if (content.includes('@keyframes page_ping')) moduleCss = filename
else globalCss = filename
}
await fs.expectFileToContain(globalCss!, [
candidate`underline`,
candidate`font-bold`,
candidate`text-3xl`,
])
await fs.expectFileToContain(moduleCss!, [
'color:var(--color-red-500,oklch(.637 .237 25.331)',
'animation:var(--animate-ping,ping 1s cubic-bezier(0,0,.2,1) infinite)',
/@keyframes page_ping.*{75%,to{transform:scale\(2\);opacity:0}/,
])
},
)
describe.each(['turbo', 'webpack'])('%s', (bundler) => {
test(
'dev mode',
{
fs: {
'package.json': json`
{
"dependencies": {
"react": "^18",
"react-dom": "^18",
"next": "^14"
},
"devDependencies": {
"@tailwindcss/postcss": "workspace:^",
"tailwindcss": "workspace:^"
}
}
`,
'postcss.config.mjs': js`
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}
`,
'next.config.mjs': js`export default {}`,
'app/layout.js': js`
import './globals.css'
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}
`,
'app/page.js': js`
import styles from './page.module.css'
export default function Page() {
return <h1 className={styles.heading + ' underline'}>Hello, Next.js!</h1>
}
`,
'app/page.module.css': css`
@reference './globals.css';
.heading {
@apply text-red-500 animate-ping content-['module'];
}
`,
'app/globals.css': css`
@reference 'tailwindcss/theme';
@import 'tailwindcss/utilities';
`,
},
},
async ({ fs, spawn, expect }) => {
let process = await spawn(`pnpm next dev ${bundler === 'turbo' ? '--turbo' : ''}`)
let url = ''
await process.onStdout((m) => {
let match = /Local:\s*(http.*)/.exec(m)
if (match) url = match[1]
return Boolean(url)
})
await process.onStdout((m) => m.includes('Ready in'))
await retryAssertion(async () => {
let css = await fetchStyles(url)
expect(css).toContain(candidate`underline`)
expect(css).toContain('content: var(--tw-content)')
expect(css).toContain('@keyframes')
})
await fs.write(
'app/page.js',
js`
import styles from './page.module.css'
export default function Page() {
return <h1 className={styles.heading + ' underline bg-red-500'}>Hello, Next.js!</h1>
}
`,
)
await process.onStdout((m) => m.includes('Compiled in'))
await retryAssertion(async () => {
let css = await fetchStyles(url)
expect(css).toContain(candidate`underline`)
expect(css).toContain(candidate`bg-red-500`)
expect(css).toContain('content: var(--tw-content)')
expect(css).toContain('@keyframes')
})
},
)
})
test(
'should scan dynamic route segments',
{
fs: {
'package.json': json`
{
"dependencies": {
"react": "^18",
"react-dom": "^18",
"next": "^14"
},
"devDependencies": {
"@tailwindcss/postcss": "workspace:^",
"tailwindcss": "workspace:^"
}
}
`,
'postcss.config.mjs': js`
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}
`,
'next.config.mjs': js`export default {}`,
'app/a/[slug]/page.js': js`
export default function Page() {
return <h1 className="content-['[slug]']">Hello, Next.js!</h1>
}
`,
'app/b/[...slug]/page.js': js`
export default function Page() {
return <h1 className="content-['[...slug]']">Hello, Next.js!</h1>
}
`,
'app/c/[[...slug]]/page.js': js`
export default function Page() {
return <h1 className="content-['[[...slug]]']">Hello, Next.js!</h1>
}
`,
'app/d/(theme)/page.js': js`
export default function Page() {
return <h1 className="content-['(theme)']">Hello, Next.js!</h1>
}
`,
'app/layout.js': js`
import './globals.css'
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}
`,
'app/globals.css': css`
@import 'tailwindcss/utilities' source(none);
@source './**/*.{js,ts,jsx,tsx,mdx}';
`,
},
},
async ({ fs, exec, expect }) => {
await exec('pnpm next build')
let files = await fs.glob('.next/static/css/**/*.css')
expect(files).toHaveLength(1)
let [filename] = files[0]
await fs.expectFileToContain(filename, [
candidate`content-['[slug]']`,
candidate`content-['[...slug]']`,
candidate`content-['[[...slug]]']`,
candidate`content-['(theme)']`,
])
},
)