Skip to content

Commit 63f159a

Browse files
deepanshupalclaudefelipefreitag
authored
fix(tailwind): resolve var() fallbacks inside variable definitions (#3768)
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Felipe Freitag <felipe.freitag@resend.com>
1 parent 1fe4b90 commit 63f159a

3 files changed

Lines changed: 138 additions & 31 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-email": patch
3+
---
4+
5+
`<Tailwind>` now inlines the default color for `shadow`, `shadow-md`, `ring-2` and similar classes, so these shadows show in email clients.

‎packages/react-email/src/components/tailwind/tailwind.spec.tsx‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,64 @@ describe('Tailwind component', () => {
8989
`);
9090
});
9191

92+
it('resolves variables nested inside the definitions of other variables', async () => {
93+
const html = await render(
94+
<Tailwind>
95+
<div className="shadow-md ring-2">default shadow color</div>
96+
</Tailwind>,
97+
);
98+
99+
expect(html).not.toContain('var(');
100+
expect(html).toContain('0 4px 6px -1px rgb(0,0,0,0.1)');
101+
expect(html).toContain('currentcolor');
102+
});
103+
104+
it("uses each element's default shadow and ring colors independently", async () => {
105+
const html = await render(
106+
<Tailwind>
107+
<div className="shadow-md ring-2">card</div>
108+
<div className="shadow-red-500 ring-blue-500">button</div>
109+
</Tailwind>,
110+
);
111+
112+
expect(html).not.toContain('var(');
113+
expect(html).toContain('0 4px 6px -1px rgb(0,0,0,0.1)');
114+
expect(html).toContain('currentcolor');
115+
});
116+
117+
it('preserves local colors without changing a sibling with the same utilities', async () => {
118+
const html = await render(
119+
<Tailwind>
120+
<div className="shadow-md ring-2 shadow-[#ff0000] ring-[#0000ff]">
121+
colored
122+
</div>
123+
<div className="shadow-md ring-2">default</div>
124+
</Tailwind>,
125+
);
126+
const document = new DOMParser().parseFromString(html, 'text/html');
127+
const [colored, defaultElement] = document.querySelectorAll('div');
128+
129+
expect(html).not.toContain('var(');
130+
expect(colored?.style.boxShadow).toContain('rgb(255,0,0');
131+
expect(colored?.style.boxShadow).toContain('rgb(0,0,255');
132+
expect(defaultElement?.style.boxShadow).toContain('rgb(0,0,0,0.1)');
133+
expect(defaultElement?.style.boxShadow).toContain('currentcolor');
134+
expect(defaultElement?.style.boxShadow).not.toContain('rgb(255,0,0');
135+
});
136+
137+
it('does not use hover shadow colors for the inline default', async () => {
138+
const html = await render(
139+
<Tailwind>
140+
<Head />
141+
<div className="shadow-md hover:shadow-red-500">card</div>
142+
</Tailwind>,
143+
);
144+
145+
expect(html).not.toContain('var(');
146+
expect(html).toContain('0 4px 6px -1px rgb(0,0,0,0.1)');
147+
expect(html).toContain('hover_shadow-red-500');
148+
});
149+
92150
it('works with class manipulation done on components', async () => {
93151
const MyComponnt = (props: {
94152
className?: string;

‎packages/react-email/src/components/tailwind/utils/css/make-inline-styles-for.ts‎

Lines changed: 75 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,32 @@
1-
import { type CssNode, type Declaration, generate, walk } from 'css-tree';
1+
import {
2+
type CssNode,
3+
type Declaration,
4+
generate,
5+
parse,
6+
type Value,
7+
walk,
8+
} from 'css-tree';
29
import { getReactProperty } from '../compatibility/get-react-property.js';
310
import type { CustomProperties } from './get-custom-properties.js';
11+
import { resolveCalcExpressions } from './resolve-calc-expressions.js';
12+
import { sanitizeDeclarations } from './sanitize-declarations.js';
413
import { stripEmptyTailwindVars } from './strip-empty-tailwind-vars.js';
514
import { unwrapValue } from './unwrap-value.js';
615

16+
function hasVarFunction(node: CssNode) {
17+
let found = false;
18+
walk(node, {
19+
visit: 'Function',
20+
enter(func) {
21+
if (func.name === 'var') {
22+
found = true;
23+
return this.break;
24+
}
25+
},
26+
});
27+
return found;
28+
}
29+
730
export function makeInlineStylesFor(
831
inlinableRules: CssNode[],
932
customProperties: CustomProperties,
@@ -22,49 +45,70 @@ export function makeInlineStylesFor(
2245
});
2346
}
2447

25-
for (const rule of inlinableRules) {
26-
walk(rule, {
48+
const resolveValue = (
49+
value: CssNode,
50+
resolving = new Set<string>(),
51+
): CssNode => {
52+
const resolved = parse(generate(value), { context: 'value' }) as Value;
53+
walk(resolved, {
2754
visit: 'Function',
28-
enter(func, funcParentListItem) {
29-
if (func.name === 'var') {
30-
let variableName: string | undefined;
31-
walk(func, {
32-
visit: 'Identifier',
33-
enter(identifier) {
34-
variableName = identifier.name;
35-
return this.break;
36-
},
37-
});
38-
if (variableName) {
39-
const definition = localVariableDeclarations.get(variableName);
40-
if (definition) {
41-
funcParentListItem.data = unwrapValue(definition.value);
42-
} else {
43-
// For most variables tailwindcss defines, they also define a custom
44-
// property for them with an initial value that we can inline here
45-
const customProperty = customProperties.get(variableName);
46-
if (customProperty?.initialValue) {
47-
funcParentListItem.data = unwrapValue(
48-
customProperty.initialValue.value,
49-
);
50-
}
51-
}
52-
}
55+
leave(func, item) {
56+
if (func.name !== 'var') return;
57+
const children = func.children.toArray();
58+
const name = children[0] ? generate(children[0]).trim() : '';
59+
if (resolving.has(name)) return;
60+
61+
const definition = localVariableDeclarations.get(name);
62+
const initialValue = customProperties.get(name)?.initialValue;
63+
const comma = children.findIndex(
64+
(child) => child.type === 'Operator' && child.value === ',',
65+
);
66+
const fallback =
67+
comma === -1
68+
? undefined
69+
: children
70+
.slice(comma + 1)
71+
.map((child) => generate(child))
72+
.join('');
73+
const replacement = definition?.value ?? initialValue?.value;
74+
if (replacement) {
75+
item.data = unwrapValue(
76+
resolveValue(replacement, new Set([...resolving, name])) as Value,
77+
);
78+
} else if (fallback) {
79+
const declaration: Declaration = {
80+
type: 'Declaration',
81+
property: 'value',
82+
important: false,
83+
value: resolveValue(
84+
parse(fallback, { context: 'value' }),
85+
resolving,
86+
) as Value,
87+
};
88+
sanitizeDeclarations(declaration);
89+
item.data = unwrapValue(declaration.value);
5390
}
5491
},
5592
});
93+
return resolved;
94+
};
5695

96+
for (const rule of inlinableRules) {
5797
walk(rule, {
5898
visit: 'Declaration',
5999
enter(declaration) {
60100
if (declaration.property.startsWith('--')) {
61101
return;
62102
}
63-
stripEmptyTailwindVars(declaration.value);
103+
let value: CssNode = declaration.value;
104+
if (hasVarFunction(value)) {
105+
value = resolveValue(value);
106+
resolveCalcExpressions(value);
107+
stripEmptyTailwindVars(value);
108+
}
64109

65110
styles[getReactProperty(declaration.property)] =
66-
generate(declaration.value).trim() +
67-
(declaration.important ? '!important' : '');
111+
generate(value).trim() + (declaration.important ? '!important' : '');
68112
},
69113
});
70114
}

0 commit comments

Comments
 (0)