Skip to content

Commit c3b7fcf

Browse files
committed
fix(tailwind): User-defined CSS variables being replaced with undefined (#1587)
1 parent a008f45 commit c3b7fcf

File tree

9 files changed

+210
-72
lines changed

9 files changed

+210
-72
lines changed

.changeset/heavy-rings-work.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@react-email/tailwind": patch
3+
---
4+
5+
Fixes CSS variables being replaced with `undefined`

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@
3737
"@types/react-dom": "npm:[email protected]"
3838
},
3939
"patchedDependencies": {
40-
4140
4241
4342
}

packages/tailwind/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,10 @@
5454
"@react-email/html": "workspace:*",
5555
"@react-email/render": "workspace:*",
5656
"@responsive-email/react-email": "0.0.3",
57-
"@types/postcss-css-variables": "0.18.3",
5857
"eslint-config-custom": "workspace:*",
5958
"eslint-plugin-regex": "1.10.0",
6059
"memfs": "4.6.0",
6160
"postcss": "8.4.40",
62-
"postcss-css-variables": "0.19.0",
6361
"process": "^0.11.10",
6462
"react-dom": "19.0.0-rc-187dd6a7-20240806",
6563
"tailwindcss": "3.4.10",
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import postcss from "postcss";
2+
import { cssVariablesResolver } from "./css-variables-resolver";
3+
4+
describe("cssVariablesResolver", () => {
5+
const processor = postcss([cssVariablesResolver()]);
6+
7+
it("should work with simple css variables on a :root", () => {
8+
const css = `:root {
9+
--width: 100px;
10+
}
11+
12+
.box {
13+
width: var(--width);
14+
}`;
15+
16+
expect(processor.process(css).css).toBe(`.box {
17+
width: 100px;
18+
}`);
19+
});
20+
21+
it("should keep variable usages if it cant find their declaration", () => {
22+
const result = processor.process(`.box {
23+
width: var(--width);
24+
}`);
25+
26+
expect(result.css).toBe(`.box {
27+
width: var(--width);
28+
}`);
29+
});
30+
31+
it("should work with variables set in the same rule", () => {
32+
const result = processor.process(`.box {
33+
--width: 200px;
34+
width: var(--width);
35+
}
36+
37+
@media (min-width: 1280px) {
38+
.xl\\:bg-green-500 {
39+
--tw-bg-opacity: 1;
40+
background-color: rgb(34 197 94 / var(--tw-bg-opacity))
41+
}
42+
}
43+
`);
44+
expect(result.css).toBe(`.box {
45+
width: 200px;
46+
}
47+
48+
@media (min-width: 1280px) {
49+
.xl\\:bg-green-500 {
50+
background-color: rgb(34 197 94 / 1)
51+
}
52+
}
53+
`);
54+
});
55+
56+
it("should work with different values between media queries", () => {
57+
const css = `:root {
58+
--width: 100px;
59+
}
60+
61+
@media (max-width: 1000px) {
62+
:root {
63+
--width: 200px;
64+
}
65+
}
66+
67+
.box {
68+
width: var(--width);
69+
}`;
70+
71+
const result = processor.process(css);
72+
expect(result.css).toBe(`@media (max-width: 1000px) {
73+
.box {
74+
width: 200px;
75+
}
76+
}
77+
78+
.box {
79+
width: 100px;
80+
}`);
81+
});
82+
});
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/* eslint-disable @typescript-eslint/no-non-null-assertion */
2+
import {
3+
type Plugin,
4+
type Container,
5+
type Document,
6+
type Node,
7+
type Declaration,
8+
Rule,
9+
rule as createRule,
10+
decl as createDeclaration,
11+
AtRule,
12+
} from "postcss";
13+
14+
export const cssVariablesResolver = () => {
15+
const removeIfEmptyRecursively = (node: Container | Document) => {
16+
if (node.first === undefined) {
17+
const parent = node.parent;
18+
if (parent) {
19+
node.remove();
20+
removeIfEmptyRecursively(parent);
21+
}
22+
}
23+
};
24+
25+
const doNodesMatch = (first: Node | undefined, second: Node | undefined) => {
26+
if (first instanceof Rule && second instanceof Rule) {
27+
return (
28+
first.selector === second.selector ||
29+
second.selector.includes("*") ||
30+
second.selector.includes(":root")
31+
);
32+
}
33+
34+
return first === second;
35+
};
36+
37+
return {
38+
postcssPlugin: "CSS Variables Resolver",
39+
Once(root) {
40+
root.walkRules((rule) => {
41+
const declarationsForAtRules = new Map<AtRule, Set<Declaration>>();
42+
const valueReplacingInformation = new Set<{
43+
declaration: Declaration;
44+
newValue: string;
45+
}>();
46+
47+
rule.walkDecls((decl) => {
48+
if (/var\(--[^\s)]+\)/.test(decl.value)) {
49+
/**
50+
* @example ['var(--width)', 'var(--length)']
51+
*/
52+
const variablesUsed = /var\(--[^\s)]+\)/gm.exec(decl.value)!;
53+
root.walkDecls((otherDecl) => {
54+
if (/--[^\s]+/.test(otherDecl.prop)) {
55+
const variable = `var(${otherDecl.prop})`;
56+
if (
57+
variablesUsed.includes(variable) &&
58+
doNodesMatch(decl.parent, otherDecl.parent)
59+
) {
60+
if (
61+
otherDecl.parent?.parent instanceof AtRule &&
62+
otherDecl.parent !== decl.parent
63+
) {
64+
const atRule = otherDecl.parent.parent;
65+
66+
const clonedDeclaration = createDeclaration();
67+
clonedDeclaration.prop = decl.prop;
68+
clonedDeclaration.value = decl.value.replaceAll(
69+
variable,
70+
otherDecl.value,
71+
);
72+
clonedDeclaration.important = decl.important;
73+
if (declarationsForAtRules.has(atRule)) {
74+
declarationsForAtRules
75+
.get(otherDecl.parent.parent)!
76+
.add(clonedDeclaration);
77+
} else {
78+
declarationsForAtRules.set(
79+
otherDecl.parent.parent,
80+
new Set([clonedDeclaration]),
81+
);
82+
}
83+
return;
84+
}
85+
86+
valueReplacingInformation.add({
87+
declaration: decl,
88+
newValue: decl.value.replaceAll(variable, otherDecl.value),
89+
});
90+
}
91+
}
92+
});
93+
}
94+
});
95+
96+
for (const { declaration, newValue } of valueReplacingInformation) {
97+
declaration.value = newValue;
98+
}
99+
100+
for (const [atRule, declarations] of declarationsForAtRules.entries()) {
101+
const equivalentRule = createRule();
102+
equivalentRule.selector = rule.selector;
103+
equivalentRule.append(...declarations);
104+
105+
atRule.append(equivalentRule);
106+
}
107+
});
108+
109+
// Removes all variable definitions and then removes the rules that are empty
110+
root.walkDecls((decl) => {
111+
if (/--[^\s]+/.test(decl.prop)) {
112+
const parent = decl.parent;
113+
decl.remove();
114+
if (parent) {
115+
removeIfEmptyRecursively(parent);
116+
}
117+
}
118+
});
119+
},
120+
} satisfies Plugin;
121+
};

packages/tailwind/src/utils/tailwindcss/get-css-for-markup.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import tailwindcss from "tailwindcss";
22
import type { CorePluginsConfig } from "tailwindcss/types/config";
3-
import postcssCssVariables from "postcss-css-variables";
43
import postcss from "postcss";
4+
import { cssVariablesResolver } from "../css/css-variables-resolver";
55
import type { TailwindConfig } from "../../tailwind";
66

77
declare global {
@@ -33,7 +33,7 @@ export const getCssForMarkup = async (
3333
...tailwindConfig,
3434
content: [{ raw: markup, extension: "html" }],
3535
}) as postcss.AcceptedPlugin,
36-
postcssCssVariables() as postcss.AcceptedPlugin,
36+
cssVariablesResolver(),
3737
]);
3838
const result = await processor.process(
3939
String.raw`

packages/tailwind/vite.config.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ export default defineConfig({
1919
// in summary, this bundles the following since vite defaults to bundling
2020
// - tailwindcss
2121
// - postcss
22-
// - postcss-css-variables
2322
// - polyfill libraries
2423
// - process
2524
// - memfs

patches/[email protected]

Lines changed: 0 additions & 38 deletions
This file was deleted.

pnpm-lock.yaml

Lines changed: 0 additions & 28 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)