From e578238da5591de69638e15ef75d916c476d8dac Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 28 Aug 2025 16:50:48 +0200 Subject: [PATCH] Migrate `supports` theme keys (#18817) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR is a follow up of #18815 and #18816, but this time let's migrate the `supports` theme keys. Let's imagine you have the following Tailwind CSS v3 configuration: ```ts export default { content: ['./src/**/*.html'], theme: { extend: { supports: { // Automatically handled by bare values (using CSS variable as the value) foo: 'foo: var(--foo)', // parentheses are optional bar: '(bar: var(--bar))', // Not automatically handled because names differ baz: 'qux: var(--foo)', // ^^^ ^^^ ← different names // Custom grid: 'display: grid', }, }, }, } ``` Then we would generate the following Tailwind CSS v4 CSS: ```css @custom-variant supports-baz { @supports (qux: var(--foo)) { @slot; } } @custom-variant supports-grid { @supports (display: grid) { @slot; } } ``` Notice how we didn't generate a custom variant for `data-foo` or `data-bar` because those are automatically handled by bare values. I also went with the longer form of `@custom-variant`, we could use the single selector approach, but that felt less clear to me. ```css @custom-variant supports-baz (@supports (qux: var(--foo))); @custom-variant supports-grid (@supports (display: grid)); ``` --------- Co-authored-by: Jordan Pittman --- CHANGELOG.md | 5 +- integrations/upgrade/js-config.test.ts | 89 +++++++++++++++++++ .../src/codemods/config/migrate-js-config.ts | 40 +++++++-- 3 files changed, 127 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3801ae9d3..cda136124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Discard matched variants with unknown named values ([#18799](https://github.com/tailwindlabs/tailwindcss/pull/18799)) - Discard matched variants with non-string values ([#18799](https://github.com/tailwindlabs/tailwindcss/pull/18799)) - Show suggestions for known `matchVariant` values ([#18798](https://github.com/tailwindlabs/tailwindcss/pull/18798)) -- Migrate `aria` theme keys to `@custom-variant` ([#18815](https://github.com/tailwindlabs/tailwindcss/pull/18815)) -- Migrate `data` theme keys to `@custom-variant` ([#18816](https://github.com/tailwindlabs/tailwindcss/pull/18816)) +- Upgrade: Migrate `aria` theme keys to `@custom-variant` ([#18815](https://github.com/tailwindlabs/tailwindcss/pull/18815)) +- Upgrade: Migrate `data` theme keys to `@custom-variant` ([#18816](https://github.com/tailwindlabs/tailwindcss/pull/18816)) +- Upgrade: Migrate `supports` theme keys to `@custom-variant` ([#18817](https://github.com/tailwindlabs/tailwindcss/pull/18817)) ## [4.1.12] - 2025-08-13 diff --git a/integrations/upgrade/js-config.test.ts b/integrations/upgrade/js-config.test.ts index 808b62013..de10250c2 100644 --- a/integrations/upgrade/js-config.test.ts +++ b/integrations/upgrade/js-config.test.ts @@ -1122,6 +1122,95 @@ test( }, ) +test( + 'migrate supports theme keys to custom variants', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "^3", + "@tailwindcss/upgrade": "workspace:^" + } + } + `, + 'tailwind.config.ts': ts` + export default { + content: { + relative: true, + files: ['./src/**/*.html'], + }, + theme: { + extend: { + supports: { + // Automatically handled by bare values (using CSS variable as the value) + foo: 'foo: var(--foo)', // parentheses are optional + bar: '(bar: var(--bar))', + + // Not automatically handled by bare values because names differ + foo: 'bar: var(--foo)', // parentheses are optional + bar: '(qux: var(--bar))', + + // Custom + grid: 'display: grid', + }, + }, + }, + } + `, + 'src/input.css': css` + @tailwind base; + @tailwind components; + @tailwind utilities; + `, + }, + }, + async ({ exec, fs, expect }) => { + await exec('npx @tailwindcss/upgrade') + + expect(await fs.dumpFiles('src/*.css')).toMatchInlineSnapshot(` + " + --- src/input.css --- + @import 'tailwindcss'; + + @custom-variant supports-foo { + @supports (bar: var(--foo)) { + @slot; + } + } + @custom-variant supports-bar { + @supports ((qux: var(--bar))) { + @slot; + } + } + @custom-variant supports-grid { + @supports (display: grid) { + @slot; + } + } + + /* + The default border color has changed to \`currentcolor\` in Tailwind CSS v4, + so we've added these compatibility styles to make sure everything still + looks the same as it did with Tailwind CSS v3. + + If we ever want to remove these styles, we need to add an explicit border + color utility to any element that depends on these defaults. + */ + @layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentcolor); + } + } + " + `) + }, +) + describe('border compatibility', () => { test( 'migrate border compatibility', diff --git a/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts b/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts index c7fb046f4..24afe56b8 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts @@ -24,6 +24,7 @@ import { isValidOpacityValue, isValidSpacingMultiplier, } from '../../../../tailwindcss/src/utils/infer-data-type' +import * as ValueParser from '../../../../tailwindcss/src/value-parser' import { findStaticPlugins, type StaticPluginOptions } from '../../utils/extract-static-plugins' import { highlight, info, relative } from '../../utils/renderer' @@ -164,6 +165,35 @@ async function migrateTheme( } delete resolvedConfig.theme.data } + + if ('supports' in resolvedConfig.theme) { + for (let [key, value] of Object.entries(resolvedConfig.theme.supports ?? {})) { + // Will be handled by bare values if the value of the declaration is a + // CSS variable. + let parsed = ValueParser.parse(`${value}`) + + // Unwrap the parens, e.g.: `(foo: var(--bar))` → `foo: var(--bar)` + if (parsed.length === 1 && parsed[0].kind === 'function' && parsed[0].value === '') { + parsed = parsed[0].nodes + } + + // Verify structure: `foo: var(--bar)` + // ^^^ ← must match the `key` + if ( + parsed.length === 3 && + parsed[0].kind === 'word' && + parsed[0].value === key && + parsed[2].kind === 'function' && + parsed[2].value === 'var' + ) { + continue + } + + // Create custom variant + variants.set(`supports-${key}`, `{@supports(${value}){@slot;}}`) + } + delete resolvedConfig.theme.supports + } } // Convert theme values to CSS custom properties @@ -242,7 +272,11 @@ async function migrateTheme( if (previousRoot !== root) css += '\n' previousRoot = root - css += `@custom-variant ${name} (${selector});\n` + if (selector.startsWith('{')) { + css += `@custom-variant ${name} ${selector}\n` + } else { + css += `@custom-variant ${name} (${selector});\n` + } } css += '}\n' } @@ -407,15 +441,11 @@ const ALLOWED_THEME_KEYS = [ // Used by @tailwindcss/container-queries 'containers', ] -const BLOCKED_THEME_KEYS = ['supports'] function onlyAllowedThemeValues(theme: ThemeConfig): boolean { for (let key of Object.keys(theme)) { if (!ALLOWED_THEME_KEYS.includes(key)) { return false } - if (BLOCKED_THEME_KEYS.includes(key)) { - return false - } } if ('screens' in theme && typeof theme.screens === 'object' && theme.screens !== null) {