diff --git a/packages/react-email/src/components/tailwind/utils/css/make-inline-styles-for.spec.ts b/packages/react-email/src/components/tailwind/utils/css/make-inline-styles-for.spec.ts index 9faffbe60f..af46b53034 100644 --- a/packages/react-email/src/components/tailwind/utils/css/make-inline-styles-for.spec.ts +++ b/packages/react-email/src/components/tailwind/utils/css/make-inline-styles-for.spec.ts @@ -41,11 +41,61 @@ describe('makeInlineStylesFor()', async () => { ), ).toMatchInlineSnapshot(` { - "backgroundColor": " #3490dc", + "backgroundColor": "#3490dc", "borderRadius": "0.25rem", - "color": " #fff", + "color": "#fff", "padding": "0.5rem 1rem", } `); }); + + it('strips Tailwind v4 variant-stacking var() refs with empty fallbacks', () => { + // Tailwind v4 compiles `tabular-nums` to a font-variant-numeric value + // where every optional variant slot is represented by an unresolved + // var(--tw-..., ) with an empty fallback. Email clients do not support + // CSS custom properties reliably, so these must collapse at inline time + // (per CSS spec, an empty fallback resolves to empty string). + const tailwindStyles = parse(` + .tabular-nums { + font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) tabular-nums var(--tw-numeric-fraction,); + } + `) as StyleSheet; + + expect( + makeInlineStylesFor( + tailwindStyles.children.toArray(), + getCustomProperties(tailwindStyles), + ), + ).toMatchInlineSnapshot(` + { + "fontVariantNumeric": "tabular-nums", + } + `); + }); + + it('preserves user-authored empty-fallback var() refs (non --tw- prefix)', () => { + // The collapse is scoped to Tailwind's --tw-* variant-stacking idiom. + // A user-authored var(--my-color,) with an empty fallback must pass + // through unchanged even though it syntactically matches the idiom -- + // the user opted into that semantic and the render target may define + // --my-color at a higher scope. + const userStyles = parse(` + .thing { + color: var(--my-color,); + background: var(--brand,) var(--tw-custom,); + } + `) as StyleSheet; + + expect( + makeInlineStylesFor( + userStyles.children.toArray(), + getCustomProperties(userStyles), + ), + ).toMatchInlineSnapshot(` + { + "background": "var(--brand,)", + "color": "var(--my-color,)", + } + `); + }); }); diff --git a/packages/react-email/src/components/tailwind/utils/css/make-inline-styles-for.ts b/packages/react-email/src/components/tailwind/utils/css/make-inline-styles-for.ts index 0eef3c7b25..3daaa222f9 100644 --- a/packages/react-email/src/components/tailwind/utils/css/make-inline-styles-for.ts +++ b/packages/react-email/src/components/tailwind/utils/css/make-inline-styles-for.ts @@ -59,9 +59,26 @@ export function makeInlineStylesFor( if (declaration.property.startsWith('--')) { return; } + // Tailwind v4 emits variant-stacking idioms like + // font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) tabular-nums var(--tw-numeric-fraction,) + // where each var() has an empty fallback so missing variants collapse to nothing. + // The walker above replaces var() calls with an initialValue when one is defined, + // but Tailwind deliberately leaves these variant vars undefined until used, so they + // stay in the output here and produce unresolvable custom properties in email HTML + // (no email client supports CSS custom properties reliably). Per the CSS spec + // (https://www.w3.org/TR/css-variables-1/#using-variables) an empty fallback means + // "use empty string if the variable is undefined", which is exactly what we want at + // inline-style time. + // + // Scoped to the `--tw-` prefix so any user-authored empty-fallback var() refs + // (even ones used inside tailwind utilities) are left untouched. + const rawValue = generate(declaration.value); + const cleanedValue = rawValue + .replace(/var\(\s*--tw-[\w-]+\s*,\s*\)/g, ' ') + .replace(/\s+/g, ' ') + .trim(); styles[getReactProperty(declaration.property)] = - generate(declaration.value) + - (declaration.important ? '!important' : ''); + cleanedValue + (declaration.important ? '!important' : ''); }, }); }