fix(tailwind): inlinable styles not handled optimally in mixed rules (#2949)

This commit is contained in:
Shubhdeep Chhabra
2026-02-12 01:36:09 +05:30
committed by GitHub
parent 9015f76aeb
commit 6ff41b716f
7 changed files with 122 additions and 6 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@react-email/tailwind": patch
---
- enhance CSS rule extraction and inline style handling
+3 -3
View File
@@ -390,7 +390,7 @@ describe('Tailwind component', () => {
);
expect(actualOutput).toMatchInlineSnapshot(
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/><!--$--><style>.text-body{color:green!important;@media (prefers-color-scheme:dark){color:orange!important}}</style></head><body class="text-body"><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td>this is the body</td></tr></tbody></table><!--/$--></body></html>"`,
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/><!--$--><style>.text-body{@media (prefers-color-scheme:dark){color:orange!important}}</style></head><body class="text-body"><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="color:green">this is the body</td></tr></tbody></table><!--/$--></body></html>"`,
);
});
@@ -560,11 +560,11 @@ describe('Tailwind component', () => {
<head>
<!--$-->
<style>
.text-body{color:green!important;@media (width>=40rem){color:darkgreen!important}}
.text-body{@media (width>=40rem){color:darkgreen!important}}
</style>
</head>
<body>
<div class="text-body"></div>
<div class="text-body" style="color:green"></div>
<!--/$-->
</body>
</html>
@@ -77,4 +77,39 @@ describe('extractRulesPerClass()', async () => {
}
`);
});
it('splits mixed rules (base + media) into inlinable base and non-inlinable media', async () => {
const tailwind = await setupTailwind({
plugins: [
{
handler: (api) => {
api.addUtilities({
'.text-body': {
'@apply text-[green] sm:text-[darkgreen]': {},
},
});
},
},
],
});
const classes = ['text-body'];
tailwind.addUtilities(classes);
const stylesheet = tailwind.getStyleSheet();
const { inlinable, nonInlinable } = extractRulesPerClass(
stylesheet,
classes,
);
expect(convertToComparable(inlinable)).toMatchInlineSnapshot(`
{
"text-body": ".text-body{color:green}",
}
`);
expect(convertToComparable(nonInlinable)).toMatchInlineSnapshot(`
{
"text-body": ".text-body{@media (width>=40rem){color:darkgreen}}",
}
`);
});
});
@@ -1,5 +1,6 @@
import { type CssNode, type Rule, string, walk } from 'css-tree';
import { isRuleInlinable } from './is-rule-inlinable';
import { splitMixedRule } from './split-mixed-rule';
export function extractRulesPerClass(root: CssNode, classes: string[]) {
const classSet = new Set(classes);
@@ -23,9 +24,14 @@ export function extractRulesPerClass(root: CssNode, classes: string[]) {
}
}
} else {
const { inlinablePart, nonInlinablePart } = splitMixedRule(rule);
for (const className of selectorClasses) {
if (classSet.has(className)) {
nonInlinableRules.set(className, rule);
if (!classSet.has(className)) continue;
if (inlinablePart) {
inlinableRules.set(className, inlinablePart);
}
if (nonInlinablePart) {
nonInlinableRules.set(className, nonInlinablePart);
}
}
}
@@ -0,0 +1,15 @@
import { type CssNode, find } from 'css-tree';
export function isPartInlinable(part: CssNode): boolean {
const hasAtRuleInside = find(part, (node) => node.type === 'Atrule') !== null;
const hasPseudoSelector =
find(
part,
(node) =>
node.type === 'PseudoClassSelector' ||
node.type === 'PseudoElementSelector',
) !== null;
return !hasAtRuleInside && !hasPseudoSelector;
}
@@ -0,0 +1,52 @@
import { type CssNode, clone, List, type Rule } from 'css-tree';
import { isPartInlinable } from './is-part-inlinable';
/**
* Split a rule into inlinable and non-inlinable parts. Caller must only pass rules
* for which isRuleInlinable(rule) is false. Returns clones so the original stylesheet is never mutated.
*
* @returns inlinablePart: rule with only inlinable block children, or null if none.
* nonInlinablePart: rule with only non-inlinable block children, or null if none.
*/
export function splitMixedRule(rule: Rule): {
inlinablePart: Rule | null;
nonInlinablePart: Rule | null;
} {
const ruleCloneInlinable = clone(rule) as Rule;
const ruleCloneNonInlinable = clone(rule) as Rule;
const inlinableParts: CssNode[] = [];
const nonInlinableParts: CssNode[] = [];
for (const part of ruleCloneInlinable.block.children.toArray()) {
if (isPartInlinable(part)) {
inlinableParts.push(part);
} else {
nonInlinableParts.push(part);
}
}
const inlinablePart =
inlinableParts.length > 0
? {
...ruleCloneInlinable,
block: {
type: 'Block' as const,
children: new List<CssNode>().fromArray(inlinableParts),
},
}
: null;
const nonInlinablePart =
nonInlinableParts.length > 0
? {
...ruleCloneNonInlinable,
block: {
type: 'Block' as const,
children: new List<CssNode>().fromArray(nonInlinableParts),
},
}
: null;
return { inlinablePart, nonInlinablePart };
}
@@ -24,7 +24,10 @@ export function cloneElementWithInlinedStyles(
const rule = inlinableRules.get(className);
if (rule) {
rules.push(rule);
} else {
}
if (nonInlinableRules.has(className)) {
residualClasses.push(className);
} else if (!rule) {
residualClasses.push(className);
}
}