From 9586bff5d84eb877936937347e36949eef0b2f6c Mon Sep 17 00:00:00 2001 From: mohith Date: Mon, 19 Oct 2026 17:57:47 +0530 Subject: [PATCH] fix(react-email): preserve local CSS variable overrides --- .changeset/local-theme-variable-overrides.md | 5 ++ .../src/components/tailwind/tailwind.spec.tsx | 26 +++++++ .../css/resolve-all-css-variables.spec.ts | 53 +++++++++++++ .../utils/css/resolve-all-css-variables.ts | 76 ++++++++++++++----- 4 files changed, 143 insertions(+), 17 deletions(-) create mode 100644 .changeset/local-theme-variable-overrides.md diff --git a/.changeset/local-theme-variable-overrides.md b/.changeset/local-theme-variable-overrides.md new file mode 100644 index 0000000000..951925638a --- /dev/null +++ b/.changeset/local-theme-variable-overrides.md @@ -0,0 +1,5 @@ +--- +"react-email": patch +--- + +Preserve custom utility theme-variable overrides when resolving email styles, including local declaration order and importance. diff --git a/packages/react-email/src/components/tailwind/tailwind.spec.tsx b/packages/react-email/src/components/tailwind/tailwind.spec.tsx index 5268011e8e..8439aa1d1c 100644 --- a/packages/react-email/src/components/tailwind/tailwind.spec.tsx +++ b/packages/react-email/src/components/tailwind/tailwind.spec.tsx @@ -1248,6 +1248,32 @@ describe('Tailwind component', () => { }); describe('with utilities', () => { + it.each([ + '--color-brand: blue;', + '--color-brand: blue !important; --color-brand: green;', + ])('uses a custom utility local theme override: %s', async (declarations) => { + const html = await render( + +
+ default +
+
+ overridden +
+
, + ); + const document = new DOMParser().parseFromString(html, 'text/html'); + expect( + document.getElementById('theme-default')?.style.backgroundColor, + ).toBe('red'); + expect(document.getElementById('local-override')?.style.color).toBe( + 'blue', + ); + }); + it('handles empty utilities string', async () => { const actualOutput = await render( diff --git a/packages/react-email/src/components/tailwind/utils/css/resolve-all-css-variables.spec.ts b/packages/react-email/src/components/tailwind/utils/css/resolve-all-css-variables.spec.ts index 06bea30be1..bd23a94f3f 100644 --- a/packages/react-email/src/components/tailwind/utils/css/resolve-all-css-variables.spec.ts +++ b/packages/react-email/src/components/tailwind/utils/css/resolve-all-css-variables.spec.ts @@ -2,6 +2,59 @@ import { generate, parse } from 'css-tree'; import { resolveAllCssVariables } from './resolve-all-css-variables.js'; describe('resolveAllCSSVariables', () => { + it.each([ + ':root', + '*', + ])('prefers a local definition over an earlier %s default', (selector) => { + const root = parse(`${selector} { --brand: red; } + .card { --brand: blue; color: var(--brand); } + .default { color: var(--brand); }`); + resolveAllCssVariables(root); + expect(generate(root)).toContain('.card{--brand: blue;color:blue}'); + expect(generate(root)).toContain('.default{color:red}'); + }); + + it.each([ + ['--brand: red; --brand: blue;', 'blue'], + ['--brand: red !important; --brand: blue;', 'red'], + ['--brand: red; --brand: blue !important;', 'blue'], + ['--brand: red !important; --brand: blue !important;', 'blue'], + ])('uses the winning local declaration in %s', (declarations, expected) => { + const root = parse(`:root { --brand: green; } + .card { ${declarations} color: var(--brand); }`); + resolveAllCssVariables(root); + expect(generate(root)).toContain(`color:${expected}`); + }); + + it.each([ + '*', + '*, .other', + ':where(*)', + ':is(*)', + '.card', + ])('preserves an earlier important definition on %s', (selector) => { + const root = parse(`${selector} { --brand: red !important; } + .card { --brand: blue; color: var(--brand); }`); + resolveAllCssVariables(root); + expect(generate(root)).toContain('.card{--brand: blue;color:red}'); + }); + + it('prefers a local declaration over an inherited important root default', () => { + const root = parse(`:root { --brand: red !important; } + .card { --brand: blue; color: var(--brand); }`); + resolveAllCssVariables(root); + expect(generate(root)).toContain('.card{--brand: blue;color:blue}'); + }); + + it('prefers a local important declaration over a universal important default', () => { + const root = parse(`* { --brand: red !important; } + .card { --brand: blue !important; color: var(--brand); }`); + resolveAllCssVariables(root); + expect(generate(root)).toContain( + '.card{--brand: blue !important;color:blue}', + ); + }); + it('ignores @layer (properties) defined for browser compatibility', () => { const root = parse(`/*! tailwindcss v4.1.12 | MIT License | https://tailwindcss.com */ diff --git a/packages/react-email/src/components/tailwind/utils/css/resolve-all-css-variables.ts b/packages/react-email/src/components/tailwind/utils/css/resolve-all-css-variables.ts index 347d6755b1..a26e8f8cdc 100644 --- a/packages/react-email/src/components/tailwind/utils/css/resolve-all-css-variables.ts +++ b/packages/react-email/src/components/tailwind/utils/css/resolve-all-css-variables.ts @@ -26,16 +26,10 @@ export interface VariableDefinition { definition: string; } -function doSelectorsIntersect( - first: SelectorList | Raw, - second: SelectorList | Raw, +function hasUniversalSelector( + selector: SelectorList | Raw, + includeRoot: boolean, ): boolean { - const firstStringified = generate(first); - const secondStringified = generate(second); - if (firstStringified === secondStringified) { - return true; - } - let hasSomeUniversal = false; const walker = ( node: CssNode, @@ -43,7 +37,11 @@ function doSelectorsIntersect( parentList: List, ) => { if (hasSomeUniversal) return; - if (node.type === 'PseudoClassSelector' && node.name === 'root') { + if ( + includeRoot && + node.type === 'PseudoClassSelector' && + node.name === 'root' + ) { hasSomeUniversal = true; } if ( @@ -54,14 +52,19 @@ function doSelectorsIntersect( hasSomeUniversal = true; } }; - walk(first, walker); - walk(second, walker); - - if (hasSomeUniversal) { - return true; - } + walk(selector, walker); + return hasSomeUniversal; +} - return false; +function doSelectorsIntersect( + first: SelectorList | Raw, + second: SelectorList | Raw, +): boolean { + return ( + generate(first) === generate(second) || + hasUniversalSelector(first, true) || + hasUniversalSelector(second, true) + ); } export function resolveAllCssVariables(node: CssNode) { @@ -138,14 +141,53 @@ export function resolveAllCssVariables(node: CssNode) { }, }); + const localDefinitions = new Map>(); + for (const definition of variableDefinitions) { + const block = definition.path[0]; + if (!block) continue; + let definitions = localDefinitions.get(block); + if (!definitions) { + definitions = new Map(); + localDefinitions.set(block, definitions); + } + const previous = definitions.get(definition.variableName); + if (!previous?.declaration.important || definition.declaration.important) { + definitions.set(definition.variableName, definition); + } + } + for (const use of variableUses) { let hasReplaced = false; + const localDefinition = use.path[0] + ? localDefinitions.get(use.path[0])?.get(use.variableName) + : undefined; + const useRule = use.path.find((ancestor) => ancestor.type === 'Rule'); for (const definition of variableDefinitions) { if (use.variableName !== definition.variableName) { continue; } + if (localDefinition && definition !== localDefinition) { + const definitionRule = definition.path.find( + (ancestor) => ancestor.type === 'Rule', + ); + // A root default is inherited, but an important declaration on the + // same selector or a bare universal selector applies directly. + const appliesDirectly = + definitionRule && + useRule && + (generate(definitionRule.prelude) === generate(useRule.prelude) || + hasUniversalSelector(definitionRule.prelude, false)); + if ( + localDefinition.declaration.important || + !definition.declaration.important || + !appliesDirectly + ) { + continue; + } + } + if ( use.path[0]?.type === 'Block' && use.path[1]?.type === 'Atrule' &&