From 81c883d0350f4c11392fadb2c7360c481391fbbe Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 27 Aug 2025 14:52:42 +0200 Subject: [PATCH 1/5] update error messages when using `addComponents` or `matchComponents` Nothing new here, but this improves the error messages a bit when using `addComponents` or `matchComponents` because otherwise the error shows `addUtilities` or `matchUtilities`. These also add an additional note: > Note: in Tailwind CSS v4, `matchComponents` is an alias for `matchUtilities`. --- .../tailwindcss/src/compat/plugin-api.test.ts | 68 +++++++++++++++++++ packages/tailwindcss/src/compat/plugin-api.ts | 28 ++++++-- 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/packages/tailwindcss/src/compat/plugin-api.test.ts b/packages/tailwindcss/src/compat/plugin-api.test.ts index 4e35afa5a84e..17cf8c72418e 100644 --- a/packages/tailwindcss/src/compat/plugin-api.test.ts +++ b/packages/tailwindcss/src/compat/plugin-api.test.ts @@ -4444,6 +4444,43 @@ describe('addComponents()', () => { }" `) }) + + test('throws on custom static utilities with an invalid name', async () => { + await expect(() => { + return compile( + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + + @theme reference { + --breakpoint-lg: 1024px; + } + `, + { + async loadModule(id, base) { + return { + path: '', + base, + module: ({ addComponents }: PluginAPI) => { + addComponents({ + ':hover > *': { + 'text-box-trim': 'both', + 'text-box-edge': 'cap alphabetic', + }, + }) + }, + } + }, + }, + ) + }).rejects.toThrowErrorMatchingInlineSnapshot(` + [Error: \`addComponents({ ':hover > *': … })\` defines an invalid utility selector. Components must be a single class name and start with a lowercase letter, eg. \`.scrollbar-none\`. + + Note: in Tailwind CSS v4 \`addComponents\` is an alias for \`addUtilities\`.] + `) + }) }) describe('matchComponents()', () => { @@ -4490,6 +4527,37 @@ describe('matchComponents()', () => { }" `) }) + + test('throws on custom utilities with an invalid name', async () => { + await expect(() => { + return compile( + css` + @plugin "my-plugin"; + @tailwind utilities; + `, + { + async loadModule(id, base) { + return { + path: '', + base, + module: ({ matchComponents }: PluginAPI) => { + matchComponents({ + '.text-trim > *': () => ({ + 'text-box-trim': 'both', + 'text-box-edge': 'cap alphabetic', + }), + }) + }, + } + }, + }, + ) + }).rejects.toThrowErrorMatchingInlineSnapshot(` + [Error: \`matchComponents({ '.text-trim > *': … })\` defines an invalid utility name. Components should be alphanumeric and start with a lowercase letter, eg. \`scrollbar\`. + + Note: in Tailwind CSS v4 \`matchComponents\` is an alias for \`matchUtilities\`.] + `) + }) }) describe('prefix()', () => { diff --git a/packages/tailwindcss/src/compat/plugin-api.ts b/packages/tailwindcss/src/compat/plugin-api.ts index c52693ef073e..b526260e60d7 100644 --- a/packages/tailwindcss/src/compat/plugin-api.ts +++ b/packages/tailwindcss/src/compat/plugin-api.ts @@ -307,7 +307,7 @@ export function buildPluginApi({ if (!foundValidUtility) { throw new Error( - `\`addUtilities({ '${name}' : … })\` defines an invalid utility selector. Utilities must be a single class name and start with a lowercase letter, eg. \`.scrollbar-none\`.`, + `\`addUtilities({ '${name}': … })\` defines an invalid utility selector. Utilities must be a single class name and start with a lowercase letter, eg. \`.scrollbar-none\`.`, ) } } @@ -347,7 +347,7 @@ export function buildPluginApi({ for (let [name, fn] of Object.entries(utilities)) { if (!IS_VALID_UTILITY_NAME.test(name)) { throw new Error( - `\`matchUtilities({ '${name}' : … })\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter, eg. \`scrollbar\`.`, + `\`matchUtilities({ '${name}': … })\` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter, eg. \`scrollbar\`.`, ) } @@ -493,11 +493,31 @@ export function buildPluginApi({ }, addComponents(components, options) { - this.addUtilities(components, options) + try { + this.addUtilities(components, options) + } catch (e) { + if (e instanceof Error) { + throw new Error( + `${e.message.replaceAll('addUtilities', 'addComponents').replaceAll('Utilities', 'Components')}\n\nNote: in Tailwind CSS v4 \`addComponents\` is an alias for \`addUtilities\`.`, + ) + } else { + throw e + } + } }, matchComponents(components, options) { - this.matchUtilities(components, options) + try { + this.matchUtilities(components, options) + } catch (e) { + if (e instanceof Error) { + throw new Error( + `${e.message.replaceAll('matchUtilities', 'matchComponents').replaceAll('Utilities', 'Components')}\n\nNote: in Tailwind CSS v4 \`matchComponents\` is an alias for \`matchUtilities\`.`, + ) + } else { + throw e + } + } }, theme: createThemeFn( From 8bda1db8c84991bb9efd97f83ec47f98680d9eea Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 27 Aug 2025 17:15:26 +0200 Subject: [PATCH 2/5] do not assume `source` is an object Otherwise `Reflect.ownKeys` would crash on non-object values. Co-Authored-By: Jordan Pittman --- packages/tailwindcss/src/compat/config/deep-merge.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/compat/config/deep-merge.ts b/packages/tailwindcss/src/compat/config/deep-merge.ts index 2bf15c9b3b00..3365a068b5bd 100644 --- a/packages/tailwindcss/src/compat/config/deep-merge.ts +++ b/packages/tailwindcss/src/compat/config/deep-merge.ts @@ -17,7 +17,7 @@ export function deepMerge( type Value = T[Key] for (let source of sources) { - if (source === null || source === undefined) { + if (source === null || source === undefined || typeof source !== 'object') { continue } From a3e5813eec00d7421b971e56f36daa73b935026b Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 27 Aug 2025 17:29:47 +0200 Subject: [PATCH 3/5] show object keys that result in failing JS migration Before this, we would just show "Cannot migrate"-like error messages. But this will show a bit more detail about which theme keys are the culprit. Co-Authored-By: Jordan Pittman --- .../src/codemods/config/migrate-js-config.ts | 90 ++++++++++++------- 1 file changed, 56 insertions(+), 34 deletions(-) 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 24afe56b85b5..e4de43a14198 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts @@ -26,7 +26,7 @@ import { } 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' +import { highlight, info, relative, warn } from '../../utils/renderer' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -49,11 +49,15 @@ export async function migrateJsConfig( fs.readFile(fullConfigPath, 'utf-8'), ]) - if (!canMigrateConfig(unresolvedConfig, source)) { + let canMigrateConfigResult = canMigrateConfig(unresolvedConfig, source) + if (!canMigrateConfigResult.valid) { info( `The configuration file at ${highlight(relative(fullConfigPath, base))} could not be automatically migrated to the new CSS configuration format, so your CSS has been updated to load your existing configuration file.`, { prefix: '↳ ' }, ) + for (let msg of canMigrateConfigResult.errors) { + warn(msg, { prefix: ' ↳ ' }) + } return null } @@ -384,22 +388,34 @@ async function migrateContent( return sources } -// Applies heuristics to determine if we can attempt to migrate the config -function canMigrateConfig(unresolvedConfig: Config, source: string): boolean { - // The file may not contain non-serializable values - function isSimpleValue(value: unknown): boolean { - if (typeof value === 'function') return false - if (Array.isArray(value)) return value.every(isSimpleValue) - if (typeof value === 'object' && value !== null) { - return Object.values(value).every(isSimpleValue) +const JS_IDENTIFIER_REGEX = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/ +function stringifyPath(path: (string | number)[]): string { + let result = '' + + for (let segment of path) { + if (typeof segment === 'number') { + result += `[${segment}]` + } else if (!JS_IDENTIFIER_REGEX.test(segment)) { + result += `[\`${segment}\`]` + } else { + result += result.length > 0 ? `.${segment}` : segment } - return ['string', 'number', 'boolean', 'undefined'].includes(typeof value) } - // `theme` and `plugins` are handled separately and allowed to be more complex - let { plugins, theme, ...remainder } = unresolvedConfig - if (!isSimpleValue(remainder)) { - return false + return result +} + +// Applies heuristics to determine if we can attempt to migrate the config +function canMigrateConfig( + unresolvedConfig: Config, + source: string, +): { valid: true } | { valid: false; errors: string[] } { + let theme = unresolvedConfig.theme + let errors: string[] = [] + + // Migrating presets are not supported + if (unresolvedConfig.presets && unresolvedConfig.presets.length > 0) { + errors.push('Cannot migrate config files that use presets') } // The file may only contain known-migratable top-level properties @@ -413,27 +429,28 @@ function canMigrateConfig(unresolvedConfig: Config, source: string): boolean { 'corePlugins', ] - if (Object.keys(unresolvedConfig).some((key) => !knownProperties.includes(key))) { - return false - } - - if (findStaticPlugins(source) === null) { - return false - } - - if (unresolvedConfig.presets && unresolvedConfig.presets.length > 0) { - return false + for (let key of Object.keys(unresolvedConfig)) { + if (!knownProperties.includes(key)) { + errors.push(`Cannot migrate unknown top-level key: \`${key}\``) + } } // Only migrate the config file if all top-level theme keys are allowed to be // migrated if (theme && typeof theme === 'object') { - if (theme.extend && !onlyAllowedThemeValues(theme.extend)) return false - let { extend: _extend, ...themeCopy } = theme - if (!onlyAllowedThemeValues(themeCopy)) return false + let { extend, ...themeCopy } = theme + errors.push(...onlyAllowedThemeValues(themeCopy, ['theme'])) + + if (extend) { + errors.push(...onlyAllowedThemeValues(extend, ['theme', 'extend'])) + } } - return true + // TODO: findStaticPlugins already logs errors for unsupported plugins, maybe + // it should return them instead? + findStaticPlugins(source) + + return errors.length <= 0 ? { valid: true } : { valid: false, errors } } const ALLOWED_THEME_KEYS = [ @@ -441,21 +458,26 @@ const ALLOWED_THEME_KEYS = [ // Used by @tailwindcss/container-queries 'containers', ] -function onlyAllowedThemeValues(theme: ThemeConfig): boolean { +function onlyAllowedThemeValues(theme: ThemeConfig, path: (string | number)[]): string[] { + let errors: string[] = [] + for (let key of Object.keys(theme)) { if (!ALLOWED_THEME_KEYS.includes(key)) { - return false + errors.push(`Cannot migrate theme key: \`${stringifyPath([...path, key])}\``) } } if ('screens' in theme && typeof theme.screens === 'object' && theme.screens !== null) { - for (let screen of Object.values(theme.screens)) { + for (let [name, screen] of Object.entries(theme.screens)) { if (typeof screen === 'object' && screen !== null && ('max' in screen || 'raw' in screen)) { - return false + errors.push( + `Cannot migrate complex screen definition: \`${stringifyPath([...path, 'screens', name])}\``, + ) } } } - return true + + return errors } function keyframesToCss(keyframes: Record): string { From be10cf44747486c6bf894d9d16258e4c15406c43 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 27 Aug 2025 18:01:54 +0200 Subject: [PATCH 4/5] categorize JS migration issues Co-Authored-By: Jordan Pittman --- .../src/codemods/config/migrate-js-config.ts | 72 +++++++++++++------ 1 file changed, 51 insertions(+), 21 deletions(-) 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 e4de43a14198..b3bec06bb78e 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/config/migrate-js-config.ts @@ -19,6 +19,7 @@ import { buildCustomContainerUtilityRules } from '../../../../tailwindcss/src/co import { darkModePlugin } from '../../../../tailwindcss/src/compat/dark-mode' import type { Config } from '../../../../tailwindcss/src/compat/plugin-api' import type { DesignSystem } from '../../../../tailwindcss/src/design-system' +import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map' import { escape } from '../../../../tailwindcss/src/utils/escape' import { isValidOpacityValue, @@ -55,8 +56,44 @@ export async function migrateJsConfig( `The configuration file at ${highlight(relative(fullConfigPath, base))} could not be automatically migrated to the new CSS configuration format, so your CSS has been updated to load your existing configuration file.`, { prefix: '↳ ' }, ) - for (let msg of canMigrateConfigResult.errors) { - warn(msg, { prefix: ' ↳ ' }) + for (let [category, messages] of canMigrateConfigResult.errors.entries()) { + switch (category) { + case 'general': { + for (let msg of messages) warn(msg, { prefix: ' ↳ ' }) + break + } + case 'top-level': { + warn( + `Cannot migrate unknown top-level keys:\n${Array.from(messages) + .map((key) => ` - \`${key}\``) + .join( + '\n', + )}\n\nThese are non-standard Tailwind CSS options, so we don't know how to migrate them to CSS.`, + { prefix: ' ↳ ' }, + ) + break + } + case 'unknown-theme-keys': { + warn( + `Cannot migrate unknown theme keys:\n${Array.from(messages) + .map((key) => ` - \`${key}\``) + .join( + '\n', + )}\n\nThese are non-standard Tailwind CSS theme keys, so we don't know how to migrate them to CSS.`, + { prefix: ' ↳ ' }, + ) + break + } + case 'complex-screens': { + warn( + `Cannot migrate complex screen configuration (min/max/raw):\n${Array.from(messages) + .map((key) => ` - \`${key}\``) + .join('\n')}`, + { prefix: ' ↳ ' }, + ) + break + } + } } return null } @@ -405,17 +442,19 @@ function stringifyPath(path: (string | number)[]): string { return result } +// Keep track of issues given a category and a set of messages +const MIGRATION_ISSUES = new DefaultMap(() => new Set()) + // Applies heuristics to determine if we can attempt to migrate the config function canMigrateConfig( unresolvedConfig: Config, source: string, -): { valid: true } | { valid: false; errors: string[] } { +): { valid: true } | { valid: false; errors: typeof MIGRATION_ISSUES } { let theme = unresolvedConfig.theme - let errors: string[] = [] // Migrating presets are not supported if (unresolvedConfig.presets && unresolvedConfig.presets.length > 0) { - errors.push('Cannot migrate config files that use presets') + MIGRATION_ISSUES.get('general').add('Cannot migrate config files that use presets') } // The file may only contain known-migratable top-level properties @@ -431,7 +470,7 @@ function canMigrateConfig( for (let key of Object.keys(unresolvedConfig)) { if (!knownProperties.includes(key)) { - errors.push(`Cannot migrate unknown top-level key: \`${key}\``) + MIGRATION_ISSUES.get('top-level').add(key) } } @@ -439,18 +478,15 @@ function canMigrateConfig( // migrated if (theme && typeof theme === 'object') { let { extend, ...themeCopy } = theme - errors.push(...onlyAllowedThemeValues(themeCopy, ['theme'])) - - if (extend) { - errors.push(...onlyAllowedThemeValues(extend, ['theme', 'extend'])) - } + onlyAllowedThemeValues(themeCopy, ['theme']) + if (extend) onlyAllowedThemeValues(extend, ['theme', 'extend']) } // TODO: findStaticPlugins already logs errors for unsupported plugins, maybe // it should return them instead? findStaticPlugins(source) - return errors.length <= 0 ? { valid: true } : { valid: false, errors } + return MIGRATION_ISSUES.size <= 0 ? { valid: true } : { valid: false, errors: MIGRATION_ISSUES } } const ALLOWED_THEME_KEYS = [ @@ -458,26 +494,20 @@ const ALLOWED_THEME_KEYS = [ // Used by @tailwindcss/container-queries 'containers', ] -function onlyAllowedThemeValues(theme: ThemeConfig, path: (string | number)[]): string[] { - let errors: string[] = [] - +function onlyAllowedThemeValues(theme: ThemeConfig, path: (string | number)[]) { for (let key of Object.keys(theme)) { if (!ALLOWED_THEME_KEYS.includes(key)) { - errors.push(`Cannot migrate theme key: \`${stringifyPath([...path, key])}\``) + MIGRATION_ISSUES.get('unknown-theme-keys').add(stringifyPath([...path, key])) } } if ('screens' in theme && typeof theme.screens === 'object' && theme.screens !== null) { for (let [name, screen] of Object.entries(theme.screens)) { if (typeof screen === 'object' && screen !== null && ('max' in screen || 'raw' in screen)) { - errors.push( - `Cannot migrate complex screen definition: \`${stringifyPath([...path, 'screens', name])}\``, - ) + MIGRATION_ISSUES.get('complex-screens').add(stringifyPath([...path, 'screens', name])) } } } - - return errors } function keyframesToCss(keyframes: Record): string { From 1d1535af9091beb3fb7e2d711eb20e94de43b2ec Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 27 Aug 2025 19:04:05 +0200 Subject: [PATCH 5/5] ensure `\n\n` are kept as `\n\n` When splitting `'foo\n\nbar'` by `\n`, you will get `['foo', '', 'bar']`. The `''` value will result in `[]` after the word wrapping. This information gets lost when we `flatMap`, so let's keep the newline using `['']` as the fallback. --- packages/@tailwindcss-upgrade/src/utils/renderer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@tailwindcss-upgrade/src/utils/renderer.ts b/packages/@tailwindcss-upgrade/src/utils/renderer.ts index 38bd9826a0ab..c9a9678014d7 100644 --- a/packages/@tailwindcss-upgrade/src/utils/renderer.ts +++ b/packages/@tailwindcss-upgrade/src/utils/renderer.ts @@ -44,7 +44,7 @@ export function wordWrap(text: string, width: number): string[] { // Handle text with newlines by maintaining the newlines, then splitting // each line separately. if (text.includes('\n')) { - return text.split('\n').flatMap((line) => wordWrap(line, width)) + return text.split('\n').flatMap((line) => (line ? wordWrap(line, width) : [''])) } let words = text.split(' ')