Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(tailwind): extract pseudo classes to stylesheet #1864

Open
wants to merge 16 commits into
base: canary
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dry-adults-grab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-email": patch
---

Fixes active state for root email templats on file tree
5 changes: 5 additions & 0 deletions .changeset/great-parrots-yell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@react-email/tailwind": minor
---

Extract tailwind pseudo classes to stylesheet
72 changes: 36 additions & 36 deletions packages/tailwind/src/__snapshots__/tailwind.spec.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -14,42 +14,6 @@ exports[`Custom theme config > should be able to use custom spacing 1`] = `"<!DO

exports[`Custom theme config > should be able to use custom text alignment 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><div style="text-align:justify"></div><!--/$-->"`;

exports[`Responsive styles > should add css to <head/> and keep responsive class names 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html lang="en"><head><!--$--><style>@media(min-width:640px){.sm_bg-red-300{background-color:rgb(252,165,165) !important}}@media(min-width:768px){.md_bg-red-400{background-color:rgb(248,113,113) !important}}@media(min-width:1024px){.lg_bg-red-500{background-color:rgb(239,68,68) !important}}</style></head><body><div class="sm_bg-red-300 md_bg-red-400 lg_bg-red-500" style="background-color:rgb(254,202,202)"></div><!--/$--></body></html>"`;

exports[`Responsive styles > should not have duplicate media queries 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><head><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/><!--$--><style>@media(min-width:768px){.md_px-64px{padding-left:64px !important;padding-right:64px !important}}</style></head><body class="md_px-64px" style="background-color:rgb(255,255,255);margin-top:auto;margin-bottom:auto;margin-left:auto;margin-right:auto;font-family:ui-sans-serif, system-ui, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;"><div class="md_px-64px"></div><!--/$--></body>"`;

exports[`Responsive styles > should persist existing <head/> elements 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html lang="en"><head><!--$--><style></style><link/><style>@media(min-width:640px){.sm_bg-red-500{background-color:rgb(239,68,68) !important}}</style></head><body><div class="sm_bg-red-500" style="background-color:rgb(254,202,202)"></div><!--/$--></body></html>"`;

exports[`Responsive styles > should throw an error when used without a <head/> 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 <style> tag inside of a <head> tag,
the Tailwind component tried finding a <head> element but just wasn't able to find it.

Make sure that you have a <head> element at some point inside of the <Tailwind> component at any depth.
This can also be our <Head> component.

If you do already have a <head> element at some depth,
please file a bug https://github.com/resend/react-email/issues/new?assignees=&labels=Type%3A+Bug&projects=&template=1.bug_report.yml.]
`;

exports[`Responsive styles > should throw error when used without the head and with media query class names only very deeply nested 1`] = `
[Error: You are trying to use the following Tailwind classes that cannot be inlined: sm:h-10 sm:w-10.
For the media queries to work properly on rendering, they need to be added into a <style> tag inside of a <head> tag,
the Tailwind component tried finding a <head> element but just wasn't able to find it.

Make sure that you have a <head> element at some point inside of the <Tailwind> component at any depth.
This can also be our <Head> component.

If you do already have a <head> element at some depth,
please file a bug https://github.com/resend/react-email/issues/new?assignees=&labels=Type%3A+Bug&projects=&template=1.bug_report.yml.]
`;

exports[`Responsive styles > should work with arbitrarily deep (in the React tree) <head> elements 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html lang="en"><head><!--$--><style>@media(min-width:640px){.sm_bg-red-300{background-color:rgb(252,165,165) !important}}@media(min-width:768px){.md_bg-red-400{background-color:rgb(248,113,113) !important}}@media(min-width:1024px){.lg_bg-red-500{background-color:rgb(239,68,68) !important}}</style></head><body><div class="sm_bg-red-300 md_bg-red-400 lg_bg-red-500" style="background-color:rgb(254,202,202)"></div><!--/$--></body></html>"`;

exports[`Responsive styles > should work with arbitrarily deep (in the React tree) <head> elements 2`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html lang="en"><head><!--$--><style>@media(min-width:640px){.sm_bg-red-300{background-color:rgb(252,165,165) !important}}@media(min-width:768px){.md_bg-red-400{background-color:rgb(248,113,113) !important}}@media(min-width:1024px){.lg_bg-red-500{background-color:rgb(239,68,68) !important}}</style></head><body><div class="sm_bg-red-300 md_bg-red-400 lg_bg-red-500" style="background-color:rgb(254,202,202)"></div><!--/$--></body></html>"`;

exports[`Responsive styles > should work with relatively complex media query utilities 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><head><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/><!--$--><style>@media not all and(min-width:640px){.max-sm_text-red-600{color:rgb(220,38,38) !important}}</style></head><p class="max-sm_text-red-600" style="color:rgb(29,78,216)">I am some text</p><!--/$-->"`;

exports[`Tailwind component > <Button className="px-3 py-2 mt-8 text-sm text-gray-200 bg-blue-600 rounded-md"> 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><a style="padding-left:0.75rem;padding-right:0.75rem;padding-top:0.5rem;padding-bottom:0.5rem;margin-top:2rem;font-size:0.875rem;line-height:1.25rem;color:rgb(229,231,235);background-color:rgb(37,99,235);border-radius:0.375rem;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;padding:8px 12px 8px 12px" target="_blank"><span><!--[if mso]><i style="mso-font-width:300%;mso-text-raise:12" hidden>&#8202;&#8202;</i><![endif]--></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:6px">Testing button</span><span><!--[if mso]><i style="mso-font-width:300%" hidden>&#8202;&#8202;&#8203;</i><![endif]--></span></a>Testing<!--/$-->"`;

exports[`Tailwind component > it should not generate styles from text 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$-->container bg-red-500 bg-blue-300<!--/$-->"`;
Expand Down Expand Up @@ -77,3 +41,39 @@ exports[`Tailwind component > should work with components that return children 1
exports[`Tailwind component > should work with components that use React.forwardRef 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><div style="font-size:50px;line-height:1;margin-top:100px">Hello world</div><div style="padding:20px"><p style="font-weight:700;font-size:50px">React Email</p></div><!--/$-->"`;

exports[`Tailwind component > should work with custom components with fragment at the root 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><div style="font-size:50px;line-height:1;margin-top:100px">Hello world</div><div style="padding:20px"><p style="font-weight:700;font-size:50px">React Email</p></div><div style="padding:20px"><p style="font-weight:700;font-size:50px">React Email</p></div><!--/$-->"`;

exports[`non-inlinable styles > should add css to <head/> and keep class names 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html lang="en"><head><!--$--><style>@media(min-width:640px){.sm_bg-red-300{background-color:rgb(252,165,165) !important}}@media(min-width:768px){.md_bg-red-400{background-color:rgb(248,113,113) !important}}@media(min-width:1024px){.lg_bg-red-500{background-color:rgb(239,68,68) !important}}@media(min-width:640px){.sm_hover_bg-red-200:hover{background-color:rgb(254,202,202) !important}}.hover_bg-red-600:hover{background-color:rgb(220,38,38) !important}.focus_bg-red-700:focus{background-color:rgb(185,28,28) !important}</style></head><body><div class="sm_bg-red-300 md_bg-red-400 lg_bg-red-500 hover_bg-red-600 focus_bg-red-700 sm_hover_bg-red-200" style="background-color:rgb(254,202,202)"></div><!--/$--></body></html>"`;

exports[`non-inlinable styles > should not have duplicate media queries 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><head><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/><!--$--><style>@media(min-width:768px){.md_px-64px{padding-left:64px !important;padding-right:64px !important}}.hover_underline:hover{text-decoration-line:underline !important}</style></head><body class="md_px-64px hover_underline" style="background-color:rgb(255,255,255);margin-top:auto;margin-bottom:auto;margin-left:auto;margin-right:auto;font-family:ui-sans-serif, system-ui, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;"><div class="md_px-64px hover_underline"></div><!--/$--></body>"`;

exports[`non-inlinable styles > should persist existing <head/> elements 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html lang="en"><head><!--$--><style></style><link/><style>@media(min-width:640px){.sm_bg-red-500{background-color:rgb(239,68,68) !important}}</style></head><body><div class="sm_bg-red-500" style="background-color:rgb(254,202,202)"></div><!--/$--></body></html>"`;

exports[`non-inlinable styles > should throw an error when used without a <head/> 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 <style> tag inside of a <head> tag,
the Tailwind component tried finding a <head> element but just wasn't able to find it.

Make sure that you have a <head> element at some point inside of the <Tailwind> component at any depth.
This can also be our <Head> component.

If you do already have a <head> element at some depth,
please file a bug https://github.com/resend/react-email/issues/new?assignees=&labels=Type%3A+Bug&projects=&template=1.bug_report.yml.]
`;

exports[`non-inlinable styles > should throw error when used without the head and with media query class names only very deeply nested 1`] = `
[Error: You are trying to use the following Tailwind classes that cannot be inlined: sm:h-10 sm:w-10.
For the media queries to work properly on rendering, they need to be added into a <style> tag inside of a <head> tag,
the Tailwind component tried finding a <head> element but just wasn't able to find it.

Make sure that you have a <head> element at some point inside of the <Tailwind> component at any depth.
This can also be our <Head> component.

If you do already have a <head> element at some depth,
please file a bug https://github.com/resend/react-email/issues/new?assignees=&labels=Type%3A+Bug&projects=&template=1.bug_report.yml.]
`;

exports[`non-inlinable styles > should work with arbitrarily deep (in the React tree) <head> elements 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html lang="en"><head><!--$--><style>@media(min-width:640px){.sm_bg-red-300{background-color:rgb(252,165,165) !important}}@media(min-width:768px){.md_bg-red-400{background-color:rgb(248,113,113) !important}}@media(min-width:1024px){.lg_bg-red-500{background-color:rgb(239,68,68) !important}}</style></head><body><div class="sm_bg-red-300 md_bg-red-400 lg_bg-red-500" style="background-color:rgb(254,202,202)"></div><!--/$--></body></html>"`;

exports[`non-inlinable styles > should work with arbitrarily deep (in the React tree) <head> elements 2`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html lang="en"><head><!--$--><style>@media(min-width:640px){.sm_bg-red-300{background-color:rgb(252,165,165) !important}}@media(min-width:768px){.md_bg-red-400{background-color:rgb(248,113,113) !important}}@media(min-width:1024px){.lg_bg-red-500{background-color:rgb(239,68,68) !important}}</style></head><body><div class="sm_bg-red-300 md_bg-red-400 lg_bg-red-500" style="background-color:rgb(254,202,202)"></div><!--/$--></body></html>"`;

exports[`non-inlinable styles > should work with relatively complex media query utilities 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><head><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/><!--$--><style>@media not all and(min-width:640px){.max-sm_text-red-600{color:rgb(220,38,38) !important}}</style></head><p class="max-sm_text-red-600" style="color:rgb(29,78,216)">I am some text</p><!--/$-->"`;
10 changes: 5 additions & 5 deletions packages/tailwind/src/tailwind.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ describe('Tailwind component', () => {
});
});

describe('Responsive styles', () => {
describe('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 <Head> component,
Expand Down Expand Up @@ -389,22 +389,22 @@ describe('Responsive styles', () => {
const output = await render(
<Tailwind>
<Head />
<Body className="bg-white my-auto mx-auto font-sans md:px-[64px]">
<div className="md:px-[64px]" />
<Body className="bg-white my-auto mx-auto font-sans md:px-[64px] hover:underline">
<div className="md:px-[64px] hover:underline" />
</Body>
</Tailwind>,
);

expect(output).toMatchSnapshot();
});

it('should add css to <head/> and keep responsive class names', async () => {
it('should add css to <head/> and keep class names', async () => {
const actualOutput = await render(
<html lang="en">
<Tailwind>
<head />
<body>
<div className="bg-red-200 sm:bg-red-300 md:bg-red-400 lg:bg-red-500" />
<div className="bg-red-200 sm:bg-red-300 md:bg-red-400 lg:bg-red-500 hover:bg-red-600 focus:bg-red-700 sm:hover:bg-red-200" />
</body>
</Tailwind>
</html>,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { AtRule, Root } from 'postcss';
import selectorParser from 'postcss-selector-parser';
import { sanitizeClassName } from '../../compatibility/sanitize-class-name';
import { processSelector } from '../process-selector';

/**
* This function goes through a few steps to ensure the best email client support and
Expand All @@ -21,23 +20,12 @@ export const sanitizeMediaQueries = (root: Root) => {
const sanitizedAtRule = atRule.clone();

sanitizedAtRule.walkRules((rule) => {
let hasPseudoSelector = false as boolean;
rule.selector = selectorParser((selectorRoot) => {
selectorRoot.walkPseudos(() => {
hasPseudoSelector = true;
});
if (!hasPseudoSelector) {
selectorRoot.walkClasses((singleClass) => {
mediaQueryClasses.push(singleClass.value);
singleClass.replaceWith(
selectorParser.className({
...singleClass,
value: sanitizeClassName(singleClass.value),
}),
);
});
}
}).processSync(rule.selector);
const { processedSelector, hasPseudoSelector } = processSelector(
rule,
false,
mediaQueryClasses,
);
rule.selector = processedSelector;

if (!hasPseudoSelector) {
rule.walkDecls((declaration) => {
Expand Down
45 changes: 45 additions & 0 deletions packages/tailwind/src/utils/css/process-selector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Rule } from 'postcss';
import selectorParser from 'postcss-selector-parser';
import { sanitizeClassName } from '../compatibility/sanitize-class-name';

/**
* Helper function to process selectors and sanitize class names.
* @param rule - The CSS rule to process.
* @param withPseudo - Whether to process selectors with pseudo-classes.
* @param outputClasses - Array to store class names after processing.
* @returns An object containing the processed selector and whether it has a pseudo selector.
*/
export const processSelector = (
rule: Rule,
withPseudo: boolean,
outputClasses: string[],
) => {
let hasPseudoSelector = false as boolean;

const processedSelector = selectorParser((selectorRoot) => {
selectorRoot.walkPseudos(() => {
hasPseudoSelector = true;
});

if (
(withPseudo && hasPseudoSelector) ||
(!withPseudo && !hasPseudoSelector)
) {
selectorRoot.walkClasses((singleClass) => {
outputClasses.push(singleClass.value);

singleClass.replaceWith(
selectorParser.className({
...singleClass,
value: sanitizeClassName(singleClass.value),
}),
);
});
}
}).processSync(rule.selector);

return {
processedSelector,
hasPseudoSelector,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import postcss, { Root } from 'postcss';
import { sanitizePseudoClasses } from './sanitize-pseudo-classes';

test('sanitizePseudoClasses()', async () => {
const { root } = await postcss()
.process(
`
.hover\\:text-sky-600:hover{
--tw-text-opacity: 1;
color: rgb(2 132 199 / var(--tw-text-opacity))
}

.focus\\:outline-none:focus{
outline: 2px solid transparent;
outline-offset: 2px
}

.hover\\:bg-gray-100:hover{
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity))
}

@media (min-width: 640px){
.sm\\:mx-auto{
margin-left: auto;
margin-right: auto
}

.hover\\:text-gray-100:hover{
--tw-text-opacity: 1;
color: rgb(243 244 246 / var(--tw-text-opacity))
}

.hover\\:underline:hover{
text-decoration-line: underline
}
}
`,
)
.async();

const { pseudoClassClasses, sanitizedPseudoClassRules } =
sanitizePseudoClasses(root as Root);

expect(pseudoClassClasses).toEqual([
'hover:text-gray-100',
'hover:underline',
'hover:text-sky-600',
'focus:outline-none',
'hover:bg-gray-100',
]);

expect(new Root({ nodes: sanitizedPseudoClassRules }).toString()).toBe(`

@media (min-width: 640px){

.hover_text-gray-100:hover{
--tw-text-opacity: 1 !important;
color: rgb(243 244 246 / var(--tw-text-opacity)) !important
}

.hover_underline:hover{
text-decoration-line: underline !important
}
}
.hover_text-sky-600:hover{
--tw-text-opacity: 1 !important;
color: rgb(2 132 199 / var(--tw-text-opacity)) !important
}
.focus_outline-none:focus{
outline: 2px solid transparent !important;
outline-offset: 2px !important
}
.hover_bg-gray-100:hover{
--tw-bg-opacity: 1 !important;
background-color: rgb(243 244 246 / var(--tw-bg-opacity)) !important
}`);
});
Loading
Loading