fix(render): edge environment errors with MessageChannel (#2225)

This commit is contained in:
Gabriel Miranda
2025-08-20 11:12:44 -03:00
committed by GitHub
parent 9cc6a5e9d7
commit 036c46ceb7
12 changed files with 270 additions and 83 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@react-email/render": patch
---
fix MessageChannel errors on edge environments
+25 -5
View File
@@ -11,14 +11,14 @@
],
"exports": {
".": {
"node": {
"workerd": {
"import": {
"types": "./dist/node/index.d.mts",
"default": "./dist/node/index.mjs"
"types": "./dist/edge/index.d.mts",
"default": "./dist/edge/index.mjs"
},
"require": {
"types": "./dist/node/index.d.ts",
"default": "./dist/node/index.js"
"types": "./dist/edge/index.d.ts",
"default": "./dist/edge/index.js"
}
},
"deno": {
@@ -41,6 +41,26 @@
"default": "./dist/browser/index.js"
}
},
"edge-light": {
"import": {
"types": "./dist/edge/index.d.mts",
"default": "./dist/edge/index.mjs"
},
"require": {
"types": "./dist/edge/index.d.ts",
"default": "./dist/edge/index.js"
}
},
"node": {
"import": {
"types": "./dist/node/index.d.mts",
"default": "./dist/node/index.mjs"
},
"require": {
"types": "./dist/node/index.d.ts",
"default": "./dist/node/index.js"
}
},
"browser": {
"import": {
"types": "./dist/browser/index.d.mts",
@@ -1,44 +0,0 @@
import type {
PipeableStream,
ReactDOMServerReadableStream,
} from 'react-dom/server.browser';
const decoder = new TextDecoder('utf-8');
export const readStream = async (
stream: PipeableStream | 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.',
{
cause: {
stream,
},
},
);
}
let length = 0;
chunks.forEach((item) => {
length += item.length;
});
const mergedChunks = new Uint8Array(length);
let offset = 0;
chunks.forEach((item) => {
mergedChunks.set(item, offset);
offset += item.length;
});
return decoder.decode(mergedChunks);
};
+1 -34
View File
@@ -1,40 +1,7 @@
import { Suspense } from 'react';
import type { ReactDOMServerReadableStream } from 'react-dom/server';
import { pretty, toPlainText } from '../node';
import type { Options } from '../shared/options';
const decoder = new TextDecoder('utf-8');
const readStream = async (stream: ReactDOMServerReadableStream) => {
const chunks: Uint8Array[] = [];
const writableStream = new WritableStream({
write(chunk: Uint8Array) {
chunks.push(chunk);
},
abort(reason) {
throw new Error('Stream aborted', {
cause: {
reason,
},
});
},
});
await stream.pipeTo(writableStream);
let length = 0;
chunks.forEach((item) => {
length += item.length;
});
const mergedChunks = new Uint8Array(length);
let offset = 0;
chunks.forEach((item) => {
mergedChunks.set(item, offset);
offset += item.length;
});
return decoder.decode(mergedChunks);
};
import { readStream } from '../shared/read-stream.browser';
export const render = async (node: React.ReactNode, options?: Options) => {
const suspendedElement = <Suspense>{node}</Suspense>;
@@ -0,0 +1,3 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`render on the edge > 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><!--/$-->"`;
@@ -0,0 +1,8 @@
export const importReactDOM = async () => {
try {
return await import('react-dom/server.edge');
} catch (_exception) {
// This ensures that we still have compatibiltity with React 18, which doesn't have the `edge` conditional export.
return await import('react-dom/server');
}
};
+14
View File
@@ -0,0 +1,14 @@
import type { Options } from '../shared/options';
import { render } from './render';
/**
* @deprecated use {@link render}
*/
export const renderAsync = (element: React.ReactElement, options?: Options) => {
return render(element, options);
};
export * from '../shared/options';
export * from '../shared/utils/pretty';
export * from '../shared/utils/to-plain-text';
export * from './render';
+126
View File
@@ -0,0 +1,126 @@
/**
* @vitest-environment jsdom
*/
import { Preview } from '../shared/utils/testing/preview';
import { Template } from '../shared/utils/testing/template';
import { render } from './render';
type Import = typeof import('react-dom/server') & {
default: typeof import('react-dom/server');
};
describe('render on the edge', () => {
beforeAll(() => {
global.MessageChannel = class {
constructor() {
throw new Error('MessageChannel is not supported');
}
} as any;
});
afterEach(() => {
vi.resetAllMocks();
});
it('converts a React component into HTML with Next 14 error stubs', async () => {
vi.mock('react-dom/server', async (_importOriginal) => {
const ReactDOMServerBrowser = await vi.importActual<Import>(
'react-dom/server.browser',
);
const ERROR_MESSAGE =
'Internal Error: do not use legacy react-dom/server APIs. If you encountered this error, please open an issue on the Next.js repo.';
return {
...ReactDOMServerBrowser,
default: {
...ReactDOMServerBrowser,
renderToString() {
throw new Error(ERROR_MESSAGE);
},
renderToStaticMarkup() {
throw new Error(ERROR_MESSAGE);
},
},
renderToString() {
throw new Error(ERROR_MESSAGE);
},
renderToStaticMarkup() {
throw new Error(ERROR_MESSAGE);
},
};
});
const actualOutput = await render(<Template firstName="Jim" />);
expect(actualOutput).toMatchInlineSnapshot(
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><link rel="preload" as="image" href="img/test.png"/><!--$--><h1>Welcome, <!-- -->Jim<!-- -->!</h1><img alt="test" src="img/test.png"/><p>Thanks for trying our product. We&#x27;re thrilled to have you on board!</p><!--/$-->"`,
);
});
// This is a test to ensure we have no regressions for https://github.com/resend/react-email/issues/1667
it('should handle characters with a higher byte count gracefully', async () => {
const actualOutput = await render(
<>
<p>Test Normal 情報Ⅰコース担当者様</p>
<p>
平素よりお世話になっております。 情報Ⅰサポートチームです。
情報Ⅰ本講座につきまして仕様変更のためご連絡させていただきました。{' '}
</p>
今後ジクタス上の講座につきましては、8回分の授業をひとまとまりとしてパート分けされた状態で公開されてまいります。
<p>
伴いまして、画面上の表示に一部変更がありますのでお知らせいたします。
ご登録いただいた各生徒の受講ペースに応じて公開パートが増えてまいります。
具体的な表示イメージは下記ページをご確認ください。
</p>
<p>
2024年8月末時点で情報Ⅰ本講座を受講していたアカウントにつきましては、
今まで公開していた第1~9回までの講座一覧に加え、パート分けされた講座が追加で公開されてまりいます。
第1~9回の表示はそのまま引き継がれますが、Webドリルの進捗表示はパート分けの講座には適用されません。ご了承くださいませ。
仕様変更に伴い、現在教室長もしくは講師に生徒アカウントログインをお願いしておりますが、今後は生徒自身にてログインをしていただいて問題ございません。
</p>
<p>
また、生徒が自宅等にてログインし復習に取り組むことも問題ございませんので、教室にてご指示いただければと存じます。
(実際にご指示いただくかは教室判断に委ねさせていただきます。)
</p>
<p>
受講ペースが変更したり、増コマが発生したりする場合などは、公開ペースを本部にて調整いたしますので、下記より必ずご連絡くださいませ。
また本件に関して不明な点がございましたら、同フォームよりお問い合わせください。
以上、引き続きよろしくお願い申し上げます。 情報Ⅰサポートチーム
</p>
</>,
);
expect(actualOutput).toMatchSnapshot();
});
it('converts a React component into HTML', async () => {
const actualOutput = await render(<Template firstName="Jim" />);
expect(actualOutput).toMatchInlineSnapshot(
`"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><link rel="preload" as="image" href="img/test.png"/><!--$--><h1>Welcome, <!-- -->Jim<!-- -->!</h1><img alt="test" src="img/test.png"/><p>Thanks for trying our product. We&#x27;re thrilled to have you on board!</p><!--/$-->"`,
);
});
it('converts a React component into PlainText', async () => {
const actualOutput = await render(<Template firstName="Jim" />, {
plainText: true,
});
expect(actualOutput).toMatchInlineSnapshot(`
"WELCOME, JIM!
Thanks for trying our product. We're thrilled to have you on board!"
`);
});
it('converts to plain text and removes reserved ID', async () => {
const actualOutput = await render(<Preview />, {
plainText: true,
});
expect(actualOutput).toMatchInlineSnapshot(
`"THIS SHOULD BE RENDERED IN PLAIN TEXT"`,
);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { Suspense } from 'react';
import { pretty } from '../node';
import type { Options } from '../shared/options';
import { readStream } from '../shared/read-stream.browser';
import { toPlainText } from '../shared/utils/to-plain-text';
import { importReactDOM } from './import-react-dom';
export const render = async (
element: React.ReactElement,
options?: Options,
) => {
const suspendedElement = <Suspense>{element}</Suspense>;
const reactDOMServer = await importReactDOM().then(
// This is beacuse react-dom/server is CJS
(m) => m.default,
);
const html = await new Promise<string>((resolve, reject) => {
reactDOMServer
.renderToReadableStream(suspendedElement, {
onError(error: unknown) {
reject(error);
},
progressiveChunkSize: Number.POSITIVE_INFINITY,
})
.then(readStream)
.then(resolve)
.catch(reject);
});
if (options?.plainText) {
return toPlainText(html, options.htmlToTextOptions);
}
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
const document = `${doctype}${html.replace(/<!DOCTYPE.*?>/, '')}`;
if (options?.pretty) {
return pretty(document);
}
return document;
};
+3
View File
@@ -1,3 +1,6 @@
declare module "react-dom/server.browser" {
export * from "react-dom/server";
}
declare module "react-dom/server.edge" {
export * from "react-dom/server";
}
@@ -0,0 +1,34 @@
import type { ReactDOMServerReadableStream } from 'react-dom/server.browser';
const decoder = new TextDecoder('utf-8');
export const readStream = async (stream: ReactDOMServerReadableStream) => {
const chunks: Uint8Array[] = [];
const writableStream = new WritableStream({
write(chunk: Uint8Array) {
chunks.push(chunk);
},
abort(reason) {
throw new Error('Stream aborted', {
cause: {
reason,
},
});
},
});
await stream.pipeTo(writableStream);
let length = 0;
chunks.forEach((item) => {
length += item.length;
});
const mergedChunks = new Uint8Array(length);
let offset = 0;
chunks.forEach((item) => {
mergedChunks.set(item, offset);
offset += item.length;
});
return decoder.decode(mergedChunks);
};
+6
View File
@@ -13,4 +13,10 @@ export default defineConfig([
outDir: './dist/browser',
format: ['cjs', 'esm'],
},
{
dts: true,
entry: ['./src/edge/index.ts'],
outDir: './dist/edge',
format: ['cjs', 'esm'],
},
]);