Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 7 additions & 0 deletions .changeset/fix-html-entities-in-style-attributes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@react-email/render': patch
---

Fix HTML entity encoding in style attributes
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we stick with just this initial portion here, and can you make it all lowercase?


Fixes #1767 - Decodes HTML entities in style attributes to fix font-family declarations with quoted font names. Only decodes ampersands in href attributes to preserve HTML structure and avoid breaking attribute syntax.
4 changes: 3 additions & 1 deletion packages/render/src/browser/render.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Suspense } from 'react';
import { pretty, toPlainText } from '../node';
import type { Options } from '../shared/options';
import { decodeAttributeEntities } from '../shared/utils/decode-html-entities';
import { readStream } from '../shared/read-stream.browser';

export const render = async (node: React.ReactNode, options?: Options) => {
Expand Down Expand Up @@ -30,7 +31,8 @@ export const render = async (node: React.ReactNode, options?: Options) => {
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';

const document = `${doctype}${html.replace(/<!DOCTYPE.*?>/, '')}`;
const decodedHtml = decodeAttributeEntities(html);
const document = `${doctype}${decodedHtml.replace(/<!DOCTYPE.*?>/, '')}`;

if (options?.pretty) {
return pretty(document);
Expand Down
4 changes: 3 additions & 1 deletion packages/render/src/edge/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Suspense } from 'react';
import { pretty } from '../node';
import type { Options } from '../shared/options';
import { readStream } from '../shared/read-stream.browser';
import { decodeAttributeEntities } from '../shared/utils/decode-html-entities';
import { toPlainText } from '../shared/utils/to-plain-text';
import { importReactDom } from './import-react-dom';

Expand Down Expand Up @@ -35,7 +36,8 @@ export const render = async (
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';

const document = `${doctype}${html.replace(/<!DOCTYPE.*?>/, '')}`;
const decodedHtml = decodeAttributeEntities(html);
const document = `${doctype}${decodedHtml.replace(/<!DOCTYPE.*?>/, '')}`;

if (options?.pretty) {
return pretty(document);
Expand Down
41 changes: 41 additions & 0 deletions packages/render/src/node/render-node.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,4 +165,45 @@ describe('render on node environments', () => {

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

it('decodes ampersands in href attributes', async () => {
const component = (
<a href="https://example.com/page?param1=value1&param2=value2&param3=value3">
Click here
</a>
);
const html = await render(component);

// Should not contain encoded ampersands in href attributes
expect(html).not.toContain('&amp;param');

// Should contain actual ampersands in URLs
expect(html).toContain('param1=value1&param2=value2&param3=value3');
});

it('decodes quotes in style attributes', async () => {
const component = (
<div style={{ fontFamily: '"Helvetica Neue", Arial, sans-serif' }}>
Test
</div>
);
const html = await render(component);

// Should not contain encoded quotes in style attributes
expect(html).not.toContain('&quot;');

// Should contain actual quotes in font-family
expect(html).toContain('"Helvetica Neue"');
});

it('does not decode quotes in href attributes to avoid breaking HTML', async () => {
const component = (
<a href='https://example.com/page?foo="bar"'>Link</a>
);
const html = await render(component);

// Quotes in href should remain encoded to avoid breaking the attribute
// The href value in the rendered HTML should still be properly encoded
expect(html).toContain('href=');
});
});
4 changes: 3 additions & 1 deletion packages/render/src/node/render.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Suspense } from 'react';
import type { Options } from '../shared/options';
import { decodeAttributeEntities } from '../shared/utils/decode-html-entities';
import { pretty } from '../shared/utils/pretty';
import { toPlainText } from '../shared/utils/to-plain-text';
import { readStream } from './read-stream';
Expand Down Expand Up @@ -43,7 +44,8 @@ export const render = async (node: React.ReactNode, options?: Options) => {
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';

const document = `${doctype}${html.replace(/<!DOCTYPE.*?>/, '')}`;
const decodedHtml = decodeAttributeEntities(html);
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Oct 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decoding the rendered HTML here replaces &quot; inside style attributes with raw ", so markup like style="font-family:&quot;Helvetica Neue&quot;,Arial" becomes style="font-family:"Helvetica Neue",Arial"; the double quote now closes the attribute early, yielding invalid HTML and dropping the intended styles.

Prompt for AI agents
Address the following comment on packages/render/src/node/render.tsx at line 47:

<comment>Decoding the rendered HTML here replaces `&amp;quot;` inside `style` attributes with raw `&quot;`, so markup like `style=&quot;font-family:&amp;quot;Helvetica Neue&amp;quot;,Arial&quot;` becomes `style=&quot;font-family:&quot;Helvetica Neue&quot;,Arial&quot;`; the double quote now closes the attribute early, yielding invalid HTML and dropping the intended styles.</comment>

<file context>
@@ -43,7 +44,8 @@ export const render = async (node: React.ReactNode, options?: Options) =&gt; {
     &#39;&lt;!DOCTYPE html PUBLIC &quot;-//W3C//DTD XHTML 1.0 Transitional//EN&quot; &quot;http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd&quot;&gt;&#39;;
 
-  const document = `${doctype}${html.replace(/&lt;!DOCTYPE.*?&gt;/, &#39;&#39;)}`;
+  const decodedHtml = decodeAttributeEntities(html);
+  const document = `${doctype}${decodedHtml.replace(/&lt;!DOCTYPE.*?&gt;/, &#39;&#39;)}`;
 
</file context>
Fix with Cubic

const document = `${doctype}${decodedHtml.replace(/<!DOCTYPE.*?>/, '')}`;

if (options?.pretty) {
return pretty(document);
Expand Down
36 changes: 36 additions & 0 deletions packages/render/src/shared/utils/decode-html-entities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Decodes HTML entities in href and style attributes
* This is necessary because React's rendering encodes characters like ampersands
* and quotes in attribute values, which can break:
* - Links with query parameters (e.g., ?param1=value1&param2=value2)
* - CSS font-family declarations with quotes
*
* Note: We only decode safe entities and avoid decoding &lt; and &gt; to prevent
* breaking HTML structure. Quotes are only decoded in style attributes to avoid
* breaking href attribute syntax.
*/
export const decodeAttributeEntities = (html: string): string => {
const decodeHrefValue = (value: string): string => {
// Only decode ampersands in hrefs to fix URL query parameters
// Do NOT decode quotes to avoid breaking the attribute syntax
return value.replace(/&amp;/g, '&');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you elaborate on why you didn't use html-entities instead of a replace here?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using html-entities would be overkill for this single character replacement and this only touch in &amp; nothing else, that's make sense?

};

const decodeStyleValue = (value: string): string => {
// Decode quotes and ampersands in style attributes
// This is safe because CSS can contain quoted strings (e.g., font-family)
return value
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#x27;/g, "'")
.replace(/&#39;/g, "'");
};

return html
.replace(/href="([^"]*)"/g, (_match, hrefContent) => {
return `href="${decodeHrefValue(hrefContent)}"`;
})
.replace(/style="([^"]*)"/g, (_match, styleContent) => {
return `style="${decodeStyleValue(styleContent)}"`;
});
};
Loading