Skip to content

feat(render): Use react-dom/server.edge on edge environments #2225

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

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
Draft
30 changes: 25 additions & 5 deletions packages/render/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand Down
45 changes: 1 addition & 44 deletions packages/render/src/browser/render.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,9 @@
import { convert } from 'html-to-text';
import { Suspense } from 'react';
import type {
PipeableStream,
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 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);
};
import { readStream } from '../shared/read-stream.browser';

export const render = async (node: React.ReactNode, options?: Options) => {
const suspendedElement = <Suspense>{node}</Suspense>;
Expand Down
3 changes: 3 additions & 0 deletions packages/render/src/edge/__snapshots__/render.spec.tsx.snap
Original file line number Diff line number Diff line change
@@ -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><!--/$-->"`;
7 changes: 7 additions & 0 deletions packages/render/src/edge/import-react-dom.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const importReactDOM = async () => {
try {
return await import('react-dom/server.edge');
} catch (_exception) {
return await import('react-dom/server');
}
};
14 changes: 14 additions & 0 deletions packages/render/src/edge/index.ts
Original file line number Diff line number Diff line change
@@ -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/plain-text-selectors';
export * from '../shared/utils/pretty';
export * from './render';
126 changes: 126 additions & 0 deletions packages/render/src/edge/render.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* @vitest-environment jsdom
*/

import { Preview } from '../shared/utils/preview';
import { Template } from '../shared/utils/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"`,
);
});
});
55 changes: 55 additions & 0 deletions packages/render/src/edge/render.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { convert } from 'html-to-text';
import { Suspense } from 'react';
import { pretty } from '../node';
import type { Options } from '../shared/options';
import { plainTextSelectors } from '../shared/plain-text-selectors';
import { readStream } from '../shared/read-stream.browser';
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,
);

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);
},
});
});
}

if (options?.plainText) {
return convert(html, {
selectors: plainTextSelectors,
...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 changes: 3 additions & 0 deletions packages/render/src/react-internals.d.ts
Original file line number Diff line number Diff line change
@@ -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";
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const readStream = async (
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.',
'For some reason, the Node version of `react-dom/server` has been imported and was read by a browser stream reading function.',
{
cause: {
stream,
Expand Down
6 changes: 6 additions & 0 deletions packages/render/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
]);