fix(render): await stream.allReady before reading renderToReadableStream output (#3091)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Andreas Pfurtscheller
2026-03-25 21:17:17 +01:00
committed by GitHub
parent 1191b35432
commit f6cfef07e1
7 changed files with 118 additions and 6 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@react-email/render": patch
---
fix: await stream.allReady before reading renderToReadableStream output
@@ -2,7 +2,7 @@
* @vitest-environment jsdom
*/
import { createElement, use } from 'react';
import { createElement, Suspense, use } from 'react';
import { Preview } from '../shared/utils/testing/preview';
import { Template } from '../shared/utils/testing/template';
import { render } from './render';
@@ -152,11 +152,35 @@ describe('render on the browser environment', () => {
const renderedTemplate = await render(<EmailTemplate />);
expect(renderedTemplate).toMatchInlineSnapshot(
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$?--><template id="B:0"></template><!--/$--><div hidden id="S:0"><div><p>example content with some multibyte characters: 情報Ⅰ</p></div></div><script>$RC=function(b,c,e){c=document.getElementById(c);c.parentNode.removeChild(c);var a=document.getElementById(b);if(a){b=a.previousSibling;if(e)b.data="$!",a.setAttribute("data-dgst",e);else{e=b.parentNode;a=b.nextSibling;var f=0;do{if(a&&8===a.nodeType){var d=a.data;if("/$"===d)if(0===f)break;else f--;else"$"!==d&&"$?"!==d&&"$!"!==d||f++}d=a.nextSibling;e.removeChild(a);a=d}while(a);for(;c.firstChild;)e.insertBefore(c.firstChild,a);b.data="$"}b._reactRetry&&b._reactRetry()}};$RC("B:0","S:0")</script>"`,
expect(renderedTemplate).toContain(
'example content with some multibyte characters: 情報Ⅰ',
);
});
// https://github.com/resend/react-email/issues/3090
it('waits for Suspense boundaries to resolve before resolving', async () => {
const htmlPromise = new Promise<string>((resolve) =>
setTimeout(
() => resolve('<p>content rendered after suspension</p>'),
500,
),
);
const EmailTemplate = () => {
const html = use(htmlPromise);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
};
const renderedTemplate = await render(
<Suspense>
<EmailTemplate />
</Suspense>,
);
expect(renderedTemplate).not.toContain('$RC');
expect(renderedTemplate).not.toContain('<!--$?-->');
expect(renderedTemplate).toContain('content rendered after suspension');
});
// See https://github.com/resend/react-email/issues/2263
it('throws error of rendering an invalid element instead of writing them into a template tag', async () => {
// @ts-expect-error we know this is not correct, and we want to test the error handling for it
+4 -1
View File
@@ -26,7 +26,10 @@ export const render = async (node: React.ReactNode, options?: Options) => {
progressiveChunkSize: Number.POSITIVE_INFINITY,
},
)
.then(readStream)
.then(async (stream) => {
await stream.allReady;
return readStream(stream);
})
.then(resolve)
.catch(reject);
});
+22
View File
@@ -2,6 +2,7 @@
* @vitest-environment jsdom
*/
import { Suspense, use } from 'react';
import { Preview } from '../shared/utils/testing/preview';
import { Template } from '../shared/utils/testing/template';
import { render } from './render';
@@ -133,4 +134,25 @@ describe('render on the edge', () => {
`"THIS SHOULD BE RENDERED IN PLAIN TEXT"`,
);
});
// https://github.com/resend/react-email/issues/3090
it('waits for Suspense boundaries to resolve before resolving', async () => {
const htmlPromise = new Promise<string>((resolve) =>
setTimeout(() => resolve('<p>content rendered after suspension</p>'), 50),
);
const EmailTemplate = () => {
const html = use(htmlPromise);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
};
const renderedTemplate = await render(
<Suspense>
<EmailTemplate />
</Suspense>,
);
expect(renderedTemplate).not.toContain('$RC');
expect(renderedTemplate).not.toContain('<!--$?-->');
expect(renderedTemplate).toContain('content rendered after suspension');
});
});
+4 -1
View File
@@ -32,7 +32,10 @@ export const render = async (
progressiveChunkSize: Number.POSITIVE_INFINITY,
},
)
.then(readStream)
.then(async (stream) => {
await stream.allReady;
return readStream(stream);
})
.then(resolve)
.catch(reject);
});
@@ -1,3 +1,4 @@
import { Suspense, use } from 'react';
import { Preview } from '../shared/utils/testing/preview';
import { Template } from '../shared/utils/testing/template';
import { render } from './render';
@@ -144,6 +145,57 @@ describe('render on the edge', () => {
`"<!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><!--/$-->"`,
);
});
// https://github.com/resend/react-email/issues/3090
// This test exercises the renderToReadableStream path in the node render by mocking
// react-dom/server to forward to react-dom/server.browser (which exposes
// renderToReadableStream, as Bun does via its "bun" export condition).
it('waits for Suspense boundaries to resolve before resolving when using renderToReadableStream', async () => {
type BrowserImport = typeof import('react-dom/server.browser') & {
default: typeof import('react-dom/server.browser');
};
vi.doMock('react-dom/server', async () => {
const ReactDOMServerBrowser = await vi.importActual<BrowserImport>(
'react-dom/server.browser',
);
return {
...ReactDOMServerBrowser,
default: ReactDOMServerBrowser,
};
});
vi.resetModules();
const { render } = await import('./render');
let resolveHtml!: (value: string) => void;
const htmlPromise = new Promise<string>((resolve) => {
resolveHtml = resolve;
});
const EmailTemplate = () => {
const html = use(htmlPromise);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
};
const renderPromise = render(
<Suspense>
<EmailTemplate />
</Suspense>,
);
// Wait for the render to start and suspend on htmlPromise before resolving it.
// render() internally awaits a module import (microtask) before calling
// renderToReadableStream, so a setTimeout(0) here guarantees those microtasks
// have settled and React has already suspended on the pending promise.
await new Promise((r) => setTimeout(r, 0));
resolveHtml('<p>content rendered after suspension</p>');
const renderedTemplate = await renderPromise;
expect(renderedTemplate).not.toContain('$RC');
expect(renderedTemplate).not.toContain('<!--$?-->');
expect(renderedTemplate).toContain('content rendered after suspension');
});
});
/**
+4 -1
View File
@@ -33,7 +33,10 @@ export const render = async (node: React.ReactNode, options?: Options) => {
},
},
)
.then((stream) => readStream(stream))
.then(async (stream) => {
await stream.allReady;
return readStream(stream);
})
.then((result) => {
html = result;
resolve();