From 366ae71ab625dd2623fbcb68cf04cec83e8e7f62 Mon Sep 17 00:00:00 2001 From: Gabriel Miranda Date: Mon, 19 Jan 2026 10:32:26 -0300 Subject: [PATCH] fix(render): client side rendering fallbacks (#2852) --- .changeset/orange-dingos-count.md | 5 ++ .../render/src/browser/render-web.spec.tsx | 8 +++ packages/render/src/browser/render.tsx | 18 +++-- packages/render/src/edge/render.spec.tsx | 8 +++ packages/render/src/edge/render.tsx | 18 +++-- packages/render/src/node/render-edge.spec.tsx | 8 +++ packages/render/src/node/render-node.spec.tsx | 8 +++ packages/render/src/node/render.tsx | 65 ++++++++++++------- packages/render/src/shared/error-boundary.ts | 14 ++++ 9 files changed, 118 insertions(+), 34 deletions(-) create mode 100644 .changeset/orange-dingos-count.md create mode 100644 packages/render/src/shared/error-boundary.ts diff --git a/.changeset/orange-dingos-count.md b/.changeset/orange-dingos-count.md new file mode 100644 index 000000000..7f7c94319 --- /dev/null +++ b/.changeset/orange-dingos-count.md @@ -0,0 +1,5 @@ +--- +"@react-email/render": patch +--- + +fix render going into client side rendering and breaking emails silently diff --git a/packages/render/src/browser/render-web.spec.tsx b/packages/render/src/browser/render-web.spec.tsx index 9749c430e..f782ba635 100644 --- a/packages/render/src/browser/render-web.spec.tsx +++ b/packages/render/src/browser/render-web.spec.tsx @@ -58,6 +58,14 @@ describe('render on the browser environment', () => { ); }); + it('properly handles component throw error', async () => { + function ThrowingComponent(): React.ReactNode { + throw new Error('This should be trown by render'); + } + + await expect(render()).rejects.toThrow(); + }); + // This is a test to ensure we have no regressions for https://github.com/resend/react-email/issues/1667 it('handles characters with a higher byte count gracefully', async () => { const actualOutput = await render( diff --git a/packages/render/src/browser/render.tsx b/packages/render/src/browser/render.tsx index a2cfd765f..0b99819f8 100644 --- a/packages/render/src/browser/render.tsx +++ b/packages/render/src/browser/render.tsx @@ -1,10 +1,10 @@ import { Suspense } from 'react'; import { pretty, toPlainText } from '../node'; +import { createErrorBoundary } from '../shared/error-boundary'; import type { Options } from '../shared/options'; import { readStream } from '../shared/read-stream.browser'; export const render = async (node: React.ReactNode, options?: Options) => { - const suspendedElement = {node}; const reactDOMServer = await import('react-dom/server').then((m) => { if ('default' in m) { return m.default; @@ -13,13 +13,19 @@ export const render = async (node: React.ReactNode, options?: Options) => { }); const html = await new Promise((resolve, reject) => { + const ErrorBoundary = createErrorBoundary(reject); reactDOMServer - .renderToReadableStream(suspendedElement, { - onError(error: unknown) { - reject(error); + .renderToReadableStream( + + {node} + , + { + onError(error: unknown) { + reject(error); + }, + progressiveChunkSize: Number.POSITIVE_INFINITY, }, - progressiveChunkSize: Number.POSITIVE_INFINITY, - }) + ) .then(readStream) .then(resolve) .catch(reject); diff --git a/packages/render/src/edge/render.spec.tsx b/packages/render/src/edge/render.spec.tsx index cdf881167..7ecc243ff 100644 --- a/packages/render/src/edge/render.spec.tsx +++ b/packages/render/src/edge/render.spec.tsx @@ -58,6 +58,14 @@ describe('render on the edge', () => { ); }); + it('properly handles component throw error', async () => { + function ThrowingComponent(): React.ReactNode { + throw new Error('This should be trown by render'); + } + + await expect(render()).rejects.toThrow(); + }); + // This is a test to ensure we have no regressions for https://github.com/resend/react-email/issues/1667 it('handles characters with a higher byte count gracefully', async () => { const actualOutput = await render( diff --git a/packages/render/src/edge/render.tsx b/packages/render/src/edge/render.tsx index 121cae757..e9ecf6044 100644 --- a/packages/render/src/edge/render.tsx +++ b/packages/render/src/edge/render.tsx @@ -1,5 +1,6 @@ import { Suspense } from 'react'; import { pretty } from '../node'; +import { createErrorBoundary } from '../shared/error-boundary'; import type { Options } from '../shared/options'; import { readStream } from '../shared/read-stream.browser'; import { toPlainText } from '../shared/utils/to-plain-text'; @@ -9,7 +10,6 @@ export const render = async ( element: React.ReactElement, options?: Options, ) => { - const suspendedElement = {element}; const reactDOMServer = await importReactDom().then((m) => { if ('default' in m) { return m.default; @@ -19,13 +19,19 @@ export const render = async ( }); const html = await new Promise((resolve, reject) => { + const ErrorBoundary = createErrorBoundary(reject); reactDOMServer - .renderToReadableStream(suspendedElement, { - onError(error: unknown) { - reject(error); + .renderToReadableStream( + + {element} + , + { + onError(error: unknown) { + reject(error); + }, + progressiveChunkSize: Number.POSITIVE_INFINITY, }, - progressiveChunkSize: Number.POSITIVE_INFINITY, - }) + ) .then(readStream) .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 07c14ea86..652ef43ca 100644 --- a/packages/render/src/node/render-edge.spec.tsx +++ b/packages/render/src/node/render-edge.spec.tsx @@ -42,6 +42,14 @@ describe('render on the edge', () => { vi.resetAllMocks(); }); + it('properly handles component throw error', async () => { + function ThrowingComponent(): React.ReactNode { + throw new Error('This should be trown by render'); + } + + await expect(render()).rejects.toThrow(); + }); + // This is a test to ensure we have no regressions for https://github.com/resend/react-email/issues/1667 it('handles characters with a higher byte count gracefully in React 18', async () => { const actualOutput = await render( diff --git a/packages/render/src/node/render-node.spec.tsx b/packages/render/src/node/render-node.spec.tsx index 60be537f5..fd9ecb455 100644 --- a/packages/render/src/node/render-node.spec.tsx +++ b/packages/render/src/node/render-node.spec.tsx @@ -47,6 +47,14 @@ describe('render on node environments', () => { vi.resetAllMocks(); }); + it('properly handles component throw error', async () => { + function ThrowingComponent(): React.ReactNode { + throw new Error('This should be trown by render'); + } + + await expect(render()).rejects.toThrow(); + }); + // This is a test to ensure we have no regressions for https://github.com/resend/react-email/issues/1667 // The error only happens with React 18, and thus is tested on React 18. it('handles characters with a higher byte count gracefully in React 18', async () => { diff --git a/packages/render/src/node/render.tsx b/packages/render/src/node/render.tsx index 642d14e13..e0e91d3a1 100644 --- a/packages/render/src/node/render.tsx +++ b/packages/render/src/node/render.tsx @@ -1,11 +1,11 @@ import { Suspense } from 'react'; +import { createErrorBoundary } from '../shared/error-boundary'; import type { Options } from '../shared/options'; import { pretty } from '../shared/utils/pretty'; import { toPlainText } from '../shared/utils/to-plain-text'; import { readStream } from './read-stream'; export const render = async (node: React.ReactNode, options?: Options) => { - const suspendedElement = {node}; const reactDOMServer = await import('react-dom/server').then((m) => { if ('default' in m) { return m.default; @@ -14,29 +14,50 @@ export const render = async (node: React.ReactNode, options?: Options) => { }); let html!: string; - if ( - Object.hasOwn(reactDOMServer, 'renderToReadableStream') && - typeof WritableStream !== 'undefined' - ) { - html = await readStream( - await reactDOMServer.renderToReadableStream(suspendedElement, { - progressiveChunkSize: Number.POSITIVE_INFINITY, - }), - ); - } else { - await new Promise((resolve, reject) => { - const stream = reactDOMServer.renderToPipeableStream(suspendedElement, { - async onAllReady() { - html = await readStream(stream); + await new Promise((resolve, reject) => { + if ( + Object.hasOwn(reactDOMServer, 'renderToReadableStream') && + typeof WritableStream !== 'undefined' + ) { + const ErrorBoundary = createErrorBoundary(reject); + reactDOMServer + .renderToReadableStream( + + {node} + , + { + progressiveChunkSize: Number.POSITIVE_INFINITY, + onError(error) { + // Throw immediately when an error occurs to prevent CSR fallback + throw error; + }, + }, + ) + .then((stream) => readStream(stream)) + .then((result) => { + html = result; resolve(); + }) + .catch(reject); + } else { + const ErrorBoundary = createErrorBoundary(reject); + const stream = reactDOMServer.renderToPipeableStream( + + {node} + , + { + async onAllReady() { + html = await readStream(stream); + resolve(); + }, + onError(error) { + reject(error); + }, + progressiveChunkSize: Number.POSITIVE_INFINITY, }, - onError(error) { - reject(error as Error); - }, - progressiveChunkSize: Number.POSITIVE_INFINITY, - }); - }); - } + ); + } + }); if (options?.plainText) { return toPlainText(html, options.htmlToTextOptions); diff --git a/packages/render/src/shared/error-boundary.ts b/packages/render/src/shared/error-boundary.ts new file mode 100644 index 000000000..befbbdfd8 --- /dev/null +++ b/packages/render/src/shared/error-boundary.ts @@ -0,0 +1,14 @@ +import React from 'react'; + +export function createErrorBoundary(reject: (error: unknown) => void) { + return class ErrorBoundary extends React.Component<{ + children: React.ReactNode; + }> { + componentDidCatch(error: unknown) { + reject(error); + } + render() { + return this.props.children; + } + }; +}