Skip to content

Commit b717543

Browse files
committed
fix(render): decode HTML entities in href and style tags
React's SSR escapes special characters to HTML entities which breaks: - URLs with query parameters (&amp;) causing issues with click tracking - CSS media queries in style tags (&gt;) breaking Tailwind responsive utilities This adds post-processing to decode entities in: - href attribute values - <style> tag contents Fixes #1767 Fixes #2841
1 parent 2fbd49b commit b717543

File tree

6 files changed

+238
-3
lines changed

6 files changed

+238
-3
lines changed

.changeset/fix-html-entities.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@react-email/render": patch
3+
---
4+
5+
fix: decode HTML entities in href attributes and style tags
6+
7+
React's SSR escapes special characters to HTML entities which breaks:
8+
- URLs with query parameters (`&``&amp;`) causing issues with email click tracking services
9+
- CSS media queries in style tags (`>``&gt;`) breaking Tailwind responsive utilities
10+
11+
This fix adds post-processing to decode entities in:
12+
- `href` attribute values (`&amp;``&`)
13+
- `<style>` tag contents (`&gt;`, `&lt;`, `&amp;`)
14+
15+
Fixes #1767
16+
Fixes #2841

packages/render/src/browser/render.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Suspense } from 'react';
22
import { pretty, toPlainText } from '../node';
3+
import { decodeHtmlEntities } from '../shared/utils/decode-html-entities';
34
import { createErrorBoundary } from '../shared/error-boundary';
45
import type { Options } from '../shared/options';
56
import { readStream } from '../shared/read-stream.browser';
@@ -38,7 +39,9 @@ export const render = async (node: React.ReactNode, options?: Options) => {
3839
const doctype =
3940
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
4041

41-
const document = `${doctype}${html.replace(/<!DOCTYPE.*?>/, '')}`;
42+
const document = decodeHtmlEntities(
43+
`${doctype}${html.replace(/<!DOCTYPE.*?>/, '')}`,
44+
);
4245

4346
if (options?.pretty) {
4447
return pretty(document);

packages/render/src/edge/render.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Suspense } from 'react';
22
import { pretty } from '../node';
3+
import { decodeHtmlEntities } from '../shared/utils/decode-html-entities';
34
import { createErrorBoundary } from '../shared/error-boundary';
45
import type { Options } from '../shared/options';
56
import { readStream } from '../shared/read-stream.browser';
@@ -44,7 +45,9 @@ export const render = async (
4445
const doctype =
4546
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
4647

47-
const document = `${doctype}${html.replace(/<!DOCTYPE.*?>/, '')}`;
48+
const document = decodeHtmlEntities(
49+
`${doctype}${html.replace(/<!DOCTYPE.*?>/, '')}`,
50+
);
4851

4952
if (options?.pretty) {
5053
return pretty(document);

packages/render/src/node/render.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Suspense } from 'react';
2+
import { decodeHtmlEntities } from '../shared/utils/decode-html-entities';
23
import { createErrorBoundary } from '../shared/error-boundary';
34
import type { Options } from '../shared/options';
45
import { pretty } from '../shared/utils/pretty';
@@ -66,7 +67,9 @@ export const render = async (node: React.ReactNode, options?: Options) => {
6667
const doctype =
6768
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
6869

69-
const document = `${doctype}${html.replace(/<!DOCTYPE.*?>/, '')}`;
70+
const document = decodeHtmlEntities(
71+
`${doctype}${html.replace(/<!DOCTYPE.*?>/, '')}`,
72+
);
7073

7174
if (options?.pretty) {
7275
return pretty(document);
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { decodeHtmlEntities } from './decode-html-entities';
3+
4+
describe('decodeHtmlEntities', () => {
5+
describe('href attributes', () => {
6+
it('should decode &amp; in href attributes', () => {
7+
const input = '<a href="https://example.com?a=1&amp;b=2">Link</a>';
8+
const expected = '<a href="https://example.com?a=1&b=2">Link</a>';
9+
expect(decodeHtmlEntities(input)).toBe(expected);
10+
});
11+
12+
it('should decode multiple &amp; in href', () => {
13+
const input =
14+
'<a href="https://example.com?a=1&amp;b=2&amp;c=3">Link</a>';
15+
const expected = '<a href="https://example.com?a=1&b=2&c=3">Link</a>';
16+
expect(decodeHtmlEntities(input)).toBe(expected);
17+
});
18+
19+
it('should handle single quoted href', () => {
20+
const input = "<a href='https://example.com?a=1&amp;b=2'>Link</a>";
21+
const expected = "<a href='https://example.com?a=1&b=2'>Link</a>";
22+
expect(decodeHtmlEntities(input)).toBe(expected);
23+
});
24+
25+
it('should handle multiple href attributes in document', () => {
26+
const input = `
27+
<a href="https://example.com?a=1&amp;b=2">Link 1</a>
28+
<a href="https://other.com?x=1&amp;y=2">Link 2</a>
29+
`;
30+
const expected = `
31+
<a href="https://example.com?a=1&b=2">Link 1</a>
32+
<a href="https://other.com?x=1&y=2">Link 2</a>
33+
`;
34+
expect(decodeHtmlEntities(input)).toBe(expected);
35+
});
36+
37+
it('should not affect href without entities', () => {
38+
const input = '<a href="https://example.com/page">Link</a>';
39+
expect(decodeHtmlEntities(input)).toBe(input);
40+
});
41+
42+
it('should not decode entities in text content', () => {
43+
const input = '<p>Tom &amp; Jerry</p>';
44+
expect(decodeHtmlEntities(input)).toBe(input);
45+
});
46+
});
47+
48+
describe('style tags', () => {
49+
it('should decode &gt; in style tags', () => {
50+
const input = '<style>.foo{@media (width&gt;=48rem){display:block}}</style>';
51+
const expected = '<style>.foo{@media (width>=48rem){display:block}}</style>';
52+
expect(decodeHtmlEntities(input)).toBe(expected);
53+
});
54+
55+
it('should decode &lt; in style tags', () => {
56+
const input = '<style>.foo{@media (width&lt;=48rem){display:block}}</style>';
57+
const expected = '<style>.foo{@media (width<=48rem){display:block}}</style>';
58+
expect(decodeHtmlEntities(input)).toBe(expected);
59+
});
60+
61+
it('should decode &amp; in style tags', () => {
62+
const input = '<style>.a &amp; .b { color: red }</style>';
63+
const expected = '<style>.a & .b { color: red }</style>';
64+
expect(decodeHtmlEntities(input)).toBe(expected);
65+
});
66+
67+
it('should decode multiple entities in style tags', () => {
68+
const input =
69+
'<style>.sm_block{@media (width&gt;=40rem){display:block}}.md_block{@media (width&gt;=48rem){display:block}}</style>';
70+
const expected =
71+
'<style>.sm_block{@media (width>=40rem){display:block}}.md_block{@media (width>=48rem){display:block}}</style>';
72+
expect(decodeHtmlEntities(input)).toBe(expected);
73+
});
74+
75+
it('should handle style tag with attributes', () => {
76+
const input =
77+
'<style type="text/css">.foo{@media (width&gt;=48rem){display:block}}</style>';
78+
const expected =
79+
'<style type="text/css">.foo{@media (width>=48rem){display:block}}</style>';
80+
expect(decodeHtmlEntities(input)).toBe(expected);
81+
});
82+
83+
it('should handle multiple style tags', () => {
84+
const input = `
85+
<style>.a{@media (width&gt;=40rem){color:red}}</style>
86+
<style>.b{@media (width&gt;=48rem){color:blue}}</style>
87+
`;
88+
const expected = `
89+
<style>.a{@media (width>=40rem){color:red}}</style>
90+
<style>.b{@media (width>=48rem){color:blue}}</style>
91+
`;
92+
expect(decodeHtmlEntities(input)).toBe(expected);
93+
});
94+
95+
it('should not affect style tags without entities', () => {
96+
const input = '<style>.foo { color: red; }</style>';
97+
expect(decodeHtmlEntities(input)).toBe(input);
98+
});
99+
});
100+
101+
describe('combined', () => {
102+
it('should decode both href and style in same document', () => {
103+
const input = `
104+
<html>
105+
<head>
106+
<style>.foo{@media (width&gt;=48rem){display:block}}</style>
107+
</head>
108+
<body>
109+
<a href="https://example.com?a=1&amp;b=2">Link</a>
110+
</body>
111+
</html>
112+
`;
113+
const expected = `
114+
<html>
115+
<head>
116+
<style>.foo{@media (width>=48rem){display:block}}</style>
117+
</head>
118+
<body>
119+
<a href="https://example.com?a=1&b=2">Link</a>
120+
</body>
121+
</html>
122+
`;
123+
expect(decodeHtmlEntities(input)).toBe(expected);
124+
});
125+
});
126+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Decodes HTML entities in specific contexts where React's SSR escaping
3+
* causes issues with email clients or CSS processing.
4+
*
5+
* This fixes:
6+
* - `&amp;` in href attributes (breaks click tracking services)
7+
* - `&gt;`, `&lt;`, `&amp;` in style tags (breaks CSS media queries)
8+
*
9+
* @see https://github.com/resend/react-email/issues/1767
10+
* @see https://github.com/resend/react-email/issues/2841
11+
*/
12+
13+
/**
14+
* Decodes common HTML entities back to their original characters.
15+
*/
16+
const decodeEntities = (str: string): string => {
17+
return str
18+
.replace(/&amp;/g, '&')
19+
.replace(/&lt;/g, '<')
20+
.replace(/&gt;/g, '>')
21+
.replace(/&quot;/g, '"')
22+
.replace(/&#x27;/g, "'")
23+
.replace(/&#39;/g, "'");
24+
};
25+
26+
/**
27+
* Decodes HTML entities inside href attribute values.
28+
* This fixes URLs with query parameters that get escaped by React SSR.
29+
*
30+
* Example:
31+
* href="https://example.com?a=1&amp;b=2"
32+
* becomes
33+
* href="https://example.com?a=1&b=2"
34+
*/
35+
const decodeHrefAttributes = (html: string): string => {
36+
// Match href="..." or href='...'
37+
return html.replace(
38+
/href=["']([^"']*)["']/gi,
39+
(_match, hrefValue: string) => {
40+
const decoded = hrefValue.replace(/&amp;/g, '&');
41+
// Preserve the original quote style by checking the match
42+
const quote = _match.charAt(5);
43+
return `href=${quote}${decoded}${quote}`;
44+
},
45+
);
46+
};
47+
48+
/**
49+
* Decodes HTML entities inside <style> tags.
50+
* This fixes CSS media queries that use comparison operators.
51+
*
52+
* Example:
53+
* @media (width&gt;=48rem)
54+
* becomes
55+
* @media (width>=48rem)
56+
*/
57+
const decodeStyleTags = (html: string): string => {
58+
return html.replace(
59+
/<style([^>]*)>([\s\S]*?)<\/style>/gi,
60+
(_match, attributes: string, cssContent: string) => {
61+
const decoded = decodeEntities(cssContent);
62+
return `<style${attributes}>${decoded}</style>`;
63+
},
64+
);
65+
};
66+
67+
/**
68+
* Post-processes HTML output from React SSR to decode HTML entities
69+
* in contexts where they cause issues with email clients.
70+
*
71+
* @param html - The HTML string from React SSR
72+
* @returns The HTML with entities decoded in href attributes and style tags
73+
*/
74+
export const decodeHtmlEntities = (html: string): string => {
75+
let result = html;
76+
77+
// Decode entities in href attributes
78+
result = decodeHrefAttributes(result);
79+
80+
// Decode entities in style tags
81+
result = decodeStyleTags(result);
82+
83+
return result;
84+
};

0 commit comments

Comments
 (0)