mirror of
https://github.com/resend/react-email.git
synced 2026-09-14 14:18:02 +08:00
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:
committed by
GitHub
parent
1191b35432
commit
f6cfef07e1
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'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');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user