mirror of
https://github.com/resend/react-email.git
synced 2026-09-14 14:18:02 +08:00
fix(render): client side rendering fallbacks (#2852)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user