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;
+ }
+ };
+}