Skip to content

Commit 9f18561

Browse files
committed
feat(render): Use react-dom/server.edge in a new edge-light export
1 parent 2242dcb commit 9f18561

File tree

8 files changed

+210
-45
lines changed

8 files changed

+210
-45
lines changed

packages/render/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,16 @@
5151
"default": "./dist/browser/index.js"
5252
}
5353
},
54+
"edge-light": {
55+
"import": {
56+
"types": "./dist/edge-light/index.d.mts",
57+
"default": "./dist/edge-light/index.mjs"
58+
},
59+
"require": {
60+
"types": "./dist/edge-light/index.d.ts",
61+
"default": "./dist/edge-light/index.js"
62+
}
63+
},
5464
"default": {
5565
"import": {
5666
"types": "./dist/node/index.d.mts",

packages/render/src/browser/render.tsx

Lines changed: 1 addition & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,9 @@
11
import { convert } from 'html-to-text';
22
import { Suspense } from 'react';
3-
import type {
4-
PipeableStream,
5-
ReactDOMServerReadableStream,
6-
} from 'react-dom/server';
73
import { pretty } from '../node';
84
import type { Options } from '../shared/options';
95
import { plainTextSelectors } from '../shared/plain-text-selectors';
10-
11-
const decoder = new TextDecoder('utf-8');
12-
13-
const readStream = async (
14-
stream: PipeableStream | ReactDOMServerReadableStream,
15-
) => {
16-
const chunks: Uint8Array[] = [];
17-
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-
{
30-
cause: {
31-
stream,
32-
},
33-
},
34-
);
35-
}
36-
37-
let length = 0;
38-
chunks.forEach((item) => {
39-
length += item.length;
40-
});
41-
const mergedChunks = new Uint8Array(length);
42-
let offset = 0;
43-
chunks.forEach((item) => {
44-
mergedChunks.set(item, offset);
45-
offset += item.length;
46-
});
47-
48-
return decoder.decode(mergedChunks);
49-
};
6+
import { readStream } from '../shared/read-stream.browser';
507

518
export const render = async (node: React.ReactNode, options?: Options) => {
529
const suspendedElement = <Suspense>{node}</Suspense>;
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { Options } from '../shared/options';
2+
import { render } from './render';
3+
4+
/**
5+
* @deprecated use {@link render}
6+
*/
7+
export const renderAsync = (element: React.ReactElement, options?: Options) => {
8+
return render(element, options);
9+
};
10+
11+
export * from '../shared/options';
12+
export * from '../shared/plain-text-selectors';
13+
export * from '../shared/utils/pretty';
14+
export * from './render';
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { Preview } from '../shared/utils/preview';
2+
import { Template } from '../shared/utils/template';
3+
import { render } from './render';
4+
5+
type Import = typeof import('react-dom/server') & {
6+
default: typeof import('react-dom/server');
7+
};
8+
9+
describe('render on the edge', () => {
10+
beforeAll(() => {
11+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-extraneous-class
12+
global.MessageChannel = class {
13+
constructor() {
14+
throw new Error('MessageChannel is not supported');
15+
}
16+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
17+
} as any;
18+
});
19+
20+
it('converts a React component into HTML with Next 14 error stubs', async () => {
21+
vi.mock('react-dom/server', async () => {
22+
const ReactDOMServer = await vi.importActual<Import>('react-dom/server');
23+
const ERROR_MESSAGE =
24+
'Internal Error: do not use legacy react-dom/server APIs. If you encountered this error, please open an issue on the Next.js repo.';
25+
26+
return {
27+
...ReactDOMServer,
28+
default: {
29+
...ReactDOMServer.default,
30+
renderToString() {
31+
throw new Error(ERROR_MESSAGE);
32+
},
33+
renderToStaticMarkup() {
34+
throw new Error(ERROR_MESSAGE);
35+
},
36+
},
37+
renderToString() {
38+
throw new Error(ERROR_MESSAGE);
39+
},
40+
renderToStaticMarkup() {
41+
throw new Error(ERROR_MESSAGE);
42+
},
43+
};
44+
});
45+
46+
const actualOutput = await render(<Template firstName="Jim" />);
47+
48+
expect(actualOutput).toMatchInlineSnapshot(
49+
`"<!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><!--/$-->"`,
50+
);
51+
52+
vi.resetAllMocks();
53+
});
54+
55+
// This is a test to ensure we have no regressions for https://github.com/resend/react-email/issues/1667
56+
it('should handle characters with a higher byte count gracefully in React 18', async () => {
57+
const actualOutput = await render(
58+
<>
59+
<p>Test Normal 情報Ⅰコース担当者様</p>
60+
<p>
61+
平素よりお世話になっております。 情報Ⅰサポートチームです。
62+
情報Ⅰ本講座につきまして仕様変更のためご連絡させていただきました。{' '}
63+
</p>
64+
今後ジクタス上の講座につきましては、8回分の授業をひとまとまりとしてパート分けされた状態で公開されてまいります。
65+
<p>
66+
伴いまして、画面上の表示に一部変更がありますのでお知らせいたします。
67+
ご登録いただいた各生徒の受講ペースに応じて公開パートが増えてまいります。
68+
具体的な表示イメージは下記ページをご確認ください。
69+
</p>
70+
<p>
71+
2024年8月末時点で情報Ⅰ本講座を受講していたアカウントにつきましては、
72+
今まで公開していた第1~9回までの講座一覧に加え、パート分けされた講座が追加で公開されてまりいます。
73+
第1~9回の表示はそのまま引き継がれますが、Webドリルの進捗表示はパート分けの講座には適用されません。ご了承くださいませ。
74+
仕様変更に伴い、現在教室長もしくは講師に生徒アカウントログインをお願いしておりますが、今後は生徒自身にてログインをしていただいて問題ございません。
75+
</p>
76+
<p>
77+
また、生徒が自宅等にてログインし復習に取り組むことも問題ございませんので、教室にてご指示いただければと存じます。
78+
(実際にご指示いただくかは教室判断に委ねさせていただきます。)
79+
</p>
80+
<p>
81+
受講ペースが変更したり、増コマが発生したりする場合などは、公開ペースを本部にて調整いたしますので、下記より必ずご連絡くださいませ。
82+
また本件に関して不明な点がございましたら、同フォームよりお問い合わせください。
83+
以上、引き続きよろしくお願い申し上げます。 情報Ⅰサポートチーム
84+
</p>
85+
</>,
86+
);
87+
88+
expect(actualOutput).toMatchSnapshot();
89+
});
90+
91+
it('converts a React component into HTML', async () => {
92+
const actualOutput = await render(<Template firstName="Jim" />);
93+
94+
expect(actualOutput).toMatchInlineSnapshot(
95+
`"<!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><!--/$-->"`,
96+
);
97+
});
98+
99+
it('converts a React component into PlainText', async () => {
100+
const actualOutput = await render(<Template firstName="Jim" />, {
101+
plainText: true,
102+
});
103+
104+
expect(actualOutput).toMatchInlineSnapshot(`
105+
"WELCOME, JIM!
106+
107+
Thanks for trying our product. We're thrilled to have you on board!"
108+
`);
109+
});
110+
111+
it('converts to plain text and removes reserved ID', async () => {
112+
const actualOutput = await render(<Preview />, {
113+
plainText: true,
114+
});
115+
116+
expect(actualOutput).toMatchInlineSnapshot(
117+
`"THIS SHOULD BE RENDERED IN PLAIN TEXT"`,
118+
);
119+
});
120+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { convert } from 'html-to-text';
2+
import { Suspense } from 'react';
3+
import { pretty } from '../node';
4+
import type { Options } from '../shared/options';
5+
import { plainTextSelectors } from '../shared/plain-text-selectors';
6+
import { readStream } from '../shared/read-stream.browser';
7+
8+
export const render = async (
9+
element: React.ReactElement,
10+
options?: Options,
11+
) => {
12+
const suspendedElement = <Suspense>{element}</Suspense>;
13+
const reactDOMServer = await import('react-dom/server.edge');
14+
await import('react-dom/server').then(
15+
// This is beacuse react-dom/server is CJS
16+
(m) => m.default,
17+
);
18+
19+
let html!: string;
20+
if (Object.hasOwn(reactDOMServer, 'renderToReadableStream')) {
21+
html = await readStream(
22+
await reactDOMServer.renderToReadableStream(suspendedElement),
23+
);
24+
} else {
25+
await new Promise<void>((resolve, reject) => {
26+
const stream = reactDOMServer.renderToPipeableStream(suspendedElement, {
27+
async onAllReady() {
28+
html = await readStream(stream);
29+
resolve();
30+
},
31+
onError(error) {
32+
reject(error as Error);
33+
},
34+
});
35+
});
36+
}
37+
38+
if (options?.plainText) {
39+
return convert(html, {
40+
selectors: plainTextSelectors,
41+
...options.htmlToTextOptions,
42+
});
43+
}
44+
45+
const doctype =
46+
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
47+
48+
const document = `${doctype}${html.replace(/<!DOCTYPE.*?>/, '')}`;
49+
50+
if (options?.pretty) {
51+
return pretty(document);
52+
}
53+
54+
return document;
55+
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
declare module "react-dom/server.browser" {
22
export * from "react-dom/server";
33
}
4+
declare module "react-dom/server.edge" {
5+
export * from "react-dom/server";
6+
}

packages/render/src/browser/read-stream.ts renamed to packages/render/src/shared/read-stream.browser.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export const readStream = async (
2020
await stream.pipeTo(writableStream);
2121
} else {
2222
throw new Error(
23-
'For some reason, the Node version of `react-dom/server` has been imported instead of the browser one.',
23+
'For some reason, the Node version of `react-dom/server` has been imported and was read by a browser stream reading function.',
2424
{
2525
cause: {
2626
stream,

packages/render/tsup.config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,10 @@ export default defineConfig([
1313
outDir: './dist/browser',
1414
format: ['cjs', 'esm'],
1515
},
16+
{
17+
dts: true,
18+
entry: ['./src/edge-light/index.ts'],
19+
outDir: './dist/edge-light',
20+
format: ['cjs', 'esm'],
21+
},
1622
]);

0 commit comments

Comments
 (0)