From f6cfef07e1f40680fec66103265a10e0e49a42d8 Mon Sep 17 00:00:00 2001 From: Andreas Pfurtscheller <1051396+aplr@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:17:17 +0100 Subject: [PATCH] fix(render): await stream.allReady before reading renderToReadableStream output (#3091) Co-authored-by: Claude Sonnet 4.6 --- .changeset/slow-carrots-ask.md | 5 ++ .../render/src/browser/render-web.spec.tsx | 30 +++++++++-- packages/render/src/browser/render.tsx | 5 +- packages/render/src/edge/render.spec.tsx | 22 ++++++++ packages/render/src/edge/render.tsx | 5 +- packages/render/src/node/render-edge.spec.tsx | 52 +++++++++++++++++++ packages/render/src/node/render.tsx | 5 +- 7 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 .changeset/slow-carrots-ask.md diff --git a/.changeset/slow-carrots-ask.md b/.changeset/slow-carrots-ask.md new file mode 100644 index 000000000..800b5b457 --- /dev/null +++ b/.changeset/slow-carrots-ask.md @@ -0,0 +1,5 @@ +--- +"@react-email/render": patch +--- + +fix: await stream.allReady before reading renderToReadableStream output \ No newline at end of file diff --git a/packages/render/src/browser/render-web.spec.tsx b/packages/render/src/browser/render-web.spec.tsx index 88e38d2ff..e306bfd3b 100644 --- a/packages/render/src/browser/render-web.spec.tsx +++ b/packages/render/src/browser/render-web.spec.tsx @@ -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(); - expect(renderedTemplate).toMatchInlineSnapshot( - `""`, + 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((resolve) => + setTimeout( + () => resolve('

content rendered after suspension

'), + 500, + ), + ); + const EmailTemplate = () => { + const html = use(htmlPromise); + return
; + }; + + const renderedTemplate = await render( + + + , + ); + + 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 diff --git a/packages/render/src/browser/render.tsx b/packages/render/src/browser/render.tsx index 0b99819f8..fcbdae325 100644 --- a/packages/render/src/browser/render.tsx +++ b/packages/render/src/browser/render.tsx @@ -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); }); diff --git a/packages/render/src/edge/render.spec.tsx b/packages/render/src/edge/render.spec.tsx index 7ecc243ff..6b013391d 100644 --- a/packages/render/src/edge/render.spec.tsx +++ b/packages/render/src/edge/render.spec.tsx @@ -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((resolve) => + setTimeout(() => resolve('

content rendered after suspension

'), 50), + ); + const EmailTemplate = () => { + const html = use(htmlPromise); + return
; + }; + + const renderedTemplate = await render( + + + , + ); + + expect(renderedTemplate).not.toContain('$RC'); + expect(renderedTemplate).not.toContain(''); + expect(renderedTemplate).toContain('content rendered after suspension'); + }); }); diff --git a/packages/render/src/edge/render.tsx b/packages/render/src/edge/render.tsx index e9ecf6044..d14492093 100644 --- a/packages/render/src/edge/render.tsx +++ b/packages/render/src/edge/render.tsx @@ -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); }); diff --git a/packages/render/src/node/render-edge.spec.tsx b/packages/render/src/node/render-edge.spec.tsx index 652ef43ca..3271c713a 100644 --- a/packages/render/src/node/render-edge.spec.tsx +++ b/packages/render/src/node/render-edge.spec.tsx @@ -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', () => { `"

Welcome, Jim!

test

Thanks for trying our product. We're thrilled to have you on board!

"`, ); }); + + // 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( + 'react-dom/server.browser', + ); + return { + ...ReactDOMServerBrowser, + default: ReactDOMServerBrowser, + }; + }); + + vi.resetModules(); + const { render } = await import('./render'); + + let resolveHtml!: (value: string) => void; + const htmlPromise = new Promise((resolve) => { + resolveHtml = resolve; + }); + const EmailTemplate = () => { + const html = use(htmlPromise); + return
; + }; + + const renderPromise = render( + + + , + ); + + // 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('

content rendered after suspension

'); + + const renderedTemplate = await renderPromise; + + expect(renderedTemplate).not.toContain('$RC'); + expect(renderedTemplate).not.toContain(''); + expect(renderedTemplate).toContain('content rendered after suspension'); + }); }); /** diff --git a/packages/render/src/node/render.tsx b/packages/render/src/node/render.tsx index 9a24d7aeb..d77b34df4 100644 --- a/packages/render/src/node/render.tsx +++ b/packages/render/src/node/render.tsx @@ -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();