From d6a67beb7660a1eb159f81654cae7a2b87f91b97 Mon Sep 17 00:00:00 2001 From: Adam Wathan Date: Wed, 4 Sep 2024 11:49:50 -0400 Subject: [PATCH] Add `default` option to `@theme` to support overriding default theme values from plugins/JS config files (#14327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds a new `default` option to `@theme` to make it possible for plugins/JS config files to override default theme values, and also ensures that the final set of CSS variables we output takes into account any theme values added by plugins/JS config files. --- Previously, if you were using the default theme but also had a JS config file that overrode any of those defaults like this: ```js // ./tailwind.config.js export default { theme: { extend: { colors: { red: { '500': 'tomato', }, } } } } ``` …then utilities like `text-red-500` would correctly use `color: tomato`, but the `--color-red-500` CSS variable would still be set to the default value: ```css :root { --color-red-500: #ef4444; } ``` This feels like a straight-up bug — if `#ef4444` is not part of your design system because you've overridden it, it shouldn't show up in your set of CSS variables anywhere. So this PR fixes this issue by making sure we don't print the final set of CSS variables until all of your plugins and config files have had a chance to update the theme. --- The second issue is that we realized people have different expectations about how plugin/config theme values should interact with Tailwind's _default_ theme vs. explicitly user-configured theme values. Take this setup for instance: ```css @import "tailwindcss"; @config "./tailwind.config.js"; ``` If `tailwind.config.js` overrides `red-500` to be `tomato`, you'd expect `text-red-500` to actually be `tomato`, not the default `#ef4444` color. But in this setup: ```css @import "tailwindcss"; @config "./tailwind.config.js"; @theme { --color-red-500: #f00; } ``` …you'd expect `text-red-500` to be `#f00`. This is despite the fact that currently in Tailwind there is no difference here — they are both just `@theme` blocks, one just happens to be coming from an imported file (`@import "tailwindcss"`). So to resolve this ambiguity, I've added a `default` option to `@theme` for explicitly registering theme values as "defaults" that are safe to override with plugin/JS config theme values: ```css @import "tailwindcss"; @config "./tailwind.config.js"; @theme default { --color-red-500: #f00; } ``` Now `text-red-500` would be `tomato` here as per the config file. This API is not something users are generally going to interact with — they will almost never want to use `default` explicitly. But in this PR I've updated the default theme we ship with to include `default` so that it interacts in a more intuitive way with plugins and JS config files. --- Finally, this PR makes sure all theme values registered by plugins/configs are registered with `isReference: true` to make sure they do not end up in the final CSS at all. This is important to make sure that the super weird shit we used to do in configs in v3 doesn't get translated into nonsense variables that pollute your output (hello typography plugin I'm looking at you). If we don't do this, you'll end up with CSS variables like this: ```css :root { --typography-sm-css-blockquote-padding-inline-start: 1.25em; } ``` Preventing theme values registered in plugins/configs from outputting CSS values also serves the secondary purpose of nudging users to migrate to the CSS config if they do want CSS variables for their theme values. --------- Co-authored-by: Adam Wathan <4323180+adamwathan@users.noreply.github.com> --- CHANGELOG.md | 2 + .../src/compat/apply-config-to-theme.ts | 3 +- packages/tailwindcss/src/index.test.ts | 209 ++++++++++++++++++ packages/tailwindcss/src/index.ts | 67 +++--- packages/tailwindcss/src/theme.ts | 18 +- packages/tailwindcss/theme.css | 2 +- 6 files changed, 264 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c56a754e..61236b94d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Support TypeScript for `@plugin` and `@config` files ([#14317](https://github.com/tailwindlabs/tailwindcss/pull/14317)) +- Add `default` option to `@theme` to support overriding default theme values from plugins/JS config files ([#14327](https://github.com/tailwindlabs/tailwindcss/pull/14327)) ### Fixed @@ -17,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure CSS `theme()` functions are evaluated in media query ranges with collapsed whitespace ((#14321)[https://github.com/tailwindlabs/tailwindcss/pull/14321]) - Fix support for Nuxt projects in the Vite plugin (requires Nuxt 3.13.1+) ([#14319](https://github.com/tailwindlabs/tailwindcss/pull/14319)) - Evaluate theme functions in plugins and JS config files ([#14326](https://github.com/tailwindlabs/tailwindcss/pull/14326)) +- Ensure theme values overridden with `reference` values don't generate stale CSS variables ([#14327](https://github.com/tailwindlabs/tailwindcss/pull/14327)) ## [4.0.0-alpha.21] - 2024-09-02 diff --git a/packages/tailwindcss/src/compat/apply-config-to-theme.ts b/packages/tailwindcss/src/compat/apply-config-to-theme.ts index e62b2b741..325c37118 100644 --- a/packages/tailwindcss/src/compat/apply-config-to-theme.ts +++ b/packages/tailwindcss/src/compat/apply-config-to-theme.ts @@ -10,7 +10,8 @@ export function applyConfigToTheme(designSystem: DesignSystem, configs: ConfigFi designSystem.theme.add(`--${name}`, value as any, { isInline: true, - isReference: false, + isReference: true, + isDefault: true, }) } diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 9a52d5af4..d4fc57379 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -1292,6 +1292,215 @@ describe('Parsing themes values from CSS', () => { }" `) }) + + test('`default` theme values can be overridden by regular theme values`', async () => { + expect( + await compileCss( + css` + @theme { + --color-potato: #ac855b; + } + @theme default { + --color-potato: #efb46b; + } + + @tailwind utilities; + `, + ['bg-potato'], + ), + ).toMatchInlineSnapshot(` + ":root { + --color-potato: #ac855b; + } + + .bg-potato { + background-color: var(--color-potato, #ac855b); + }" + `) + }) + + test('`default` and `inline` can be used together', async () => { + expect( + await compileCss( + css` + @theme default inline { + --color-potato: #efb46b; + } + + @tailwind utilities; + `, + ['bg-potato'], + ), + ).toMatchInlineSnapshot(` + ":root { + --color-potato: #efb46b; + } + + .bg-potato { + background-color: #efb46b; + }" + `) + }) + + test('`default` and `reference` can be used together', async () => { + expect( + await compileCss( + css` + @theme default reference { + --color-potato: #efb46b; + } + + @tailwind utilities; + `, + ['bg-potato'], + ), + ).toMatchInlineSnapshot(` + ".bg-potato { + background-color: var(--color-potato, #efb46b); + }" + `) + }) + + test('`default`, `inline`, and `reference` can be used together', async () => { + expect( + await compileCss( + css` + @theme default reference inline { + --color-potato: #efb46b; + } + + @tailwind utilities; + `, + ['bg-potato'], + ), + ).toMatchInlineSnapshot(` + ".bg-potato { + background-color: #efb46b; + }" + `) + }) + + test('`default` can be used in `media(…)`', async () => { + expect( + await compileCss( + css` + @media theme() { + @theme { + --color-potato: #ac855b; + } + } + @media theme(default) { + @theme { + --color-potato: #efb46b; + --color-tomato: tomato; + } + } + + @tailwind utilities; + `, + ['bg-potato', 'bg-tomato'], + ), + ).toMatchInlineSnapshot(` + ":root { + --color-potato: #ac855b; + --color-tomato: tomato; + } + + .bg-potato { + background-color: var(--color-potato, #ac855b); + } + + .bg-tomato { + background-color: var(--color-tomato, tomato); + }" + `) + }) + + test('`default` theme values can be overridden by plugin theme values', async () => { + let { build } = await compile( + css` + @theme default { + --color-red: red; + } + @theme { + --color-orange: orange; + } + @plugin "my-plugin"; + @tailwind utilities; + `, + { + loadPlugin: async () => { + return plugin(({}) => {}, { + theme: { + extend: { + colors: { + red: 'tomato', + orange: '#f28500', + }, + }, + }, + }) + }, + }, + ) + + expect(optimizeCss(build(['text-red', 'text-orange'])).trim()).toMatchInlineSnapshot(` + ":root { + --color-orange: orange; + } + + .text-orange { + color: var(--color-orange, orange); + } + + .text-red { + color: tomato; + }" + `) + }) + + test('`default` theme values can be overridden by config theme values', async () => { + let { build } = await compile( + css` + @theme default { + --color-red: red; + } + @theme { + --color-orange: orange; + } + @config "./my-config.js"; + @tailwind utilities; + `, + { + loadConfig: async () => { + return { + theme: { + extend: { + colors: { + red: 'tomato', + orange: '#f28500', + }, + }, + }, + } + }, + }, + ) + + expect(optimizeCss(build(['text-red', 'text-orange'])).trim()).toMatchInlineSnapshot(` + ":root { + --color-orange: orange; + } + + .text-orange { + color: var(--color-orange, orange); + } + + .text-red { + color: tomato; + }" + `) + }) }) describe('plugins', () => { diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 893e83984..e31a82f22 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -28,16 +28,19 @@ function throwOnConfig(): never { function parseThemeOptions(selector: string) { let isReference = false let isInline = false + let isDefault = false for (let option of segment(selector.slice(6) /* '@theme'.length */, ' ')) { if (option === 'reference') { isReference = true } else if (option === 'inline') { isInline = true + } else if (option === 'default') { + isDefault = true } } - return { isReference, isInline } + return { isReference, isInline, isDefault } } async function parseCss( @@ -253,8 +256,8 @@ async function parseCss( 'Files imported with `@import "…" theme(…)` must only contain `@theme` blocks.', ) } - if (child.selector === '@theme') { - child.selector = '@theme ' + themeParams + if (child.selector === '@theme' || child.selector.startsWith('@theme ')) { + child.selector += ' ' + themeParams return WalkAction.Skip } }) @@ -264,7 +267,7 @@ async function parseCss( if (node.selector !== '@theme' && !node.selector.startsWith('@theme ')) return - let { isReference, isInline } = parseThemeOptions(node.selector) + let { isReference, isInline, isDefault } = parseThemeOptions(node.selector) // Record all custom properties in the `@theme` declaration walk(node.nodes, (child, { replaceWith }) => { @@ -278,7 +281,7 @@ async function parseCss( if (child.kind === 'comment') return if (child.kind === 'declaration' && child.property.startsWith('--')) { - theme.add(child.property, child.value ?? '', { isReference, isInline }) + theme.add(child.property, child.value ?? '', { isReference, isInline, isDefault }) return } @@ -302,6 +305,33 @@ async function parseCss( return WalkAction.Skip }) + let designSystem = buildDesignSystem(theme) + + let configs = await Promise.all( + configPaths.map(async (configPath) => ({ + path: configPath, + config: await loadConfig(configPath), + })), + ) + + let plugins = await Promise.all( + pluginPaths.map(async ([pluginPath, pluginOptions]) => ({ + path: pluginPath, + plugin: await loadPlugin(pluginPath), + options: pluginOptions, + })), + ) + + let { pluginApi, resolvedConfig } = registerPlugins(plugins, designSystem, ast, configs) + + for (let customVariant of customVariants) { + customVariant(designSystem) + } + + for (let customUtility of customUtilities) { + customUtility(designSystem) + } + // Output final set of theme variables at the position of the first `@theme` // rule. if (firstThemeRule) { @@ -340,33 +370,6 @@ async function parseCss( firstThemeRule.nodes = nodes } - let designSystem = buildDesignSystem(theme) - - let configs = await Promise.all( - configPaths.map(async (configPath) => ({ - path: configPath, - config: await loadConfig(configPath), - })), - ) - - let plugins = await Promise.all( - pluginPaths.map(async ([pluginPath, pluginOptions]) => ({ - path: pluginPath, - plugin: await loadPlugin(pluginPath), - options: pluginOptions, - })), - ) - - let { pluginApi, resolvedConfig } = registerPlugins(plugins, designSystem, ast, configs) - - for (let customVariant of customVariants) { - customVariant(designSystem) - } - - for (let customUtility of customUtilities) { - customUtility(designSystem) - } - // Replace `@apply` rules with the actual utility classes. if (css.includes('@apply')) { substituteAtApply(ast, designSystem) diff --git a/packages/tailwindcss/src/theme.ts b/packages/tailwindcss/src/theme.ts index 178472b96..2a7bdb80e 100644 --- a/packages/tailwindcss/src/theme.ts +++ b/packages/tailwindcss/src/theme.ts @@ -2,10 +2,17 @@ import { escape } from './utils/escape' export class Theme { constructor( - private values = new Map(), + private values = new Map< + string, + { value: string; isReference: boolean; isInline: boolean; isDefault: boolean } + >(), ) {} - add(key: string, value: string, { isReference = false, isInline = false } = {}): void { + add( + key: string, + value: string, + { isReference = false, isInline = false, isDefault = false } = {}, + ): void { if (key.endsWith('-*')) { if (value !== 'initial') { throw new Error(`Invalid theme value \`${value}\` for namespace \`${key}\``) @@ -17,10 +24,15 @@ export class Theme { } } + if (isDefault) { + let existing = this.values.get(key) + if (existing && !existing.isDefault) return + } + if (value === 'initial') { this.values.delete(key) } else { - this.values.set(key, { value, isReference, isInline }) + this.values.set(key, { value, isReference, isInline, isDefault }) } } diff --git a/packages/tailwindcss/theme.css b/packages/tailwindcss/theme.css index 190e51f09..a6de6e84a 100644 --- a/packages/tailwindcss/theme.css +++ b/packages/tailwindcss/theme.css @@ -1,4 +1,4 @@ -@theme { +@theme default { /* Defaults */ --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);