Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
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.
8 changes: 1 addition & 7 deletions apps/web/src/components/component-code-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,7 @@ import * as Select from '@radix-ui/react-select';
import * as Tabs from '@radix-ui/react-tabs';
import * as allReactEmailComponents from '@react-email/components';
import * as allReactResponsiveComponents from '@responsive-email/react-email';
import {
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
ClipboardIcon,
} from 'lucide-react';
import * as React from 'react';
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import type {
CodeVariant,
ImportedComponent,
Expand Down
4 changes: 3 additions & 1 deletion packages/render/src/browser/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Suspense } from 'react';
import { pretty, toPlainText } from '../node';
import type { Options } from '../shared/options';
import { readStream } from '../shared/read-stream.browser';
import { decodeAttributeEntities } from '../shared/utils/decode-html-entities';

export const render = async (node: React.ReactNode, options?: Options) => {
const suspendedElement = <Suspense>{node}</Suspense>;
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
40 changes: 40 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,44 @@ 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 single quotes (converted from encoded double quotes)
// to avoid breaking the style="..." attribute syntax
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
39 changes: 39 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,39 @@
/**
* 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 entities in style attributes
// Note: We decode &quot; to single quotes to avoid breaking the style="..." syntax
// since the style attribute is wrapped in double quotes
return value
.replace(/&amp;/g, '&')
.replace(/&quot;/g, "'") // Decode to single quote to avoid breaking attribute
.replace(/&#x27;/g, "'")
.replace(/&#39;/g, "'");
};

// Match href and style attributes more carefully to avoid breaking HTML structure
// Use a regex that matches the attribute name, =, opening quote, content, closing quote
return html
.replace(/\bhref="([^"]*)"/g, (_match, hrefContent) => {
return `href="${decodeHrefValue(hrefContent)}"`;
})
.replace(/\bstyle="([^"]*)"/g, (_match, styleContent) => {
return `style="${decodeStyleValue(styleContent)}"`;
});
};
5 changes: 5 additions & 0 deletions packages/tailwind/src/utils/react/map-react-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export function mapReactTree(
: (processed.type as React.FC);

const rendered = OriginalComponent(processed.props);
// Handle async Server Components
Copy link
Member

Choose a reason for hiding this comment

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

this seems unrelated to the pull request

Copy link
Author

Choose a reason for hiding this comment

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

Right, I tried to fix because of build failing

Copy link
Member

Choose a reason for hiding this comment

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

Let's not do that here.

if (rendered && typeof rendered === 'object' && 'then' in rendered) {
// For now, return the unprocessed component for async components
return processed;
}
const mappedRenderedNode = mapReactTree(rendered, process);

return mappedRenderedNode;
Expand Down
Loading
Loading