fix(render): client side rendering fallbacks (#2852)

This commit is contained in:
Gabriel Miranda
2026-01-19 10:32:26 -03:00
committed by GitHub
parent e76c37f4b4
commit 366ae71ab6
9 changed files with 118 additions and 34 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@react-email/render": patch
---
fix render going into client side rendering and breaking emails silently
@@ -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(<ThrowingComponent />)).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(
+12 -6
View File
@@ -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 = <Suspense>{node}</Suspense>;
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<string>((resolve, reject) => {
const ErrorBoundary = createErrorBoundary(reject);
reactDOMServer
.renderToReadableStream(suspendedElement, {
onError(error: unknown) {
reject(error);
.renderToReadableStream(
<Suspense>
<ErrorBoundary>{node}</ErrorBoundary>
</Suspense>,
{
onError(error: unknown) {
reject(error);
},
progressiveChunkSize: Number.POSITIVE_INFINITY,
},
progressiveChunkSize: Number.POSITIVE_INFINITY,
})
)
.then(readStream)
.then(resolve)
.catch(reject);
+8
View File
@@ -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(<ThrowingComponent />)).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(
+12 -6
View File
@@ -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 = <Suspense>{element}</Suspense>;
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<string>((resolve, reject) => {
const ErrorBoundary = createErrorBoundary(reject);
reactDOMServer
.renderToReadableStream(suspendedElement, {
onError(error: unknown) {
reject(error);
.renderToReadableStream(
<ErrorBoundary>
<Suspense>{element}</Suspense>
</ErrorBoundary>,
{
onError(error: unknown) {
reject(error);
},
progressiveChunkSize: Number.POSITIVE_INFINITY,
},
progressiveChunkSize: Number.POSITIVE_INFINITY,
})
)
.then(readStream)
.then(resolve)
.catch(reject);
@@ -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(<ThrowingComponent />)).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(
@@ -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(<ThrowingComponent />)).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 () => {
+43 -22
View File
@@ -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 = <Suspense>{node}</Suspense>;
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<void>((resolve, reject) => {
const stream = reactDOMServer.renderToPipeableStream(suspendedElement, {
async onAllReady() {
html = await readStream(stream);
await new Promise<void>((resolve, reject) => {
if (
Object.hasOwn(reactDOMServer, 'renderToReadableStream') &&
typeof WritableStream !== 'undefined'
) {
const ErrorBoundary = createErrorBoundary(reject);
reactDOMServer
.renderToReadableStream(
<ErrorBoundary>
<Suspense>{node}</Suspense>
</ErrorBoundary>,
{
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(
<ErrorBoundary>
<Suspense>{node}</Suspense>
</ErrorBoundary>,
{
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);
@@ -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;
}
};
}