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
2 changes: 1 addition & 1 deletion packages/img/src/img.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe('<Img> component', () => {
<Img alt="Cat" height="300" src="cat.jpg" width="300" />,
);
expect(actualOutput).toMatchInlineSnapshot(
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><link rel="preload" as="image" href="cat.jpg"/><!--$--><img alt="Cat" height="300" src="cat.jpg" style="display:block;outline:none;border:none;text-decoration:none" width="300"/><!--/$-->"`,
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><img alt="Cat" height="300" src="cat.jpg" style="display:block;outline:none;border:none;text-decoration:none" width="300"/><!--/$-->"`,
);
});
});
4 changes: 0 additions & 4 deletions packages/preview-server/src/utils/get-email-component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,6 @@ describe('getEmailComponent()', () => {
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<link rel="preload" as="image" href="/static/vercel-logo.png" />
<link rel="preload" as="image" href="/static/vercel-user.png" />
<link rel="preload" as="image" href="/static/vercel-arrow.png" />
<link rel="preload" as="image" href="/static/vercel-team.png" />
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
<!--$-->
Expand Down
2 changes: 0 additions & 2 deletions packages/react-email/src/commands/testing/export.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ test('email export', { retry: 3 }, async () => {
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<link rel="preload" as="image" href="/static/vercel-logo.png" />
<link rel="preload" as="image" href="/static/vercel-arrow.png" />
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
<!--$-->
Expand Down
4 changes: 2 additions & 2 deletions packages/render/src/browser/render-web.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('render on the browser environment', () => {
const actualOutput = await render(<Template firstName="Jim" />);

expect(actualOutput).toMatchInlineSnapshot(
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><link rel="preload" as="image" href="img/test.png"/><!--$--><h1>Welcome, <!-- -->Jim<!-- -->!</h1><img alt="test" src="img/test.png"/><p>Thanks for trying our product. We&#x27;re thrilled to have you on board!</p><!--/$-->"`,
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><h1>Welcome, <!-- -->Jim<!-- -->!</h1><img alt="test" src="img/test.png"/><p>Thanks for trying our product. We&#x27;re thrilled to have you on board!</p><!--/$-->"`,
);
});

Expand Down Expand Up @@ -108,7 +108,7 @@ describe('render on the browser environment', () => {
const actualOutput = await render(<Template firstName="Jim" />);

expect(actualOutput).toMatchInlineSnapshot(
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><link rel="preload" as="image" href="img/test.png"/><!--$--><h1>Welcome, <!-- -->Jim<!-- -->!</h1><img alt="test" src="img/test.png"/><p>Thanks for trying our product. We&#x27;re thrilled to have you on board!</p><!--/$-->"`,
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><h1>Welcome, <!-- -->Jim<!-- -->!</h1><img alt="test" src="img/test.png"/><p>Thanks for trying our product. We&#x27;re thrilled to have you on board!</p><!--/$-->"`,
);
});

Expand Down
3 changes: 2 additions & 1 deletion packages/render/src/browser/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { pretty, toPlainText } from '../node';
import { createErrorBoundary } from '../shared/error-boundary';
import type { Options } from '../shared/options';
import { readStream } from '../shared/read-stream.browser';
import { stripAutoInjectedImagePreloads } from '../shared/utils/strip-preload-links';

export const render = async (node: React.ReactNode, options?: Options) => {
const reactDOMServer = await import('react-dom/server').then((m) => {
Expand Down Expand Up @@ -38,7 +39,7 @@ 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 document = `${doctype}${stripAutoInjectedImagePreloads(html).replace(/<!DOCTYPE.*?>/, '')}`;

if (options?.pretty) {
return pretty(document);
Expand Down
4 changes: 2 additions & 2 deletions packages/render/src/edge/render.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('render on the edge', () => {
const actualOutput = await render(<Template firstName="Jim" />);

expect(actualOutput).toMatchInlineSnapshot(
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><link rel="preload" as="image" href="img/test.png"/><!--$--><h1>Welcome, <!-- -->Jim<!-- -->!</h1><img alt="test" src="img/test.png"/><p>Thanks for trying our product. We&#x27;re thrilled to have you on board!</p><!--/$-->"`,
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><h1>Welcome, <!-- -->Jim<!-- -->!</h1><img alt="test" src="img/test.png"/><p>Thanks for trying our product. We&#x27;re thrilled to have you on board!</p><!--/$-->"`,
);
});

Expand Down Expand Up @@ -108,7 +108,7 @@ describe('render on the edge', () => {
const actualOutput = await render(<Template firstName="Jim" />);

expect(actualOutput).toMatchInlineSnapshot(
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><link rel="preload" as="image" href="img/test.png"/><!--$--><h1>Welcome, <!-- -->Jim<!-- -->!</h1><img alt="test" src="img/test.png"/><p>Thanks for trying our product. We&#x27;re thrilled to have you on board!</p><!--/$-->"`,
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><h1>Welcome, <!-- -->Jim<!-- -->!</h1><img alt="test" src="img/test.png"/><p>Thanks for trying our product. We&#x27;re thrilled to have you on board!</p><!--/$-->"`,
);
});

Expand Down
3 changes: 2 additions & 1 deletion packages/render/src/edge/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { pretty } from '../node';
import { createErrorBoundary } from '../shared/error-boundary';
import type { Options } from '../shared/options';
import { readStream } from '../shared/read-stream.browser';
import { stripAutoInjectedImagePreloads } from '../shared/utils/strip-preload-links';
import { toPlainText } from '../shared/utils/to-plain-text';
import { importReactDom } from './import-react-dom';

Expand Down Expand Up @@ -44,7 +45,7 @@ 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 document = `${doctype}${stripAutoInjectedImagePreloads(html).replace(/<!DOCTYPE.*?>/, '')}`;

if (options?.pretty) {
return pretty(document);
Expand Down
6 changes: 3 additions & 3 deletions packages/render/src/node/render-edge.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ describe('render on the edge', () => {
const actualOutput = await render(<Template firstName="Jim" />);

expect(actualOutput).toMatchInlineSnapshot(
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><link rel="preload" as="image" href="img/test.png"/><!--$--><h1>Welcome, <!-- -->Jim<!-- -->!</h1><img alt="test" src="img/test.png"/><p>Thanks for trying our product. We&#x27;re thrilled to have you on board!</p><!--/$-->"`,
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><h1>Welcome, <!-- -->Jim<!-- -->!</h1><img alt="test" src="img/test.png"/><p>Thanks for trying our product. We&#x27;re thrilled to have you on board!</p><!--/$-->"`,
);

vi.resetAllMocks();
Expand Down Expand Up @@ -92,7 +92,7 @@ describe('render on the edge', () => {
const actualOutput = await render(<Template firstName="Jim" />);

expect(actualOutput).toMatchInlineSnapshot(
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><link rel="preload" as="image" href="img/test.png"/><!--$--><h1>Welcome, <!-- -->Jim<!-- -->!</h1><img alt="test" src="img/test.png"/><p>Thanks for trying our product. We&#x27;re thrilled to have you on board!</p><!--/$-->"`,
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><h1>Welcome, <!-- -->Jim<!-- -->!</h1><img alt="test" src="img/test.png"/><p>Thanks for trying our product. We&#x27;re thrilled to have you on board!</p><!--/$-->"`,
);
});

Expand Down Expand Up @@ -141,7 +141,7 @@ describe('render on the edge', () => {
const actualOutput = await render(<Template firstName="Jim" />);

expect(actualOutput).toMatchInlineSnapshot(
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><link rel="preload" as="image" href="img/test.png"/><!--$--><h1>Welcome, <!-- -->Jim<!-- -->!</h1><img alt="test" src="img/test.png"/><p>Thanks for trying our product. We&#x27;re thrilled to have you on board!</p><!--/$-->"`,
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><h1>Welcome, <!-- -->Jim<!-- -->!</h1><img alt="test" src="img/test.png"/><p>Thanks for trying our product. We&#x27;re thrilled to have you on board!</p><!--/$-->"`,
);
});
});
Expand Down
4 changes: 2 additions & 2 deletions packages/render/src/node/render-node.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe('render on node environments', () => {
const actualOutput = await render(<Template firstName="Jim" />);

expect(actualOutput).toMatchInlineSnapshot(
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><link rel="preload" as="image" href="img/test.png"/><!--$--><h1>Welcome, <!-- -->Jim<!-- -->!</h1><img alt="test" src="img/test.png"/><p>Thanks for trying our product. We&#x27;re thrilled to have you on board!</p><!--/$-->"`,
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><h1>Welcome, <!-- -->Jim<!-- -->!</h1><img alt="test" src="img/test.png"/><p>Thanks for trying our product. We&#x27;re thrilled to have you on board!</p><!--/$-->"`,
);

vi.resetAllMocks();
Expand Down Expand Up @@ -125,7 +125,7 @@ describe('render on node environments', () => {
const actualOutput = await render(<Template firstName="Jim" />);

expect(actualOutput).toMatchInlineSnapshot(
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><link rel="preload" as="image" href="img/test.png"/><!--$--><h1>Welcome, <!-- -->Jim<!-- -->!</h1><img alt="test" src="img/test.png"/><p>Thanks for trying our product. We&#x27;re thrilled to have you on board!</p><!--/$-->"`,
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><h1>Welcome, <!-- -->Jim<!-- -->!</h1><img alt="test" src="img/test.png"/><p>Thanks for trying our product. We&#x27;re thrilled to have you on board!</p><!--/$-->"`,
);
});

Expand Down
3 changes: 2 additions & 1 deletion packages/render/src/node/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Suspense } from 'react';
import { createErrorBoundary } from '../shared/error-boundary';
import type { Options } from '../shared/options';
import { pretty } from '../shared/utils/pretty';
import { stripAutoInjectedImagePreloads } from '../shared/utils/strip-preload-links';
import { toPlainText } from '../shared/utils/to-plain-text';
import { readStream } from './read-stream';

Expand Down Expand Up @@ -66,7 +67,7 @@ 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 document = `${doctype}${stripAutoInjectedImagePreloads(html).replace(/<!DOCTYPE.*?>/, '')}`;

if (options?.pretty) {
return pretty(document);
Expand Down
80 changes: 80 additions & 0 deletions packages/render/src/shared/utils/strip-preload-links.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { stripAutoInjectedImagePreloads } from './strip-preload-links';

describe('stripAutoInjectedImagePreloads', () => {
it('removes image preload links that match an <img> src', () => {
const html =
'<link rel="preload" as="image" href="img/test.png"/><h1>Hello</h1><img src="img/test.png"/>';
expect(stripAutoInjectedImagePreloads(html)).toBe(
'<h1>Hello</h1><img src="img/test.png"/>',
);
});

it('removes multiple matching image preload links', () => {
const html =
'<link rel="preload" as="image" href="a.png"/><link rel="preload" as="image" href="b.png"/><img src="a.png"/><img src="b.png"/>';
expect(stripAutoInjectedImagePreloads(html)).toBe(
'<img src="a.png"/><img src="b.png"/>',
);
});

it('preserves image preload links that do NOT match any <img> src', () => {
const html =
'<link rel="preload" as="image" href="bg-pattern.png"/><img src="logo.png"/>';
expect(stripAutoInjectedImagePreloads(html)).toBe(html);
});

it('preserves non-image preload links (fonts, scripts, etc.)', () => {
const html =
'<link rel="preload" as="font" href="font.woff2" crossorigin/><link rel="preload" as="script" href="app.js"/><img src="logo.png"/>';
expect(stripAutoInjectedImagePreloads(html)).toBe(html);
});

it('returns unchanged html when no images exist', () => {
const html =
'<link rel="preload" as="image" href="test.png"/><h1>Hello</h1>';
expect(stripAutoInjectedImagePreloads(html)).toBe(html);
});

it('returns unchanged html when no preload links exist', () => {
const html = '<h1>Hello</h1><img src="test.png"/>';
expect(stripAutoInjectedImagePreloads(html)).toBe(html);
});

it('handles self-closing tags with a space before the slash', () => {
const html =
'<link rel="preload" as="image" href="test.png" /><img src="test.png" />';
expect(stripAutoInjectedImagePreloads(html)).toBe(
'<img src="test.png" />',
);
});

it('removes srcset-based image preloads that match an <img> srcset', () => {
const html =
'<link rel="preload" as="image" imagesrcset="img-1x.png 1x, img-2x.png 2x"/><img srcset="img-1x.png 1x, img-2x.png 2x"/>';
expect(stripAutoInjectedImagePreloads(html)).toBe(
'<img srcset="img-1x.png 1x, img-2x.png 2x"/>',
);
});

it('removes srcset-based preloads with imagesizes', () => {
const html =
'<link rel="preload" as="image" imagesrcset="sm.png 480w, lg.png 800w" imagesizes="(max-width: 600px) 480px, 800px"/><img srcset="sm.png 480w, lg.png 800w" sizes="(max-width: 600px) 480px, 800px"/>';
expect(stripAutoInjectedImagePreloads(html)).toBe(
'<img srcset="sm.png 480w, lg.png 800w" sizes="(max-width: 600px) 480px, 800px"/>',
);
});

it('preserves srcset-based preloads that do NOT match any <img> srcset', () => {
const html =
'<link rel="preload" as="image" imagesrcset="other.png 1x"/><img srcset="logo.png 1x"/>';
expect(stripAutoInjectedImagePreloads(html)).toBe(html);
});

it('handles mixed src and srcset images', () => {
const html =
'<link rel="preload" as="image" href="a.png"/><link rel="preload" as="image" imagesrcset="b-1x.png 1x, b-2x.png 2x"/><img src="a.png"/><img srcset="b-1x.png 1x, b-2x.png 2x"/>';
expect(stripAutoInjectedImagePreloads(html)).toBe(
'<img src="a.png"/><img srcset="b-1x.png 1x, b-2x.png 2x"/>',
);
});
});
70 changes: 70 additions & 0 deletions packages/render/src/shared/utils/strip-preload-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Strips auto-injected image preload links from rendered HTML.
*
* React 19's server renderer automatically injects preload links for every
* `<img>` it encounters during SSR:
*
* - `<img src="X">` → `<link rel="preload" as="image" href="X" />`
* - `<img srcset="X 1x">` → `<link rel="preload" as="image" imagesrcset="X 1x" />`
*
* These are useful for web pages but unnecessary in email HTML, where they add
* noise and are ignored by email clients.
*
* To avoid removing preload links that the template author added intentionally
* (e.g., font or script preloads, or image preloads for resources not rendered
* via `<img>`), we only strip image preloads whose href/imagesrcset matches an
* `<img src>`/`<img srcset>` found in the document.
*
* Known limitation: if a template author explicitly adds
* `<link rel="preload" as="image" href="X">` inside `<Head>` for the exact
* same `X` that also appears as an `<img src="X">`, it will be stripped.
* This is acceptable because image preloads are not supported by email clients
* and have no effect in rendered emails.
*/
export const stripAutoInjectedImagePreloads = (html: string): string => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I really don't like this much regex, we should find some react-dom native way of not having the links anymore.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Hey @gabrielmfern , thanks for the feedback! I totally understand the concern about the regex approach.

I did some research into React's internals to find a native solution.
Unfortunately, there's i havent found a global option in renderToReadableStream or renderToPipeableStream to disable auto-injected preloads.

However, looking at the source code in ReactFizzConfigDOM.js (pushImg function), the preload is suppressed when any of these conditions are met on the :

  • loading="lazy"
  • fetchPriority="low"
  • wrapped in

One idea: we could add fetchPriority="low" to the component email clients ignore this attribute, so it wouldn't affect rendering. The trade-off is that it adds a fetchPriority="low" attribute to every in the output HTML.

What do you think?

// Collect every <img src="..."> and <img srcset="..."> in the document.
const imgSrcs = new Set<string>();
const imgSrcSets = new Set<string>();

const imgPattern = /<img\b[^>]*?\/?>/gi;
let imgMatch: RegExpExecArray | null;
while ((imgMatch = imgPattern.exec(html)) !== null) {
const tag = imgMatch[0];

const srcMatch = /\bsrc=["']([^"']+)["']/i.exec(tag);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
if (srcMatch) {
imgSrcs.add(srcMatch[1]!);
}

const srcSetMatch = /\bsrcset=["']([^"']+)["']/i.exec(tag);
if (srcSetMatch) {
imgSrcSets.add(srcSetMatch[1]!);
}
}

if (imgSrcs.size === 0 && imgSrcSets.size === 0) return html;

// Remove only <link rel="preload" as="image"> whose href or imagesrcset
// matches a rendered <img> — these are the ones React 19 auto-injects.
return html.replace(
/<link[^>]*?\s+rel="preload"[^>]*?\/?>/gi,
(tag) => {
const isImage = /\bas=["']image["']/i.test(tag);
if (!isImage) return tag;

// React 19 emits href-based preloads for <img src>.
const hrefMatch = /\bhref=["']([^"']+)["']/i.exec(tag);
if (hrefMatch && imgSrcs.has(hrefMatch[1]!)) {
return '';
}

// React 19 emits imagesrcset-based preloads for <img srcset>.
const imgSrcSetMatch = /\bimagesrcset=["']([^"']+)["']/i.exec(tag);
if (imgSrcSetMatch && imgSrcSets.has(imgSrcSetMatch[1]!)) {
return '';
}

return tag;
},
);
};
Loading