Skip to content

fix(tailwind): max-* breakpoints generating invalid CSS #2308

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

Open
wants to merge 4 commits into
base: main
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
6 changes: 6 additions & 0 deletions .changeset/icy-ghosts-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@react-email/tailwind": minor
"@react-email/components": minor
---

fix `max-*` breakpoints generating invalid CSS
1 change: 1 addition & 0 deletions packages/tailwind/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"@vitejs/plugin-react": "4.4.1",
"postcss": "8.5.3",
"postcss-selector-parser": "7.1.0",
"postcss-value-parser": "4.2.0",
"react-dom": "^19",
"shelljs": "0.9.2",
"tailwindcss": "3.4.10",
Expand Down
22 changes: 12 additions & 10 deletions packages/tailwind/src/__snapshots__/tailwind.spec.tsx.snap

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions packages/tailwind/src/tailwind.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,18 @@ describe('Responsive styles', () => {
).toMatchSnapshot();
});

// https://github.com/resend/react-email/issues/2297
it('should work with max-* breakpoints', async () => {
const actualOutput = await render(
<Tailwind config={{}}>
<head />
<div className="bg-red-100 max-sm:bg-green-500">Test</div>
</Tailwind>,
);

expect(actualOutput).toMatchSnapshot();
});

it('should not have duplicate media queries', async () => {
const Body = (props: { className: string; children: React.ReactNode }) => {
return <body className={props.className}>{props.children}</body>;
Expand Down
4 changes: 1 addition & 3 deletions packages/tailwind/src/tailwind.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,7 @@ export const Tailwind: React.FC<TailwindProps> = ({ children, config }) => {

/* only minify here since it is the only place that is going to be in the DOM */
const styleElement = (
<style>
{minifyCss(nonInlineStylesRootToApply.toString().trim())}
</style>
<style>{minifyCss(nonInlineStylesRootToApply)}</style>
);

return React.cloneElement(
Expand Down
47 changes: 47 additions & 0 deletions packages/tailwind/src/utils/css/minify-css.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { parse } from 'postcss';
import { minifyCss } from './minify-css';

describe('minifyCss', () => {
it('should remove comments', () => {
const input = parse('body { color: red; /* This is a comment */ }');
const expected = 'body{color:red}';
expect(minifyCss(input)).toBe(expected);
});

it('should remove extra spaces after semicolons and colons', () => {
const input = parse('body { color: red; font-size: 16px; }');
const expected = 'body{color:red;font-size:16px}';
expect(minifyCss(input)).toBe(expected);
});

it('should remove extra spaces before and after brackets', () => {
const input = parse('body { color: red; } .class { margin: 10px; }');
const expected = 'body{color:red}.class{margin:10px}';
expect(minifyCss(input)).toBe(expected);
});

it('should handle multiple rules in a single string', () => {
const input = parse('body { color: red; } .class { margin: 10px; }');
const expected = 'body{color:red}.class{margin:10px}';
expect(minifyCss(input)).toBe(expected);
});

// https://github.com/resend/react-email/issues/2297
it('should handle at rules with multiple parameters', () => {
const input = parse(`@media not all and (min-width:600px) {
.max-desktop_px-40 {
padding-left: 40px !important;
padding-right: 40px !important
}
}`);
const expected =
'@media not all and (min-width:600px){.max-desktop_px-40{padding-left:40px!important;padding-right:40px!important}}';
expect(minifyCss(input)).toBe(expected);
});

it('should handle empty strings', () => {
const input = parse('');
const expected = '';
expect(minifyCss(input)).toBe(expected);
});
});
72 changes: 54 additions & 18 deletions packages/tailwind/src/utils/css/minify-css.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,57 @@
export const minifyCss = (css: string): string => {
// Thanks tw-to-css!
// from https://github.com/vinicoder/tw-to-css/blob/main/src/util/format-css.ts
return (
css
// Remove comments
.replace(/\/\*[\s\S]*?\*\//gm, '')
import type { Root } from 'postcss';
import selectorParser from 'postcss-selector-parser';
import valueParser from 'postcss-value-parser';

// Remove extra spaces after semicolons and colons
.replace(/;\s+/gm, ';')
.replace(/:\s+/gm, ':')
function minifyValue(value: string) {
const parsed = valueParser(value.trim());
parsed.walk((node) => {
if ('before' in node) node.before = '';
if ('after' in node) node.after = '';
if (node.type === 'space') node.value = ' ';
});
return parsed.toString();
}

// Remove extra spaces before and after brackets
.replace(/\)\s*{/gm, '){') // Remove spaces before opening curly brace after closing parenthesis
.replace(/\s+\(/gm, '(') // Remove spaces before opening parenthesis
.replace(/{\s+/gm, '{') // Remove spaces after opening curly brace
.replace(/}\s+/gm, '}') // Remove spaces before closing curly brace
.replace(/\s*{/gm, '{') // Remove spaces after opening curly brace
.replace(/;?\s*}/gm, '}')
); // Remove extra spaces and semicolons before closing curly braces
export const minifyCss = (root: Root): string => {
const toMinify = root.clone();
toMinify.walk((node) => {
if (node.type === 'comment') {
if (node.text[0] === '!') {
node.raws.before = '';
node.raws.after = '';
} else {
node.remove();
}
} else if (node.type === 'atrule') {
node.raws = {
before: '',
after: '',
afterName: ' ',
};
node.params = minifyValue(node.params);
} else if (node.type === 'decl') {
node.raws = {
before: '',
between: ':',
important: node.important
? (node.raws.important?.replaceAll(' ', '') ?? '!important')
: undefined,
};
node.value = minifyValue(node.value);
} else if (node.type === 'rule') {
node.raws = { before: '', between: '', after: '', semicolon: false };
node.selector = selectorParser((selectorRoot) => {
selectorRoot.walk((selector) => {
selector.spaces = { before: '', after: '' };

if ('raws' in selector && selector.raws?.spaces) {
selector.raws.spaces = {};
}
});
})
.processSync(node.selector)
.toString();
}
});
return toMinify.toString();
};
1 change: 1 addition & 0 deletions packages/tailwind/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default defineConfig({
// - tailwindcss
// - postcss
// - postcss-selector-parser
// - postcss-value-parser
external: ['react', /^react\/.*/, 'react-dom', /react-dom\/.*/],
},
lib: {
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading