diff --git a/.changeset/downlevel-email-css.md b/.changeset/downlevel-email-css.md new file mode 100644 index 0000000000..b4c30bb54d --- /dev/null +++ b/.changeset/downlevel-email-css.md @@ -0,0 +1,9 @@ +--- +"@react-email/tailwind": patch +--- + +fix(tailwind): downlevel CSS for email client compatibility + +Rewrites modern CSS features that email clients don't support using css-tree AST: +- Unnests `@media` rules from inside selectors (CSS Nesting) +- Converts Media Queries Level 4 range syntax to legacy `min-width`/`max-width` diff --git a/packages/tailwind/src/tailwind.spec.tsx b/packages/tailwind/src/tailwind.spec.tsx index 081f8c8ec6..43b8dc6db0 100644 --- a/packages/tailwind/src/tailwind.spec.tsx +++ b/packages/tailwind/src/tailwind.spec.tsx @@ -51,7 +51,7 @@ describe('Tailwind component', () => {
@@ -352,7 +352,7 @@ describe('Tailwind component', () => { { ); expect(actualOutput).toMatchInlineSnapshot( - `"| this is the body |
| this is the body |
I am some text
@@ -705,7 +705,7 @@ describe('Tailwind component', () => { @@ -926,7 +926,7 @@ describe('Tailwind component', () => { diff --git a/packages/tailwind/src/tailwind.tsx b/packages/tailwind/src/tailwind.tsx index aa088735af..edfd38d058 100644 --- a/packages/tailwind/src/tailwind.tsx +++ b/packages/tailwind/src/tailwind.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import type { Config } from 'tailwindcss'; import { useSuspensedPromise } from './hooks/use-suspended-promise'; import { sanitizeStyleSheet } from './sanitize-stylesheet'; +import { downlevelForEmailClients } from './utils/css/downlevel-for-email-clients'; import { extractRulesPerClass } from './utils/css/extract-rules-per-class'; import { getCustomProperties } from './utils/css/get-custom-properties'; import { sanitizeNonInlinableRules } from './utils/css/sanitize-non-inlinable-rules'; @@ -118,6 +119,7 @@ export function Tailwind({ children, config }: TailwindProps) { ), }; sanitizeNonInlinableRules(nonInlineStyles); + downlevelForEmailClients(nonInlineStyles); const hasNonInlineStylesToApply = nonInlinableRules.size > 0; let appliedNonInlineStyles = false as boolean; diff --git a/packages/tailwind/src/utils/css/downlevel-for-email-clients.spec.ts b/packages/tailwind/src/utils/css/downlevel-for-email-clients.spec.ts new file mode 100644 index 0000000000..0a3a495767 --- /dev/null +++ b/packages/tailwind/src/utils/css/downlevel-for-email-clients.spec.ts @@ -0,0 +1,125 @@ +import { generate, parse, type StyleSheet } from 'css-tree'; +import { downlevelForEmailClients } from './downlevel-for-email-clients'; + +function transform(css: string): string { + const ast = parse(css) as StyleSheet; + downlevelForEmailClients(ast); + return generate(ast); +} + +describe('downlevelForEmailClients', () => { + describe('range syntax', () => { + it('converts width>= to min-width', () => { + expect( + transform('@media (width>=40rem){.sm_p-4{padding:1rem}}'), + ).toBe('@media (min-width:40rem){.sm_p-4{padding:1rem}}'); + }); + + it('converts width<= to max-width', () => { + expect( + transform('@media (width<=40rem){.max-sm_p-4{padding:1rem}}'), + ).toBe('@media (max-width:40rem){.max-sm_p-4{padding:1rem}}'); + }); + + it('converts width< to max-width', () => { + expect( + transform('@media (width<40rem){.max-sm_text-red{color:red}}'), + ).toBe('@media (max-width:40rem){.max-sm_text-red{color:red}}'); + }); + + it('converts width> to min-width', () => { + expect( + transform('@media (width>40rem){.sm_p-4{padding:1rem}}'), + ).toBe('@media (min-width:40rem){.sm_p-4{padding:1rem}}'); + }); + + it('does not affect non-range media queries', () => { + expect( + transform( + '@media (prefers-color-scheme:dark){.dark{color:white}}', + ), + ).toBe('@media (prefers-color-scheme:dark){.dark{color:white}}'); + }); + }); + + describe('unnesting', () => { + it('unnests @media from inside a selector', () => { + expect( + transform( + '.sm_bg-red{@media (min-width:40rem){background-color:red!important}}', + ), + ).toBe( + '@media (min-width:40rem){.sm_bg-red{background-color:red!important}}', + ); + }); + + it('handles combined range syntax + nesting', () => { + expect( + transform( + '.sm_bg-red{@media (width>=40rem){background-color:rgb(255,162,162)!important}}', + ), + ).toBe( + '@media (min-width:40rem){.sm_bg-red{background-color:rgb(255,162,162)!important}}', + ); + }); + + it('handles multiple concatenated rules', () => { + const input = + '.sm_bg-red{@media (width>=40rem){background-color:red!important}}' + + '.md_bg-blue{@media (width>=48rem){background-color:blue!important}}'; + + const result = transform(input); + + expect(result).toContain( + '@media (min-width:40rem){.sm_bg-red{background-color:red!important}}', + ); + expect(result).toContain( + '@media (min-width:48rem){.md_bg-blue{background-color:blue!important}}', + ); + }); + + it('unnests dark mode media queries', () => { + expect( + transform( + '.dark_text-white{@media (prefers-color-scheme:dark){color:rgb(255,255,255)!important}}', + ), + ).toBe( + '@media (prefers-color-scheme:dark){.dark_text-white{color:rgb(255,255,255)!important}}', + ); + }); + + it('preserves rules without nested @media', () => { + expect(transform('.bg-red{background-color:red}')).toBe( + '.bg-red{background-color:red}', + ); + }); + + it('preserves already top-level @media rules', () => { + expect( + transform( + '@media (min-width:40rem){.sm_p-4{padding:1rem!important}}', + ), + ).toBe( + '@media (min-width:40rem){.sm_p-4{padding:1rem!important}}', + ); + }); + + it('handles multiple @media nested in one selector', () => { + const input = + '.multi{@media (width>=40rem){color:red!important}@media (width>=48rem){color:blue!important}}'; + + const result = transform(input); + + expect(result).toContain( + '@media (min-width:40rem){.multi{color:red!important}}', + ); + expect(result).toContain( + '@media (min-width:48rem){.multi{color:blue!important}}', + ); + }); + + it('handles empty stylesheet', () => { + expect(transform('')).toBe(''); + }); + }); +}); diff --git a/packages/tailwind/src/utils/css/downlevel-for-email-clients.ts b/packages/tailwind/src/utils/css/downlevel-for-email-clients.ts new file mode 100644 index 0000000000..62254391f7 --- /dev/null +++ b/packages/tailwind/src/utils/css/downlevel-for-email-clients.ts @@ -0,0 +1,212 @@ +/** + * Downlevels modern CSS features that email clients don't support, + * operating on a css-tree StyleSheet AST. + * + * 1. CSS Nesting: unnests @media rules from inside selectors + * `.sm_p-4{@media (min-width:40rem){padding:1rem!important}}` + * → `@media (min-width:40rem){.sm_p-4{padding:1rem!important}}` + * + * 2. Media Queries Level 4 range syntax → legacy min-width/max-width + * `(width>=40rem)` → `(min-width:40rem)` + * + * Gmail, Outlook, Yahoo, and most email clients don't support either feature. + * See: https://www.caniemail.com/features/css-at-media/ + * https://www.caniemail.com/features/css-nesting/ + */ + +import { + type Atrule, + type CssNode, + clone, + List, + type ListItem, + type Rule, + type StyleSheet, + walk, +} from 'css-tree'; + +/** + * Unnest @media at-rules from inside regular rules, and downlevel + * range media query syntax to legacy min-width/max-width. + * + * Mutates the stylesheet in place. + */ +export function downlevelForEmailClients(styleSheet: StyleSheet): void { + unnestMediaQueries(styleSheet); + downlevelRangeMediaQueries(styleSheet); +} + +// --------------------------------------------------------------------------- +// Unnesting +// --------------------------------------------------------------------------- + +interface UnnestTransform { + parentRule: Rule; + parentItem: ListItem