diff --git a/.changeset/new-moles-fry.md b/.changeset/new-moles-fry.md new file mode 100644 index 000000000..51e07850a --- /dev/null +++ b/.changeset/new-moles-fry.md @@ -0,0 +1,5 @@ +--- +"@react-email/tailwind": patch +--- + +fix mixed pseudo selectors not being split 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 cc254c3d3..716123784 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 @@ -112,4 +112,35 @@ describe('extractRulesPerClass()', async () => { } `); }); + + it('treats rules with pseudo-selectors as fully non-inlinable', async () => { + const tailwind = await setupTailwind({ + plugins: [ + { + handler: (api) => { + api.addUtilities({ + '.btn:hover': { + color: 'red', + }, + }); + }, + }, + ], + }); + const classes = ['btn']; + tailwind.addUtilities(classes); + + const stylesheet = tailwind.getStyleSheet(); + const { inlinable, nonInlinable } = extractRulesPerClass( + stylesheet, + classes, + ); + + expect(convertToComparable(inlinable)).toMatchInlineSnapshot('{}'); + expect(convertToComparable(nonInlinable)).toMatchInlineSnapshot(` + { + "btn": ".btn{&:hover{color:red}}", + } + `); + }); }); diff --git a/packages/tailwind/src/utils/css/split-mixed-rule.ts b/packages/tailwind/src/utils/css/split-mixed-rule.ts index 4bcc0cc19..03b0d98f4 100644 --- a/packages/tailwind/src/utils/css/split-mixed-rule.ts +++ b/packages/tailwind/src/utils/css/split-mixed-rule.ts @@ -1,4 +1,4 @@ -import { type CssNode, clone, List, type Rule } from 'css-tree'; +import { type CssNode, clone, find, List, type Rule } from 'css-tree'; import { isPartInlinable } from './is-part-inlinable'; /** @@ -12,6 +12,22 @@ export function splitMixedRule(rule: Rule): { inlinablePart: Rule | null; nonInlinablePart: Rule | null; } { + // If the selector itself contains pseudo-selectors, every declaration in the + // block only applies in that pseudo context — splitting would incorrectly + // inline them as base styles. Return the whole rule as non-inlinable. + const selectorHasPseudo = + rule.prelude !== null && + find( + rule.prelude, + (node) => + node.type === 'PseudoClassSelector' || + node.type === 'PseudoElementSelector', + ) !== null; + + if (selectorHasPseudo) { + return { inlinablePart: null, nonInlinablePart: clone(rule) as Rule }; + } + const ruleCloneInlinable = clone(rule) as Rule; const ruleCloneNonInlinable = clone(rule) as Rule;