Skip to content

Commit b54ad50

Browse files
committed
fix(render): Browser version including errors in the output instead of throwing them (#2267)
1 parent 8a1b641 commit b54ad50

File tree

5 files changed

+155
-44
lines changed

5 files changed

+155
-44
lines changed

.changeset/deep-clowns-bet.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@react-email/render": patch
3+
---
4+
5+
fix browser version including errors in the output instead of throwing them

packages/render/src/browser/__snapshots__/render-async-web.spec.tsx.snap

Lines changed: 0 additions & 3 deletions
This file was deleted.
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,105 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

33
exports[`render on the browser environment > should handle characters with a higher byte count gracefully 1`] = `"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><p>Test Normal 情報Ⅰコース担当者様</p><p>平素よりお世話になっております。 情報Ⅰサポートチームです。 情報Ⅰ本講座につきまして仕様変更のためご連絡させていただきました。<!-- --> </p>今後ジクタス上の講座につきましては、8回分の授業をひとまとまりとしてパート分けされた状態で公開されてまいります。<p>伴いまして、画面上の表示に一部変更がありますのでお知らせいたします。 ご登録いただいた各生徒の受講ペースに応じて公開パートが増えてまいります。 具体的な表示イメージは下記ページをご確認ください。</p><p>2024年8月末時点で情報Ⅰ本講座を受講していたアカウントにつきましては、 今まで公開していた第1~9回までの講座一覧に加え、パート分けされた講座が追加で公開されてまりいます。 第1~9回の表示はそのまま引き継がれますが、Webドリルの進捗表示はパート分けの講座には適用されません。ご了承くださいませ。 仕様変更に伴い、現在教室長もしくは講師に生徒アカウントログインをお願いしておりますが、今後は生徒自身にてログインをしていただいて問題ございません。</p><p>また、生徒が自宅等にてログインし復習に取り組むことも問題ございませんので、教室にてご指示いただければと存じます。 (実際にご指示いただくかは教室判断に委ねさせていただきます。)</p><p>受講ペースが変更したり、増コマが発生したりする場合などは、公開ペースを本部にて調整いたしますので、下記より必ずご連絡くださいませ。 また本件に関して不明な点がございましたら、同フォームよりお問い合わせください。 以上、引き続きよろしくお願い申し上げます。 情報Ⅰサポートチーム</p><!--/$-->"`;
4+
5+
exports[`render on the browser environment > should properly wait for Suepsense boundaries to ending before resolving 1`] = `
6+
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$?--><template id="B:0"></template><!--/$--><div hidden id="S:0"><div><!doctype html>
7+
<html>
8+
<head>
9+
<title>Example Domain</title>
10+
11+
<meta charset="utf-8" />
12+
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
13+
<meta name="viewport" content="width=device-width, initial-scale=1" />
14+
<style type="text/css">
15+
body {
16+
background-color: #f0f0f2;
17+
margin: 0;
18+
padding: 0;
19+
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
20+
21+
}
22+
div {
23+
width: 600px;
24+
margin: 5em auto;
25+
padding: 2em;
26+
background-color: #fdfdff;
27+
border-radius: 0.5em;
28+
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
29+
}
30+
a:link, a:visited {
31+
color: #38488f;
32+
text-decoration: none;
33+
}
34+
@media (max-width: 700px) {
35+
div {
36+
margin: 0 auto;
37+
width: auto;
38+
}
39+
}
40+
</style>
41+
</head>
42+
43+
<body>
44+
<div>
45+
<h1>Example Domain</h1>
46+
<p>This domain is for use in illustrative examples in documents. You may use this
47+
domain in literature without prior coordination or asking for permission.</p>
48+
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
49+
</div>
50+
</body>
51+
</html>
52+
</div></div><script>$RC=function(b,c,e){c=document.getElementById(c);c.parentNode.removeChild(c);var a=document.getElementById(b);if(a){b=a.previousSibling;if(e)b.data="$!",a.setAttribute("data-dgst",e);else{e=b.parentNode;a=b.nextSibling;var f=0;do{if(a&&8===a.nodeType){var d=a.data;if("/$"===d)if(0===f)break;else f--;else"$"!==d&&"$?"!==d&&"$!"!==d||f++}d=a.nextSibling;e.removeChild(a);a=d}while(a);for(;c.firstChild;)e.insertBefore(c.firstChild,a);b.data="$"}b._reactRetry&&b._reactRetry()}};$RC("B:0","S:0")</script>"
53+
`;
54+
55+
exports[`render on the browser environment > should throw error of rendering an invalid element instead of writing them into a template tag 1`] = `[Error: Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: undefined. You likely forgot to export your component from the file it's defined in, or you might have mixed up default and named imports.]`;
56+
57+
exports[`render on the browser environment > that it properly waits for Suepsense boundaries to resolve before resolving 1`] = `
58+
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$?--><template id="B:0"></template><!--/$--><div hidden id="S:0"><div><!doctype html>
59+
<html>
60+
<head>
61+
<title>Example Domain</title>
62+
63+
<meta charset="utf-8" />
64+
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
65+
<meta name="viewport" content="width=device-width, initial-scale=1" />
66+
<style type="text/css">
67+
body {
68+
background-color: #f0f0f2;
69+
margin: 0;
70+
padding: 0;
71+
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
72+
73+
}
74+
div {
75+
width: 600px;
76+
margin: 5em auto;
77+
padding: 2em;
78+
background-color: #fdfdff;
79+
border-radius: 0.5em;
80+
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
81+
}
82+
a:link, a:visited {
83+
color: #38488f;
84+
text-decoration: none;
85+
}
86+
@media (max-width: 700px) {
87+
div {
88+
margin: 0 auto;
89+
width: auto;
90+
}
91+
}
92+
</style>
93+
</head>
94+
95+
<body>
96+
<div>
97+
<h1>Example Domain</h1>
98+
<p>This domain is for use in illustrative examples in documents. You may use this
99+
domain in literature without prior coordination or asking for permission.</p>
100+
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
101+
</div>
102+
</body>
103+
</html>
104+
</div></div><script>$RC=function(b,c,e){c=document.getElementById(c);c.parentNode.removeChild(c);var a=document.getElementById(b);if(a){b=a.previousSibling;if(e)b.data="$!",a.setAttribute("data-dgst",e);else{e=b.parentNode;a=b.nextSibling;var f=0;do{if(a&&8===a.nodeType){var d=a.data;if("/$"===d)if(0===f)break;else f--;else"$"!==d&&"$?"!==d&&"$!"!==d||f++}d=a.nextSibling;e.removeChild(a);a=d}while(a);for(;c.firstChild;)e.insertBefore(c.firstChild,a);b.data="$"}b._reactRetry&&b._reactRetry()}};$RC("B:0","S:0")</script>"
105+
`;

packages/render/src/browser/render-web.spec.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* @vitest-environment jsdom
33
*/
44

5+
import { createElement } from 'react';
6+
import usePromise from 'react-promise-suspense';
57
import { Preview } from '../shared/utils/preview';
68
import { Template } from '../shared/utils/template';
79
import { render } from './render';
@@ -122,4 +124,26 @@ describe('render on the browser environment', () => {
122124
`"THIS SHOULD BE RENDERED IN PLAIN TEXT"`,
123125
);
124126
});
127+
128+
it('should properly wait for Suepsense boundaries to ending before resolving', async () => {
129+
const EmailTemplate = () => {
130+
const html = usePromise(
131+
() => fetch('https://example.com').then((res) => res.text()),
132+
[],
133+
);
134+
135+
return <div dangerouslySetInnerHTML={{ __html: html }} />;
136+
};
137+
138+
const renderedTemplate = await render(<EmailTemplate />);
139+
140+
expect(renderedTemplate).toMatchSnapshot();
141+
});
142+
143+
// See https://github.com/resend/react-email/issues/2263
144+
it('should throw error of rendering an invalid element instead of writing them into a template tag', async () => {
145+
// @ts-ignore we know this is not correct, and we want to test the error handling for it
146+
const element = createElement(undefined);
147+
await expect(render(element)).rejects.toThrowErrorMatchingSnapshot();
148+
});
125149
});

packages/render/src/browser/render.tsx

Lines changed: 24 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,28 @@
11
import { convert } from 'html-to-text';
22
import { Suspense } from 'react';
3-
import type {
4-
PipeableStream,
5-
ReactDOMServerReadableStream,
6-
} from 'react-dom/server';
3+
import type { ReactDOMServerReadableStream } from 'react-dom/server';
74
import { pretty } from '../node';
85
import type { Options } from '../shared/options';
96
import { plainTextSelectors } from '../shared/plain-text-selectors';
107

118
const decoder = new TextDecoder('utf-8');
129

13-
const readStream = async (
14-
stream: PipeableStream | ReactDOMServerReadableStream,
15-
) => {
10+
const readStream = async (stream: ReactDOMServerReadableStream) => {
1611
const chunks: Uint8Array[] = [];
1712

18-
if ('pipeTo' in stream) {
19-
// means it's a readable stream
20-
const writableStream = new WritableStream({
21-
write(chunk: Uint8Array) {
22-
chunks.push(chunk);
23-
},
24-
});
25-
await stream.pipeTo(writableStream);
26-
} else {
27-
throw new Error(
28-
'For some reason, the Node version of `react-dom/server` has been imported instead of the browser one.',
29-
{
13+
const writableStream = new WritableStream({
14+
write(chunk: Uint8Array) {
15+
chunks.push(chunk);
16+
},
17+
abort(reason) {
18+
throw new Error('Stream aborted', {
3019
cause: {
31-
stream,
20+
reason,
3221
},
33-
},
34-
);
35-
}
22+
});
23+
},
24+
});
25+
await stream.pipeTo(writableStream);
3626

3727
let length = 0;
3828
chunks.forEach((item) => {
@@ -50,29 +40,22 @@ const readStream = async (
5040

5141
export const render = async (node: React.ReactNode, options?: Options) => {
5242
const suspendedElement = <Suspense>{node}</Suspense>;
53-
const reactDOMServer = await import('react-dom/server').then(
43+
const reactDOMServer = await import('react-dom/server.browser').then(
5444
// This is beacuse react-dom/server is CJS
5545
(m) => m.default,
5646
);
5747

58-
let html!: string;
59-
if (Object.hasOwn(reactDOMServer, 'renderToReadableStream')) {
60-
html = await readStream(
61-
await reactDOMServer.renderToReadableStream(suspendedElement),
62-
);
63-
} else {
64-
await new Promise<void>((resolve, reject) => {
65-
const stream = reactDOMServer.renderToPipeableStream(suspendedElement, {
66-
async onAllReady() {
67-
html = await readStream(stream);
68-
resolve();
69-
},
70-
onError(error) {
71-
reject(error as Error);
48+
const html = await new Promise<string>((resolve, reject) => {
49+
reactDOMServer
50+
.renderToReadableStream(suspendedElement, {
51+
onError(error: unknown) {
52+
reject(error);
7253
},
73-
});
74-
});
75-
}
54+
})
55+
.then(readStream)
56+
.then(resolve)
57+
.catch(reject);
58+
});
7659

7760
if (options?.plainText) {
7861
return convert(html, {

0 commit comments

Comments
 (0)