diff --git a/packages/components/package.json b/packages/components/package.json
index de81282a4e..59d452461c 100644
--- a/packages/components/package.json
+++ b/packages/components/package.json
@@ -1,6 +1,6 @@
{
"name": "@react-email/components",
- "version": "0.5.6",
+ "version": "1.0.0-tailwindv4.6",
"description": "A collection of all components React Email.",
"sideEffects": false,
"main": "./dist/index.js",
@@ -58,7 +58,7 @@
"@react-email/render": "workspace:1.3.2",
"@react-email/row": "workspace:0.0.12",
"@react-email/section": "workspace:0.0.16",
- "@react-email/tailwind": "workspace:1.2.2",
+ "@react-email/tailwind": "workspace:2.0.0-tailwindv4.4",
"@react-email/text": "workspace:0.1.5"
},
"peerDependencies": {
diff --git a/packages/preview-server/src/utils/__snapshots__/get-email-component.spec.ts.snap b/packages/preview-server/src/utils/__snapshots__/get-email-component.spec.ts.snap
index a6e818a9d9..3886d5c63b 100644
--- a/packages/preview-server/src/utils/__snapshots__/get-email-component.spec.ts.snap
+++ b/packages/preview-server/src/utils/__snapshots__/get-email-component.spec.ts.snap
@@ -12,8 +12,7 @@ exports[`getEmailComponent() > with a demo email template 1`] = `
-
+
with a demo email template 1`] = `
+ style='margin-right:auto;margin-left:auto;margin-bottom:auto;margin-top:auto;background-color:rgb(255,255,255);padding-right:8px;padding-left:8px;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"'>
@@ -40,7 +39,7 @@ exports[`getEmailComponent() > with a demo email template 1`] = `
cellpadding="0"
cellspacing="0"
role="presentation"
- style="margin-left:auto;margin-right:auto;margin-top:40px;margin-bottom:40px;max-width:465px;border-radius:0.25rem;border-width:1px;border-color:rgb(234,234,234);border-style:solid;padding:20px">
+ style="max-width:465px;margin-right:auto;margin-left:auto;margin-bottom:40px;margin-top:40px;border-radius:0.25rem;border-style:solid;border-width:1px;border-color:rgb(234,234,234);padding:20px">
@@ -59,26 +58,26 @@ exports[`getEmailComponent() > with a demo email template 1`] = `
alt="Vercel Logo"
height="37"
src="/static/vercel-logo.png"
- style="margin-left:auto;margin-right:auto;margin-top:0;margin-bottom:0;display:block;outline:none;border:none;text-decoration:none"
+ style="display:block;outline:none;border:none;text-decoration:none;margin-right:auto;margin-left:auto;margin-bottom:0;margin-top:0"
width="40" />
+ style="margin-right:0;margin-left:0;margin-bottom:30px;margin-top:30px;padding:0;text-align:center;font-weight:400;font-size:24px;color:rgb(0,0,0)">
Join Enigma on Vercel
+ style="font-size:14px;line-height:24px;color:rgb(0,0,0);margin-top:16px;margin-bottom:16px">
Hello
alanturing,
+ style="font-size:14px;line-height:24px;color:rgb(0,0,0);margin-top:16px;margin-bottom:16px">
Alan (alan.turing@example.com ) has invited you to the Enigma team on
@@ -110,7 +109,7 @@ exports[`getEmailComponent() > with a demo email template 1`] = `
alt="alanturing's profile picture"
height="64"
src="/static/vercel-user.png"
- style="border-radius:9999px;display:block;outline:none;border:none;text-decoration:none"
+ style="display:block;outline:none;border:none;text-decoration:none;border-radius:9999px"
width="64" />
with a demo email template 1`] = `
alt="Enigma team logo"
height="64"
src="/static/vercel-team.png"
- style="border-radius:9999px;display:block;outline:none;border:none;text-decoration:none"
+ style="display:block;outline:none;border:none;text-decoration:none;border-radius:9999px"
width="64" />
@@ -153,7 +152,7 @@ exports[`getEmailComponent() > with a demo email template 1`] = `
with a demo email template 1`] = `
+ style="font-size:14px;line-height:24px;color:rgb(0,0,0);margin-top:16px;margin-bottom:16px">
or copy and paste this URL into your browser:
https://vercel.com
+ style="width:100%;border:none;border-top:1px solid #eaeaea;margin-right:0;margin-left:0;margin-bottom:26px;margin-top:26px;border-style:solid;border-width:1px;border-color:rgb(234,234,234)" />
+ style="font-size:12px;line-height:24px;color:rgb(102,102,102);margin-top:16px;margin-bottom:16px">
This invitation was intended for
alanturing . This
invite was sent from
diff --git a/packages/react-email/src/commands/testing/__snapshots__/export.spec.ts.snap b/packages/react-email/src/commands/testing/__snapshots__/export.spec.ts.snap
index 615acfb5ad..7e4c1a61e7 100644
--- a/packages/react-email/src/commands/testing/__snapshots__/export.spec.ts.snap
+++ b/packages/react-email/src/commands/testing/__snapshots__/export.spec.ts.snap
@@ -10,8 +10,7 @@ exports[`email export 1`] = `
-
+
+ style='margin-right:auto;margin-left:auto;margin-bottom:auto;margin-top:auto;background-color:rgb(255,255,255);padding-right:8px;padding-left:8px;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"'>
@@ -38,7 +37,7 @@ exports[`email export 1`] = `
cellpadding="0"
cellspacing="0"
role="presentation"
- style="margin-left:auto;margin-right:auto;margin-top:40px;margin-bottom:40px;max-width:465px;border-radius:0.25rem;border-width:1px;border-color:rgb(234,234,234);border-style:solid;padding:20px">
+ style="max-width:465px;margin-right:auto;margin-left:auto;margin-bottom:40px;margin-top:40px;border-radius:0.25rem;border-style:solid;border-width:1px;border-color:rgb(234,234,234);padding:20px">
@@ -57,26 +56,26 @@ exports[`email export 1`] = `
alt="Vercel Logo"
height="37"
src="/static/vercel-logo.png"
- style="margin-left:auto;margin-right:auto;margin-top:0;margin-bottom:0;display:block;outline:none;border:none;text-decoration:none"
+ style="display:block;outline:none;border:none;text-decoration:none;margin-right:auto;margin-left:auto;margin-bottom:0;margin-top:0"
width="40" />
+ style="margin-right:0;margin-left:0;margin-bottom:30px;margin-top:30px;padding:0;text-align:center;font-weight:400;font-size:24px;color:rgb(0,0,0)">
Join on Vercel
+ style="font-size:14px;line-height:24px;color:rgb(0,0,0);margin-top:16px;margin-bottom:16px">
Hello
,
+ style="font-size:14px;line-height:24px;color:rgb(0,0,0);margin-top:16px;margin-bottom:16px">
( ) has invited you to the team on
Vercel .
@@ -106,7 +105,7 @@ exports[`email export 1`] = `
@@ -147,7 +146,7 @@ exports[`email export 1`] = `
+ style="font-size:14px;line-height:24px;color:rgb(0,0,0);margin-top:16px;margin-bottom:16px">
or copy and paste this URL into your browser:
+ style="width:100%;border:none;border-top:1px solid #eaeaea;margin-right:0;margin-left:0;margin-bottom:26px;margin-top:26px;border-style:solid;border-width:1px;border-color:rgb(234,234,234)" />
+ style="font-size:12px;line-height:24px;color:rgb(102,102,102);margin-top:16px;margin-bottom:16px">
This invitation was intended for
. This invite was
sent from
diff --git a/packages/tailwind/copy-tailwind-types.mjs b/packages/tailwind/copy-tailwind-types.mjs
deleted file mode 100644
index 6fe7008614..0000000000
--- a/packages/tailwind/copy-tailwind-types.mjs
+++ /dev/null
@@ -1,13 +0,0 @@
-import { promises as fs } from 'node:fs';
-import path from 'node:path';
-import url from 'node:url';
-
-const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
-
-await fs.cp(
- path.resolve(__dirname, './node_modules/tailwindcss/types'),
- path.resolve(__dirname, './dist/tailwindcss'),
- {
- recursive: true,
- },
-);
diff --git a/packages/tailwind/package.json b/packages/tailwind/package.json
index 43ed1c5e28..b5c1edfdd2 100644
--- a/packages/tailwind/package.json
+++ b/packages/tailwind/package.json
@@ -1,6 +1,6 @@
{
"name": "@react-email/tailwind",
- "version": "1.2.2",
+ "version": "2.0.0-tailwindv4.4",
"description": "A React component to wrap emails with Tailwind CSS",
"sideEffects": false,
"main": "./dist/index.js",
@@ -23,8 +23,8 @@
},
"license": "MIT",
"scripts": {
- "build": "tsc && cross-env NODE_ENV=production vite build --mode production && node ./copy-tailwind-types.mjs",
- "build:watch": "vite build --watch",
+ "build": "tsdown",
+ "build:watch": "tsdown --watch",
"clean": "rm -rf dist",
"test": "vitest run",
"test:watch": "vitest"
@@ -43,7 +43,49 @@
"node": ">=18.0.0"
},
"peerDependencies": {
- "react": "^18.0 || ^19.0 || ^19.0.0-rc"
+ "react": "^18.0 || ^19.0 || ^19.0.0-rc",
+ "@react-email/button": "*",
+ "@react-email/body": "*",
+ "@react-email/code-block": "*",
+ "@react-email/code-inline": "*",
+ "@react-email/container": "*",
+ "@react-email/heading": "*",
+ "@react-email/hr": "*",
+ "@react-email/img": "*",
+ "@react-email/link": "*",
+ "@react-email/preview": "*"
+ },
+ "peerDependenciesMeta": {
+ "@react-email/button": {
+ "optional": true
+ },
+ "@react-email/body": {
+ "optional": true
+ },
+ "@react-email/code-block": {
+ "optional": true
+ },
+ "@react-email/code-inline": {
+ "optional": true
+ },
+ "@react-email/container": {
+ "optional": true
+ },
+ "@react-email/heading": {
+ "optional": true
+ },
+ "@react-email/hr": {
+ "optional": true
+ },
+ "@react-email/img": {
+ "optional": true
+ },
+ "@react-email/link": {
+ "optional": true
+ },
+ "@react-email/preview": {
+ "optional": true
+ }
},
"devDependencies": {
"@react-email/button": "workspace:^",
@@ -54,14 +96,12 @@
"@react-email/link": "workspace:*",
"@react-email/render": "workspace:*",
"@responsive-email/react-email": "0.0.4",
+ "@types/css-tree": "2.3.10",
"@types/shelljs": "0.8.15",
"@vitejs/plugin-react": "4.4.1",
- "cross-env": "10.0.0",
- "postcss": "8.5.3",
- "postcss-selector-parser": "7.1.0",
+ "css-tree": "3.1.0",
"react-dom": "^19",
"shelljs": "0.9.2",
- "tailwindcss": "3.4.10",
"tsconfig": "workspace:*",
"typescript": "5.8.3",
"vite": "6.3.6",
@@ -70,5 +110,8 @@
},
"publishConfig": {
"access": "public"
+ },
+ "dependencies": {
+ "tailwindcss": "4.1.12"
}
}
diff --git a/packages/tailwind/src/__snapshots__/tailwind.spec.tsx.snap b/packages/tailwind/src/__snapshots__/tailwind.spec.tsx.snap
index 0bdcd1b93d..7ffb5d28fc 100644
--- a/packages/tailwind/src/__snapshots__/tailwind.spec.tsx.snap
+++ b/packages/tailwind/src/__snapshots__/tailwind.spec.tsx.snap
@@ -1,52 +1,225 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-exports[`Custom plugins config > uses custom plugins 1`] = `"
"`;
+exports[`Tailwind component > 1`] = `"Testing button Testing"`;
-exports[`Custom plugins config > uses custom plugins with responsive styles 1`] = `"
"`;
+exports[`Tailwind component > allows for complex children manipulation 1`] = `"This is the second column
"`;
-exports[`Custom theme config > uses custom border radius 1`] = `"
"`;
+exports[`Tailwind component > does not override inline styles with Tailwind styles 1`] = `"
"`;
-exports[`Custom theme config > uses custom colors 1`] = `"
"`;
+exports[`Tailwind component > doesn't generate styles from text 1`] = `"container bg-red-500 bg-blue-300"`;
-exports[`Custom theme config > uses custom fonts 1`] = `"
"`;
+exports[`Tailwind component > overrides component styles with Tailwind styles 1`] = `" "`;
-exports[`Custom theme config > uses custom spacing 1`] = `"
"`;
+exports[`Tailwind component > preserves mso styles 1`] = `
+"
+
+
+
+
+
+
+
+
+
+"
+`;
-exports[`Custom theme config > uses custom text alignment 1`] = `"
"`;
+exports[`Tailwind component > recognizes custom responsive screen 1`] = `
+"
+
+
+
+
+
+
+
+
+ Test
+
+ Test
+
+
+"
+`;
-exports[`Tailwind component > 1`] = `"Testing button Testing"`;
+exports[`Tailwind component > renders children with inline Tailwind styles 1`] = `"
"`;
-exports[`Tailwind component > allows for complex children manipulation 1`] = `"This is the second column
"`;
+exports[`Tailwind component > uses background image 1`] = `"
"`;
-exports[`Tailwind component > does not override inline styles with Tailwind styles 1`] = `"
"`;
+exports[`Tailwind component > with custom plugins config > supports custom plugins 1`] = `
+"
+
+
+
+"
+`;
-exports[`Tailwind component > it should not generate styles from text 1`] = `"container bg-red-500 bg-blue-300"`;
+exports[`Tailwind component > with custom plugins config > supports custom plugins with responsive styles 1`] = `
+"
+
+
+
+
+
+
+
+
+
+
+"
+`;
-exports[`Tailwind component > preserves mso styles 1`] = `"
"`;
+exports[`Tailwind component > with custom theme config > supports custom border radius 1`] = `
+"
+
+
+
+"
+`;
-exports[`Tailwind component > recognizes custom responsive screen 1`] = `"Test
Test
"`;
+exports[`Tailwind component > with custom theme config > supports custom colors 1`] = `
+"
+
+
+
+"
+`;
-exports[`Tailwind component > uses background image 1`] = `"
"`;
+exports[`Tailwind component > with custom theme config > supports custom fonts 1`] = `
+"
+
+
+
+
+"
+`;
-exports[`Tailwind component > warns about safelist not being supported 1`] = `
+exports[`Tailwind component > with custom theme config > supports custom spacing 1`] = `
+"
+
+
+
+"
+`;
+
+exports[`Tailwind component > with custom theme config > supports custom text alignment 1`] = `
+"
+
+
+
+"
+`;
+
+exports[`Tailwind component > with non-inlinable styles > adds css to and keep class names 1`] = `
+"
+
+
+
+
+
+
+
+
+
+
+"
+`;
+
+exports[`Tailwind component > with non-inlinable styles > persists existing elements 1`] = `
+"
+
+
+
+
+
+
+
+
+
+
+
+
+"
+`;
+
+exports[`Tailwind component > with non-inlinable styles > throws an error when used without a 1`] = `
+[Error: You are trying to use the following Tailwind classes that cannot be inlined: sm:bg-red-500.
+For the media queries to work properly on rendering, they need to be added into a
+
+
+
+
+
+
+"
+`;
+
+exports[`Tailwind component > with non-inlinable styles > works with arbitrarily deep (in the React tree) elements 2`] = `"
"`;
+
+exports[`Tailwind component > with non-inlinable styles > works with relatively complex media query utilities 1`] = `
"
-
-
- Click me
-
+I am some text
+
"
`;
-exports[`Tailwind component > works properly with 'no-underline' 1`] = `"or copy and paste this URL into your browser: https://react.email
or copy and paste this URL into your browser: https://react.email
"`;
+exports[`Tailwind component > works properly with 'no-underline' 1`] = `"or copy and paste this URL into your browser: https://react.email
or copy and paste this URL into your browser: https://react.email
"`;
exports[`Tailwind component > works with Heading component 1`] = `"HelloMy testing heading friends"`;
@@ -57,7 +230,7 @@ exports[`Tailwind component > works with blocklist 1`] = `
@@ -67,63 +240,27 @@ exports[`Tailwind component > works with blocklist 1`] = `
"
`;
-exports[`Tailwind component > works with calc() with + sign 1`] = `""`;
-
-exports[`Tailwind component > works with class manipulation done on components 1`] = `"
"`;
-
-exports[`Tailwind component > works with components that return children 1`] = `"Hello world
"`;
-
-exports[`Tailwind component > works with components that use React.forwardRef 1`] = `"Hello world
"`;
-
-exports[`Tailwind component > works with custom components with fragment at the root 1`] = `"Hello world
"`;
-
-exports[`non-inlinable styles > adds css to and keeps class names 1`] = `"
"`;
-
-exports[`non-inlinable styles > does not have duplicate media queries 1`] = `
+exports[`Tailwind component > works with calc() with + sign 1`] = `
"
-
-
-
-
-
-
+
+
"
`;
-exports[`non-inlinable styles > persists existing elements 1`] = `"
"`;
-
-exports[`non-inlinable styles > throws an error when used without a
"`;
+exports[`Tailwind component > works with components that return children 1`] = `"Hello world
"`;
-exports[`non-inlinable styles > works with arbitrarily deep (in the React tree) elements 2`] = `"
"`;
+exports[`Tailwind component > works with components that use React.forwardRef 1`] = `"Hello world
"`;
-exports[`non-inlinable styles > works with relatively complex media query utilities 1`] = `"I am some text
"`;
+exports[`Tailwind component > works with custom components with fragment at the root 1`] = `"Hello world
"`;
diff --git a/packages/tailwind/src/hooks/__snapshots__/use-tailwind.spec.ts.snap b/packages/tailwind/src/hooks/__snapshots__/use-tailwind.spec.ts.snap
deleted file mode 100644
index baebff2970..0000000000
--- a/packages/tailwind/src/hooks/__snapshots__/use-tailwind.spec.ts.snap
+++ /dev/null
@@ -1,16 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[`useTailwind() 1`] = `
-".text-red-500 {
- color: rgb(239 68 68 / 1)
-}
- @media (min-width: 640px) {
- .sm\\:bg-blue-300 {
- background-color: rgb(147 197 253 / 1)
- }
-}
- .bg-slate-900 {
- background-color: rgb(15 23 42 / 1)
-}
-"
-`;
diff --git a/packages/tailwind/src/hooks/use-suspensed-promise.ts b/packages/tailwind/src/hooks/use-suspended-promise.ts
similarity index 100%
rename from packages/tailwind/src/hooks/use-suspensed-promise.ts
rename to packages/tailwind/src/hooks/use-suspended-promise.ts
diff --git a/packages/tailwind/src/hooks/use-suspensed-promise.spec.ts b/packages/tailwind/src/hooks/use-suspensed-promise.spec.ts
index a43199c247..2063a1ab49 100644
--- a/packages/tailwind/src/hooks/use-suspensed-promise.spec.ts
+++ b/packages/tailwind/src/hooks/use-suspensed-promise.spec.ts
@@ -1,5 +1,5 @@
/** biome-ignore-all lint/correctness/useHookAtTopLevel: function is not a React hook */
-import { useSuspensedPromise } from './use-suspensed-promise';
+import { useSuspensedPromise } from './use-suspended-promise';
describe('useSuspensedPromise', () => {
beforeEach(() => {});
diff --git a/packages/tailwind/src/tailwind.spec.tsx b/packages/tailwind/src/tailwind.spec.tsx
index cc05724cbd..8b36f1655a 100644
--- a/packages/tailwind/src/tailwind.spec.tsx
+++ b/packages/tailwind/src/tailwind.spec.tsx
@@ -4,10 +4,9 @@ import { Heading } from '@react-email/heading';
import { Hr } from '@react-email/hr';
import { Html } from '@react-email/html';
import { Link } from '@react-email/link';
-import { render } from '@react-email/render';
+import { pretty, render } from '@react-email/render';
import { ResponsiveColumn, ResponsiveRow } from '@responsive-email/react-email';
import React from 'react';
-import { vi } from 'vitest';
import type { TailwindConfig } from '.';
import { Tailwind } from '.';
@@ -26,7 +25,11 @@ describe('Tailwind component', () => {
it('works with blocklist', async () => {
const actualOutput = await render(
-
+
@@ -40,25 +43,6 @@ describe('Tailwind component', () => {
expect(actualOutput).toMatchSnapshot();
});
- it('warns about safelist not being supported', async () => {
- const spy = vi.spyOn(console, 'warn');
-
- const actualOutput = await render(
-
-
-
-
- Click me
-
-
- ,
- { pretty: true },
- );
-
- expect(spy).toHaveBeenCalled();
- expect(actualOutput).toMatchSnapshot();
- });
-
it('works with class manipulation done on components', async () => {
const MyComponnt = (props: {
className?: string;
@@ -66,11 +50,9 @@ describe('Tailwind component', () => {
}) => {
expect(
props.style,
- 'Styles should be generated the same for a component',
- ).toEqual({
- color: 'rgb(96,165,250)',
- padding: '1rem',
- });
+ 'styles should not be generated for a component',
+ ).toBeUndefined();
+ expect(props.className).toBe('p-4 text-blue-400');
return (
{
expect(actualOutput).toMatchSnapshot();
});
- describe('Inline styles', () => {
- it('renders children with inline Tailwind styles', async () => {
- const actualOutput = await render(
-
-
- ,
- );
+ it('renders children with inline Tailwind styles', async () => {
+ const actualOutput = await render(
+
+
+ ,
+ );
- expect(actualOutput).not.toBeNull();
- });
+ expect(actualOutput).toMatchSnapshot();
});
test('
', async () => {
@@ -175,7 +155,7 @@ describe('Tailwind component', () => {
expect(actualOutput).toMatchSnapshot();
});
- test('it should not generate styles from text', async () => {
+ it("doesn't generate styles from text", async () => {
expect(
await render(container bg-red-500 bg-blue-300 ),
).toMatchSnapshot();
@@ -284,7 +264,7 @@ describe('Tailwind component', () => {
,
);
- expect(actualOutput).toContain('width:3rem');
+ expect(actualOutput).toMatchSnapshot();
});
it('preserves mso styles', async () => {
@@ -300,32 +280,33 @@ describe('Tailwind component', () => {
,
- );
+ ).then(pretty);
expect(actualOutput).toMatchSnapshot();
});
it('recognizes custom responsive screen', async () => {
- const config: TailwindConfig = {
- theme: {
- screens: {
- sm: { min: '640px' },
- md: { min: '768px' },
- lg: { min: '1024px' },
- xl: { min: '1280px' },
- '2xl': { min: '1536px' },
- },
- },
- };
const actualOutput = await render(
-
+
Test
Test
,
- );
+ ).then(pretty);
expect(actualOutput).toMatchSnapshot();
});
@@ -338,14 +319,13 @@ describe('Tailwind component', () => {
something tall
,
- );
+ ).then(pretty);
expect(actualOutput).toMatchSnapshot();
});
-});
-describe('non-inlinable styles', () => {
- /*
+ describe('with non-inlinable styles', () => {
+ /*
This test is because of https://github.com/resend/react-email/issues/1112
which was being caused because we required to, either have our component,
or a element directly inside the component for media queries to be applied
@@ -356,309 +336,277 @@ describe('non-inlinable styles', () => {
and apply the styles there. This also fixes the issue where it would not be allowed to use
Tailwind classes on the element as the would be required directly bellow Tailwind.
*/
- it('works with arbitrarily deep (in the React tree) elements', async () => {
- expect(
- await render(
-
-
-
-
-
-
-
- ,
- ),
- ).toMatchSnapshot();
+ it('works with arbitrarily deep (in the React tree) elements', async () => {
+ expect(
+ await render(
+
+
+
+
+
+
+
+ ,
+ ).then(pretty),
+ ).toMatchSnapshot();
+
+ const MyHead = (props: Record) => {
+ return ;
+ };
- const MyHead = (props: Record) => {
- return ;
- };
+ expect(
+ await render(
+
+
+
+
+
+
+
+ ,
+ ),
+ ).toMatchSnapshot();
+ });
- expect(
- await render(
-
-
-
+ it('adds css to and keep class names', async () => {
+ const actualOutput = await render(
+
+
+
-
+
-
- ,
- ),
- ).toMatchSnapshot();
- });
-
- it('does not have duplicate media queries', async () => {
- const Body = (props: { className: string; children: React.ReactNode }) => {
- return {props.children};
- };
- const output = await render(
-
-
-
-
-
- ,
- {
- pretty: true,
- },
- );
-
- expect(output).toMatchSnapshot();
- });
-
- it('adds css to
-
-
-
- ,
- );
-
- expect(actualOutput).toMatchSnapshot();
- });
+
+ ,
+ ).then(pretty);
- it('throws error when used without the head and with media query class names only very deeply nested', async () => {
- const Component1 = (props: Record) => {
- return (
-
- {props.children}
-
- );
- };
- const Component2 = (props: Record) => {
- return (
-
- {props.children}
-
- );
- };
- const Component3 = (props: Record) => {
- return (
-
- {props.children}
-
- );
- };
+ expect(actualOutput).toMatchSnapshot();
+ });
- function renderComplexEmailWithoutHead() {
- return render(
-
-
-
- Testing
-
+ it('throws error when used without the head and with media query class names very deeply nested', async () => {
+ const Component1 = (props: Record
) => {
+ return (
+
+ {props.children}
- ,
- );
- }
+ );
+ };
+ const Component2 = (props: Record) => {
+ return (
+
+ {props.children}
+
+ );
+ };
+ const Component3 = (props: Record) => {
+ return (
+
+ {props.children}
+
+ );
+ };
- await expect(
- renderComplexEmailWithoutHead,
- ).rejects.toThrowErrorMatchingSnapshot();
- });
+ function renderComplexEmailWithoutHead() {
+ return render(
+
+
+ ,
+ );
+ }
+
+ await expect(
+ renderComplexEmailWithoutHead,
+ ).rejects.toThrowErrorMatchingSnapshot();
+ });
- it('works with relatively complex media query utilities', async () => {
- const Email = () => {
- return (
-
-
- I am some text
-
- );
- };
+ it('works with relatively complex media query utilities', async () => {
+ const Email = () => {
+ return (
+
+
+ I am some text
+
+ );
+ };
- expect(await render( )).toMatchSnapshot();
- });
+ expect(await render( ).then(pretty)).toMatchSnapshot();
+ });
- it('throws an error when used without a ', async () => {
- function noHead() {
- return render(
-
-
- {/* */}
-
-
- ,
- );
- }
- await expect(noHead).rejects.toThrowErrorMatchingSnapshot();
- });
+ it('throws an error when used without a ', async () => {
+ function noHead() {
+ return render(
+
+
+ {/* */}
+
+
+ ,
+ ).then(pretty);
+ }
+ await expect(noHead).rejects.toThrowErrorMatchingSnapshot();
+ });
- it('persists existing elements', async () => {
- const actualOutput = await render(
-
-
-
-
-
-
-
-
-
-
- ,
- );
+ it('persists existing
elements', async () => {
+ const actualOutput = await render(
+
+
+
+
+
+
+
+
+
+
+ ,
+ ).then(pretty);
- expect(actualOutput).toMatchSnapshot();
+ expect(actualOutput).toMatchSnapshot();
+ });
});
-});
-describe('Custom theme config', () => {
- it('uses custom colors', async () => {
- const config: TailwindConfig = {
- theme: {
- extend: {
- colors: {
- custom: '#1fb6ff',
+ describe('with custom theme config', () => {
+ it('supports custom colors', async () => {
+ const config: TailwindConfig = {
+ theme: {
+ extend: {
+ colors: {
+ custom: '#1fb6ff',
+ },
},
},
- },
- };
+ };
- const actualOutput = await render(
-
-
- ,
- );
+ const actualOutput = await render(
+
+
+ ,
+ ).then(pretty);
- expect(actualOutput).toMatchSnapshot();
- });
+ expect(actualOutput).toMatchSnapshot();
+ });
- it('uses custom fonts', async () => {
- const config: TailwindConfig = {
- theme: {
- extend: {
- fontFamily: {
- sans: ['Graphik', 'sans-serif'],
- serif: ['Merriweather', 'serif'],
+ it('supports custom fonts', async () => {
+ const config: TailwindConfig = {
+ theme: {
+ extend: {
+ fontFamily: {
+ sans: ['Graphik', 'sans-serif'],
+ serif: ['Merriweather', 'serif'],
+ },
},
},
- },
- };
+ };
- const actualOutput = await render(
-
-
-
- ,
- );
+ const actualOutput = await render(
+
+
+
+ ,
+ ).then(pretty);
- expect(actualOutput).toMatchSnapshot();
- });
+ expect(actualOutput).toMatchSnapshot();
+ });
- it('uses custom spacing', async () => {
- const config: TailwindConfig = {
- theme: {
- extend: {
- spacing: {
- '8xl': '96rem',
+ it('supports custom spacing', async () => {
+ const config: TailwindConfig = {
+ theme: {
+ extend: {
+ spacing: {
+ '8xl': '96rem',
+ },
},
},
- },
- };
- const actualOutput = await render(
-
-
- ,
- );
- expect(actualOutput).toMatchSnapshot();
- });
+ };
+ const actualOutput = await render(
+
+
+ ,
+ ).then(pretty);
+ expect(actualOutput).toMatchSnapshot();
+ });
- it('uses custom border radius', async () => {
- const config: TailwindConfig = {
- theme: {
- extend: {
- borderRadius: {
- '4xl': '2rem',
+ it('supports custom border radius', async () => {
+ const config: TailwindConfig = {
+ theme: {
+ extend: {
+ borderRadius: {
+ '4xl': '2rem',
+ },
},
},
- },
- };
- const actualOutput = await render(
-
-
- ,
- );
- expect(actualOutput).toMatchSnapshot();
- });
+ };
+ const actualOutput = await render(
+
+
+ ,
+ ).then(pretty);
+ expect(actualOutput).toMatchSnapshot();
+ });
- it('uses custom text alignment', async () => {
- const config: TailwindConfig = {
- theme: {
- extend: {
- textAlign: {
- justify: 'justify',
+ it('supports custom text alignment', async () => {
+ const config: TailwindConfig = {
+ theme: {
+ extend: {
+ textAlign: {
+ justify: 'justify',
+ },
},
},
- },
- };
+ };
- const actualOutput = await render(
-
-
- ,
- );
+ const actualOutput = await render(
+
+
+ ,
+ ).then(pretty);
- expect(actualOutput).toMatchSnapshot();
+ expect(actualOutput).toMatchSnapshot();
+ });
});
-});
-describe('Custom plugins config', () => {
- it('uses custom plugins', async () => {
- const config: TailwindConfig = {
+ describe('with custom plugins config', () => {
+ const config = {
plugins: [
- ({ addUtilities }: any) => {
- const newUtilities = {
- '.border-custom': {
- border: '2px solid',
- },
- };
-
- addUtilities(newUtilities);
+ {
+ handler: (api) => {
+ api.addUtilities({
+ '.border-custom': {
+ border: '2px solid',
+ },
+ });
+ },
},
],
- };
+ } satisfies TailwindConfig;
- const actualOutput = await render(
-
-
- ,
- );
-
- expect(actualOutput).toMatchSnapshot();
- });
-
- it('uses custom plugins with responsive styles', async () => {
- const config: TailwindConfig = {
- plugins: [
- ({ addUtilities }: any) => {
- const newUtilities = {
- '.border-custom': {
- border: '2px solid',
- },
- };
+ it('supports custom plugins', async () => {
+ const actualOutput = await render(
+
+
+ ,
+ ).then(pretty);
- addUtilities(newUtilities);
- },
- ],
- };
+ expect(actualOutput).toMatchSnapshot();
+ });
- const actualOutput = await render(
-
-
-
-
-
-
-
- ,
- );
+ it('supports custom plugins with responsive styles', async () => {
+ const actualOutput = await render(
+
+
+
+
+
+
+
+ ,
+ ).then(pretty);
- expect(actualOutput).toMatchSnapshot();
+ expect(actualOutput).toMatchSnapshot();
+ });
});
});
diff --git a/packages/tailwind/src/tailwind.tsx b/packages/tailwind/src/tailwind.tsx
index f8b100bfb2..142a9558b2 100644
--- a/packages/tailwind/src/tailwind.tsx
+++ b/packages/tailwind/src/tailwind.tsx
@@ -1,27 +1,17 @@
-import { Root } from 'postcss';
+import { type CssNode, generate, List, type StyleSheet } from 'css-tree';
import * as React from 'react';
-import type { Config as TailwindOriginalConfig } from 'tailwindcss';
-import { minifyCss } from './utils/css/minify-css';
-import { removeRuleDuplicatesFromRoot } from './utils/css/remove-rule-duplicates-from-root';
+import type { Config } from 'tailwindcss';
+import { useSuspensedPromise } from './hooks/use-suspended-promise';
+import { extractRulesPerClass } from './utils/css/extract-rules-per-class';
+import { resolveAllCssVariables } from './utils/css/resolve-all-css-variables';
+import { resolveCalcExpressions } from './utils/css/resolve-calc-expressions';
+import { sanitizeDeclarations } from './utils/css/sanitize-declarations';
+import { sanitizeNonInlinableRules } from './utils/css/sanitize-non-inlinable-rules';
import { mapReactTree } from './utils/react/map-react-tree';
import { cloneElementWithInlinedStyles } from './utils/tailwindcss/clone-element-with-inlined-styles';
import { setupTailwind } from './utils/tailwindcss/setup-tailwind';
-export type TailwindConfig = Pick<
- TailwindOriginalConfig,
- | 'important'
- | 'prefix'
- | 'separator'
- | 'safelist'
- | 'blocklist'
- | 'presets'
- | 'future'
- | 'experimental'
- | 'darkMode'
- | 'theme'
- | 'corePlugins'
- | 'plugins'
->;
+export type TailwindConfig = Omit;
export interface TailwindProps {
children: React.ReactNode;
@@ -36,128 +26,134 @@ export interface EmailElementProps {
export const pixelBasedPreset: TailwindConfig = {
theme: {
- fontSize: {
- xs: ['12px', { lineHeight: '16px' }],
- sm: ['14px', { lineHeight: '20px' }],
- base: ['16px', { lineHeight: '24px' }],
- lg: ['18px', { lineHeight: '28px' }],
- xl: ['20px', { lineHeight: '28px' }],
- '2xl': ['24px', { lineHeight: '32px' }],
- '3xl': ['30px', { lineHeight: '36px' }],
- '4xl': ['36px', { lineHeight: '36px' }],
- '5xl': ['48px', { lineHeight: '1' }],
- '6xl': ['60px', { lineHeight: '1' }],
- '7xl': ['72px', { lineHeight: '1' }],
- '8xl': ['96px', { lineHeight: '1' }],
- '9xl': ['144px', { lineHeight: '1' }],
- },
- spacing: {
- px: '1px',
- 0: '0',
- 0.5: '2px',
- 1: '4px',
- 1.5: '6px',
- 2: '8px',
- 2.5: '10px',
- 3: '12px',
- 3.5: '14px',
- 4: '16px',
- 5: '20px',
- 6: '24px',
- 7: '28px',
- 8: '32px',
- 9: '36px',
- 10: '40px',
- 11: '44px',
- 12: '48px',
- 14: '56px',
- 16: '64px',
- 20: '80px',
- 24: '96px',
- 28: '112px',
- 32: '128px',
- 36: '144px',
- 40: '160px',
- 44: '176px',
- 48: '192px',
- 52: '208px',
- 56: '224px',
- 60: '240px',
- 64: '256px',
- 72: '288px',
- 80: '320px',
- 96: '384px',
+ extend: {
+ fontSize: {
+ xs: ['12px', { lineHeight: '16px' }],
+ sm: ['14px', { lineHeight: '20px' }],
+ base: ['16px', { lineHeight: '24px' }],
+ lg: ['18px', { lineHeight: '28px' }],
+ xl: ['20px', { lineHeight: '28px' }],
+ '2xl': ['24px', { lineHeight: '32px' }],
+ '3xl': ['30px', { lineHeight: '36px' }],
+ '4xl': ['36px', { lineHeight: '36px' }],
+ '5xl': ['48px', { lineHeight: '1' }],
+ '6xl': ['60px', { lineHeight: '1' }],
+ '7xl': ['72px', { lineHeight: '1' }],
+ '8xl': ['96px', { lineHeight: '1' }],
+ '9xl': ['144px', { lineHeight: '1' }],
+ },
+ spacing: {
+ px: '1px',
+ 0: '0',
+ 0.5: '2px',
+ 1: '4px',
+ 1.5: '6px',
+ 2: '8px',
+ 2.5: '10px',
+ 3: '12px',
+ 3.5: '14px',
+ 4: '16px',
+ 5: '20px',
+ 6: '24px',
+ 7: '28px',
+ 8: '32px',
+ 9: '36px',
+ 10: '40px',
+ 11: '44px',
+ 12: '48px',
+ 14: '56px',
+ 16: '64px',
+ 20: '80px',
+ 24: '96px',
+ 28: '112px',
+ 32: '128px',
+ 36: '144px',
+ 40: '160px',
+ 44: '176px',
+ 48: '192px',
+ 52: '208px',
+ 56: '224px',
+ 60: '240px',
+ 64: '256px',
+ 72: '288px',
+ 80: '320px',
+ 96: '384px',
+ },
},
},
};
-export const Tailwind: React.FC = ({ children, config }) => {
- const tailwind = setupTailwind(config ?? {});
-
- const nonInlineStylesRootToApply = new Root();
- let mediaQueryClassesForAllElement: string[] = [];
-
- let hasNonInlineStylesToApply = false as boolean;
+export function Tailwind({ children, config }: TailwindProps) {
+ const tailwindSetup = useSuspensedPromise(
+ () => setupTailwind(config ?? {}),
+ JSON.stringify(config),
+ );
+ let classesUsed: string[] = [];
let mappedChildren: React.ReactNode = mapReactTree(children, (node) => {
if (React.isValidElement(node)) {
- const {
- elementWithInlinedStyles,
- nonInlinableClasses,
- nonInlineStyleNodes,
- } = cloneElementWithInlinedStyles(node, tailwind);
- mediaQueryClassesForAllElement =
- mediaQueryClassesForAllElement.concat(nonInlinableClasses);
- nonInlineStylesRootToApply.append(nonInlineStyleNodes);
-
- if (nonInlinableClasses.length > 0 && !hasNonInlineStylesToApply) {
- hasNonInlineStylesToApply = true;
+ if (node.props.className) {
+ const classes = node.props.className?.split(/\s+/);
+ classesUsed = [...classesUsed, ...classes];
+ tailwindSetup.addUtilities(classes);
}
-
- return elementWithInlinedStyles;
}
return node;
});
- removeRuleDuplicatesFromRoot(nonInlineStylesRootToApply);
+ const styleSheet = tailwindSetup.getStyleSheet();
+ resolveAllCssVariables(styleSheet);
+ resolveCalcExpressions(styleSheet);
+ sanitizeDeclarations(styleSheet);
- if (hasNonInlineStylesToApply) {
- let hasAppliedNonInlineStyles = false as boolean;
+ const { inlinable: inlinableRules, nonInlinable: nonInlinableRules } =
+ extractRulesPerClass(styleSheet, classesUsed);
+ sanitizeNonInlinableRules(styleSheet);
- mappedChildren = mapReactTree(mappedChildren, (node) => {
- if (hasAppliedNonInlineStyles) {
- return node;
- }
+ const nonInlineStyles: StyleSheet = {
+ type: 'StyleSheet',
+ children: new List().fromArray(
+ nonInlinableRules.values().toArray(),
+ ),
+ };
+
+ const hasNonInlineStylesToApply = nonInlinableRules.size > 0;
+ let appliedNonInlineStyles = false as boolean;
+
+ mappedChildren = mapReactTree(mappedChildren, (node) => {
+ if (React.isValidElement(node)) {
+ const elementWithInlinedStyles = cloneElementWithInlinedStyles(
+ node,
+ inlinableRules,
+ nonInlinableRules,
+ );
+
+ if (elementWithInlinedStyles.type === 'head') {
+ appliedNonInlineStyles = true;
- if (React.isValidElement(node)) {
- if (node.type === 'head') {
- hasAppliedNonInlineStyles = true;
-
- /* only minify here since it is the only place that is going to be in the DOM */
- const styleElement = (
-
- );
-
- return React.cloneElement(
- node,
- node.props,
- node.props.children,
- styleElement,
- );
- }
+ const styleElement = ;
+
+ return React.cloneElement(
+ elementWithInlinedStyles,
+ elementWithInlinedStyles.props,
+ styleElement,
+ elementWithInlinedStyles.props.children,
+ );
}
- return node;
- });
+ return elementWithInlinedStyles;
+ }
+
+ return node;
+ });
- if (!hasAppliedNonInlineStyles) {
- throw new Error(
- `You are trying to use the following Tailwind classes that cannot be inlined: ${mediaQueryClassesForAllElement.join(
- ' ',
- )}.
+ if (hasNonInlineStylesToApply && !appliedNonInlineStyles) {
+ throw new Error(
+ `You are trying to use the following Tailwind classes that cannot be inlined: ${nonInlinableRules
+ .keys()
+ .toArray()
+ .join(' ')}.
For the media queries to work properly on rendering, they need to be added into a