Skip to content
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
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

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);
const document = `${doctype}${decodedHtml.replace(/<!DOCTYPE.*?>/, '')}`;

if (options?.pretty) {
return pretty(document);
Expand Down
22 changes: 22 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,22 @@
/**
* Decodes HTML entities in href attributes
* This is necessary because React's rendering encodes characters like ampersands
* in attribute values, which can break links with query parameters
* (e.g., ?param1=value1&param2=value2)
*
* Note: We only decode safe entities and avoid decoding &lt; and &gt; to prevent
* breaking HTML structure.
*/
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, '&');
};

// Match href attributes 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)}"`;
});
};
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
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