From bab4857f0a5b2703e8c4626bbe76dd3dcac42554 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sat, 24 Oct 2026 00:36:19 -0500 Subject: [PATCH] fix(react-email): resolve theme variables used inside Tailwind utility variables --- ...ailwind-theme-vars-in-utility-variables.md | 5 ++ .../src/components/tailwind/tailwind.spec.tsx | 26 ++++++ .../css/resolve-all-css-variables.spec.ts | 19 ++++ .../utils/css/resolve-all-css-variables.ts | 87 +++++++++++-------- 4 files changed, 100 insertions(+), 37 deletions(-) create mode 100644 .changeset/tailwind-theme-vars-in-utility-variables.md diff --git a/.changeset/tailwind-theme-vars-in-utility-variables.md b/.changeset/tailwind-theme-vars-in-utility-variables.md new file mode 100644 index 0000000000..581bc4b975 --- /dev/null +++ b/.changeset/tailwind-theme-vars-in-utility-variables.md @@ -0,0 +1,5 @@ +--- +"react-email": patch +--- + +`` now resolves theme values that utilities pass through their own variables, so gradients (`from-red-500`), shadow and ring colors (`shadow-red-500`, `ring-blue-500`), `blur-*`, `drop-shadow-*` and `translate-*` no longer inline an unresolvable `var(--color-…)` or `var(--spacing)`. diff --git a/packages/react-email/src/components/tailwind/tailwind.spec.tsx b/packages/react-email/src/components/tailwind/tailwind.spec.tsx index 5268011e8e..a9f80b7580 100644 --- a/packages/react-email/src/components/tailwind/tailwind.spec.tsx +++ b/packages/react-email/src/components/tailwind/tailwind.spec.tsx @@ -134,6 +134,32 @@ describe('Tailwind component', () => { expect(defaultElement?.style.boxShadow).not.toContain('rgb(255,0,0'); }); + it('resolves theme values passed through utility variables', async () => { + const html = await render( + + +
+ gradient +
+
+ colored +
+
transformed
+
, + ); + const document = new DOMParser().parseFromString(html, 'text/html'); + const [gradient, colored, transformed] = document.querySelectorAll('div'); + + expect(html).not.toContain('var('); + expect(gradient?.getAttribute('style')).toContain( + 'linear-gradient(to right,rgb(251,44,54) 0%,rgb(0,201,80) 50%,rgb(43,127,255) 100%)', + ); + expect(colored?.getAttribute('style')).toContain('rgb(251,44,54'); + expect(colored?.getAttribute('style')).toContain('rgb(43,127,255)'); + expect(transformed?.getAttribute('style')).toContain('filter:blur(8px)'); + expect(transformed?.getAttribute('style')).toContain('translate:0.5rem 0'); + }); + it('does not use hover shadow colors for the inline default', async () => { const html = 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..11ec394de4 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 @@ -188,6 +188,25 @@ describe('resolveAllCSSVariables', () => { ); }); + it('resolves variables used inside custom property definitions', () => { + const root = parse(`:root { + --color-red-500: red; + --blur-sm: 8px; +} +.from-red-500 { + --tw-gradient-from: var(--color-red-500); + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to, transparent); +} +.blur-sm { + --tw-blur: blur(var(--blur-sm)); + filter: var(--tw-blur,) var(--tw-drop-shadow,); +}`); + resolveAllCssVariables(root); + expect(generate(root)).toMatchInlineSnapshot( + `":root{--color-red-500: red;--blur-sm: 8px}.from-red-500{--tw-gradient-from:red;--tw-gradient-stops:red,var(--tw-gradient-to, transparent)}.blur-sm{--tw-blur:blur(8px);filter:blur(8px) var(--tw-drop-shadow,)}"`, + ); + }); + it('uses fallback values when variable definition is not found', () => { const root = parse(`.box { width: var(--undefined-width, 150px); 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..e09e9809a1 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 @@ -23,7 +23,6 @@ export interface VariableDefinition { declaration: Declaration; path: CssNode[]; variableName: string; - definition: string; } function doSelectorsIntersect( @@ -96,43 +95,50 @@ export function resolveAllCssVariables(node: CssNode) { declaration, path: [...path], variableName: declaration.property, - definition: generate(declaration.value), }); - } else { - function parseVariableUsesFrom(node: CssNode) { - walk(node, { - visit: 'Function', - enter(funcNode) { - if (funcNode.name === 'var') { - const children = funcNode.children.toArray(); - const name = generate(children[0]!); - const fallback = - // The second argument should be an "," Operator Node, - // such that the actual fallback is only in the third argument - children[2] ? generate(children[2]) : undefined; - - variableUses.add({ - declaration, - path: [...path], - fallback, - variableName: name, - raw: generate(funcNode), - }); + } - if (fallback?.includes('var(')) { - const parsedFallback = parse(fallback, { - context: 'value', - }); + // Custom properties can reference other variables too, e.g. Tailwind's + // `--tw-gradient-from: var(--color-red-500)`, which is only read later + // through `var(--tw-gradient-from)` when inlining. css-tree keeps their + // values as Raw, so they need parsing before their var()s can be found. + function parseVariableUsesFrom(node: CssNode) { + walk(node, { + visit: 'Function', + enter(funcNode) { + if (funcNode.name === 'var') { + const children = funcNode.children.toArray(); + const name = generate(children[0]!); + const fallback = + // The second argument should be an "," Operator Node, + // such that the actual fallback is only in the third argument + children[2] ? generate(children[2]) : undefined; + + variableUses.add({ + declaration, + path: [...path], + fallback, + variableName: name, + raw: generate(funcNode), + }); + + if (fallback?.includes('var(')) { + const parsedFallback = parse(fallback, { + context: 'value', + }); - parseVariableUsesFrom(parsedFallback); - } + parseVariableUsesFrom(parsedFallback); } - }, - }); - } - - parseVariableUsesFrom(declaration.value); + } + }, + }); } + + parseVariableUsesFrom( + declaration.value.type === 'Raw' + ? parse(declaration.value.value, { context: 'value' }) + : declaration.value, + ); } path.unshift(node); }, @@ -158,7 +164,7 @@ export function resolveAllCssVariables(node: CssNode) { use.declaration.value = parse( generate(use.declaration.value).replaceAll( use.raw, - definition.definition, + generate(definition.declaration.value), ), { context: 'value', @@ -178,7 +184,7 @@ export function resolveAllCssVariables(node: CssNode) { use.declaration.value = parse( generate(use.declaration.value).replaceAll( use.raw, - definition.definition, + generate(definition.declaration.value), ), { context: 'value', @@ -215,7 +221,7 @@ export function resolveAllCssVariables(node: CssNode) { use.declaration.value = parse( generate(use.declaration.value).replaceAll( use.raw, - definition.definition, + generate(definition.declaration.value), ), { context: 'value', @@ -226,7 +232,14 @@ export function resolveAllCssVariables(node: CssNode) { } } - if (!hasReplaced && use.fallback) { + // A custom property's own fallback is left in place, since a declaration + // on the element's other classes might still define the variable when + // the styles are inlined. + if ( + !hasReplaced && + use.fallback && + !use.declaration.property.startsWith('--') + ) { use.declaration.value = parse( generate(use.declaration.value).replaceAll(use.raw, use.fallback), { context: 'value' },