From 6ff41b716fe7c988513511a8b9e4d4473846ad92 Mon Sep 17 00:00:00 2001 From: Shubhdeep Chhabra Date: Thu, 12 Feb 2026 01:36:09 +0530 Subject: [PATCH] fix(tailwind): inlinable styles not handled optimally in mixed rules (#2949) --- .changeset/witty-dodos-begin.md | 5 ++ packages/tailwind/src/tailwind.spec.tsx | 6 +-- .../utils/css/extract-rules-per-class.spec.ts | 35 +++++++++++++ .../src/utils/css/extract-rules-per-class.ts | 10 +++- .../src/utils/css/is-part-inlinable.ts | 15 ++++++ .../src/utils/css/split-mixed-rule.ts | 52 +++++++++++++++++++ .../clone-element-with-inlined-styles.ts | 5 +- 7 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 .changeset/witty-dodos-begin.md create mode 100644 packages/tailwind/src/utils/css/is-part-inlinable.ts create mode 100644 packages/tailwind/src/utils/css/split-mixed-rule.ts diff --git a/.changeset/witty-dodos-begin.md b/.changeset/witty-dodos-begin.md new file mode 100644 index 000000000..0e722ceee --- /dev/null +++ b/.changeset/witty-dodos-begin.md @@ -0,0 +1,5 @@ +--- +"@react-email/tailwind": patch +--- + +- enhance CSS rule extraction and inline style handling diff --git a/packages/tailwind/src/tailwind.spec.tsx b/packages/tailwind/src/tailwind.spec.tsx index 0dfdd9ff7..081f8c8ec 100644 --- a/packages/tailwind/src/tailwind.spec.tsx +++ b/packages/tailwind/src/tailwind.spec.tsx @@ -390,7 +390,7 @@ describe('Tailwind component', () => { ); expect(actualOutput).toMatchInlineSnapshot( - `"
this is the body
"`, + `"
this is the body
"`, ); }); @@ -560,11 +560,11 @@ describe('Tailwind component', () => { -
+
diff --git a/packages/tailwind/src/utils/css/extract-rules-per-class.spec.ts b/packages/tailwind/src/utils/css/extract-rules-per-class.spec.ts index d98334807..cc254c3d3 100644 --- a/packages/tailwind/src/utils/css/extract-rules-per-class.spec.ts +++ b/packages/tailwind/src/utils/css/extract-rules-per-class.spec.ts @@ -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}}", + } + `); + }); }); diff --git a/packages/tailwind/src/utils/css/extract-rules-per-class.ts b/packages/tailwind/src/utils/css/extract-rules-per-class.ts index f41b4c917..0b1aaaf12 100644 --- a/packages/tailwind/src/utils/css/extract-rules-per-class.ts +++ b/packages/tailwind/src/utils/css/extract-rules-per-class.ts @@ -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); } } } diff --git a/packages/tailwind/src/utils/css/is-part-inlinable.ts b/packages/tailwind/src/utils/css/is-part-inlinable.ts new file mode 100644 index 000000000..1399cc1c2 --- /dev/null +++ b/packages/tailwind/src/utils/css/is-part-inlinable.ts @@ -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; +} diff --git a/packages/tailwind/src/utils/css/split-mixed-rule.ts b/packages/tailwind/src/utils/css/split-mixed-rule.ts new file mode 100644 index 000000000..4bcc0cc19 --- /dev/null +++ b/packages/tailwind/src/utils/css/split-mixed-rule.ts @@ -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().fromArray(inlinableParts), + }, + } + : null; + + const nonInlinablePart = + nonInlinableParts.length > 0 + ? { + ...ruleCloneNonInlinable, + block: { + type: 'Block' as const, + children: new List().fromArray(nonInlinableParts), + }, + } + : null; + + return { inlinablePart, nonInlinablePart }; +} diff --git a/packages/tailwind/src/utils/tailwindcss/clone-element-with-inlined-styles.ts b/packages/tailwind/src/utils/tailwindcss/clone-element-with-inlined-styles.ts index f2b7a66c9..183cfc2bd 100644 --- a/packages/tailwind/src/utils/tailwindcss/clone-element-with-inlined-styles.ts +++ b/packages/tailwind/src/utils/tailwindcss/clone-element-with-inlined-styles.ts @@ -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); } }