From 2f87fa6657cf2e89e3e491fcdcd35b7c3f117c77 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 17 Apr 2026 09:11:47 -0400 Subject: [PATCH 1/2] fix(tailwind): collapse empty-fallback var() refs in inline styles Closes #2898 Tailwind v4 compiles single-class utilities like `tabular-nums` into a variant-stacking idiom where optional variants are represented by `var(--tw-ordinal,)` / `var(--tw-slashed-zero,)` / etc. - each with an empty fallback so missing variants collapse to nothing. Per the CSS Custom Properties spec an empty var() fallback resolves to empty string, but the declarations were landing verbatim in the inline style output because `makeInlineStylesFor` only substituted variables that had a registered `initialValue` in `:root`. Email clients don't support custom properties reliably, so the entire `font-variant-numeric: ...` rule was dropped in most inboxes. Strips the empty-fallback `var(--name,)` calls from the generated CSS value before writing it to the inline-style object, and collapses the leftover whitespace. Adds a regression test covering the exact output Tailwind v4 emits for `tabular-nums`. Also re-snapshots the existing basic-local-variable test where the previously retained leading space was a formatting artifact, not intentional behavior. --- .../utils/css/make-inline-styles-for.spec.ts | 28 +++++++++++++++++-- .../utils/css/make-inline-styles-for.ts | 18 ++++++++++-- 2 files changed, 42 insertions(+), 4 deletions(-) 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..87e8bbc1a3 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,35 @@ 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", + } + `); + }); }); 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..506546667f 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,23 @@ 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. + const rawValue = generate(declaration.value); + const cleanedValue = rawValue + .replace(/var\(\s*--[\w-]+\s*,\s*\)/g, ' ') + .replace(/\s+/g, ' ') + .trim(); styles[getReactProperty(declaration.property)] = - generate(declaration.value) + - (declaration.important ? '!important' : ''); + cleanedValue + (declaration.important ? '!important' : ''); }, }); } From d73e81564e0708c78aeaccf41110812ce5df4645 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:48:23 -0400 Subject: [PATCH 2/2] fix(tailwind): scope empty-fallback var() collapse to --tw-* prefix Prior regex collapsed any empty-fallback var(--foo,). Narrow to --tw-* so user-authored empty-fallback vars (even inside tailwind utilities) pass through unchanged. Adds a spec that asserts var(--my-color,) and var(--brand,) are preserved while var(--tw-custom,) collapses. Addresses review feedback from @gabrielmfern on #3359. --- .../utils/css/make-inline-styles-for.spec.ts | 26 +++++++++++++++++++ .../utils/css/make-inline-styles-for.ts | 5 +++- 2 files changed, 30 insertions(+), 1 deletion(-) 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 87e8bbc1a3..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 @@ -72,4 +72,30 @@ describe('makeInlineStylesFor()', async () => { } `); }); + + 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 506546667f..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 @@ -69,9 +69,12 @@ export function makeInlineStylesFor( // (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*--[\w-]+\s*,\s*\)/g, ' ') + .replace(/var\(\s*--tw-[\w-]+\s*,\s*\)/g, ' ') .replace(/\s+/g, ' ') .trim(); styles[getReactProperty(declaration.property)] =