Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/local-theme-variable-overrides.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-email": patch
---

Preserve custom utility theme-variable overrides when resolving email styles, including local declaration order and importance.
26 changes: 26 additions & 0 deletions packages/react-email/src/components/tailwind/tailwind.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Tailwind
theme="@theme { --color-brand: red; }"
utility={`.card { ${declarations} color: var(--color-brand); }`}
>
<div id="theme-default" className="bg-brand">
default
</div>
<div id="local-override" className="card">
overridden
</div>
</Tailwind>,
);
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(
<Tailwind utility="">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,24 +26,22 @@ 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,
_parentListItem: ListItem<CssNode>,
parentList: List<CssNode>,
) => {
if (hasSomeUniversal) return;
if (node.type === 'PseudoClassSelector' && node.name === 'root') {
if (
includeRoot &&
node.type === 'PseudoClassSelector' &&
node.name === 'root'
) {
hasSomeUniversal = true;
}
if (
Expand All @@ -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) {
Expand Down Expand Up @@ -138,14 +141,53 @@ export function resolveAllCssVariables(node: CssNode) {
},
});

const localDefinitions = new Map<CssNode, Map<string, VariableDefinition>>();
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' &&
Expand Down