Skip to content

fix(render): Browser version including errors in the output instead of throwing them #2267

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/deep-clowns-bet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@react-email/render": patch
---

fix browser version including errors in the output instead of throwing them

This file was deleted.

102 changes: 102 additions & 0 deletions packages/render/src/browser/__snapshots__/render-web.spec.tsx.snap
Original file line number Diff line number Diff line change
@@ -1,3 +1,105 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

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><!--/$-->"`;

exports[`render on the browser environment > should properly wait for Suepsense boundaries to ending before resolving 1`] = `
"<!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>
<html>
<head>
<title>Example Domain</title>

<meta charset="utf-8" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type="text/css">
body {
background-color: #f0f0f2;
margin: 0;
padding: 0;
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;

}
div {
width: 600px;
margin: 5em auto;
padding: 2em;
background-color: #fdfdff;
border-radius: 0.5em;
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
}
a:link, a:visited {
color: #38488f;
text-decoration: none;
}
@media (max-width: 700px) {
div {
margin: 0 auto;
width: auto;
}
}
</style>
</head>

<body>
<div>
<h1>Example Domain</h1>
<p>This domain is for use in illustrative examples in documents. You may use this
domain in literature without prior coordination or asking for permission.</p>
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
</html>
</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>"
`;

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.]`;

exports[`render on the browser environment > that it properly waits for Suepsense boundaries to resolve before resolving 1`] = `
"<!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>
<html>
<head>
<title>Example Domain</title>

<meta charset="utf-8" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type="text/css">
body {
background-color: #f0f0f2;
margin: 0;
padding: 0;
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;

}
div {
width: 600px;
margin: 5em auto;
padding: 2em;
background-color: #fdfdff;
border-radius: 0.5em;
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
}
a:link, a:visited {
color: #38488f;
text-decoration: none;
}
@media (max-width: 700px) {
div {
margin: 0 auto;
width: auto;
}
}
</style>
</head>

<body>
<div>
<h1>Example Domain</h1>
<p>This domain is for use in illustrative examples in documents. You may use this
domain in literature without prior coordination or asking for permission.</p>
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
</html>
</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>"
`;
24 changes: 24 additions & 0 deletions packages/render/src/browser/render-web.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
* @vitest-environment jsdom
*/

import { createElement } from 'react';
import usePromise from 'react-promise-suspense';
import { Preview } from '../shared/utils/preview';
import { Template } from '../shared/utils/template';
import { render } from './render';
Expand Down Expand Up @@ -122,4 +124,26 @@ describe('render on the browser environment', () => {
`"THIS SHOULD BE RENDERED IN PLAIN TEXT"`,
);
});

it('should properly wait for Suepsense boundaries to ending before resolving', async () => {
const EmailTemplate = () => {
const html = usePromise(
() => fetch('https://example.com').then((res) => res.text()),
[],
);

return <div dangerouslySetInnerHTML={{ __html: html }} />;
};

const renderedTemplate = await render(<EmailTemplate />);

expect(renderedTemplate).toMatchSnapshot();
});

// See https://github.com/resend/react-email/issues/2263
it('should throw error of rendering an invalid element instead of writing them into a template tag', async () => {
// @ts-ignore we know this is not correct, and we want to test the error handling for it
const element = createElement(undefined);
await expect(render(element)).rejects.toThrowErrorMatchingSnapshot();
});
});
65 changes: 24 additions & 41 deletions packages/render/src/browser/render.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,28 @@
import { convert } from 'html-to-text';
import { Suspense } from 'react';
import type {
PipeableStream,
ReactDOMServerReadableStream,
} from 'react-dom/server';
import type { ReactDOMServerReadableStream } from 'react-dom/server';
import { pretty } from '../node';
import type { Options } from '../shared/options';
import { plainTextSelectors } from '../shared/plain-text-selectors';

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

const readStream = async (
stream: PipeableStream | ReactDOMServerReadableStream,
) => {
const readStream = async (stream: ReactDOMServerReadableStream) => {
const chunks: Uint8Array[] = [];

if ('pipeTo' in stream) {
// means it's a readable stream
const writableStream = new WritableStream({
write(chunk: Uint8Array) {
chunks.push(chunk);
},
});
await stream.pipeTo(writableStream);
} else {
throw new Error(
'For some reason, the Node version of `react-dom/server` has been imported instead of the browser one.',
{
const writableStream = new WritableStream({
write(chunk: Uint8Array) {
chunks.push(chunk);
},
abort(reason) {
throw new Error('Stream aborted', {
cause: {
stream,
reason,
},
},
);
}
});
},
});
await stream.pipeTo(writableStream);

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

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

let html!: string;
if (Object.hasOwn(reactDOMServer, 'renderToReadableStream')) {
html = await readStream(
await reactDOMServer.renderToReadableStream(suspendedElement),
);
} else {
await new Promise<void>((resolve, reject) => {
const stream = reactDOMServer.renderToPipeableStream(suspendedElement, {
async onAllReady() {
html = await readStream(stream);
resolve();
},
onError(error) {
reject(error as Error);
const html = await new Promise<string>((resolve, reject) => {
reactDOMServer
.renderToReadableStream(suspendedElement, {
onError(error: unknown) {
reject(error);
},
});
});
}
})
.then(readStream)
.then(resolve)
.catch(reject);
});

if (options?.plainText) {
return convert(html, {
Expand Down
Loading