Skip to content

Commit 992cad0

Browse files
authored
chore(render): Use renderToPipeableStream instead of renderToStaticNodeStream (#1443)
1 parent 7da2b4e commit 992cad0

File tree

5 files changed

+139
-31
lines changed

5 files changed

+139
-31
lines changed

packages/render/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@
4646
"html-to-text": "9.0.5",
4747
"js-beautify": "^1.14.11",
4848
"react": "^18.2.0",
49-
"react-dom": "^18.2.0"
49+
"react-dom": "^18.2.0",
50+
"react-promise-suspense": "0.3.4"
5051
},
5152
"devDependencies": {
5253
"@babel/preset-react": "7.23.3",
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`renderAsync on node environments > that it properly waits for Suepsense boundaries to resolve before resolving 1`] = `
4+
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><div><!doctype html>
5+
<html>
6+
<head>
7+
<title>Example Domain</title>
8+
9+
<meta charset="utf-8" />
10+
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
11+
<meta name="viewport" content="width=device-width, initial-scale=1" />
12+
<style type="text/css">
13+
body {
14+
background-color: #f0f0f2;
15+
margin: 0;
16+
padding: 0;
17+
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
18+
19+
}
20+
div {
21+
width: 600px;
22+
margin: 5em auto;
23+
padding: 2em;
24+
background-color: #fdfdff;
25+
border-radius: 0.5em;
26+
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
27+
}
28+
a:link, a:visited {
29+
color: #38488f;
30+
text-decoration: none;
31+
}
32+
@media (max-width: 700px) {
33+
div {
34+
margin: 0 auto;
35+
width: auto;
36+
}
37+
}
38+
</style>
39+
</head>
40+
41+
<body>
42+
<div>
43+
<h1>Example Domain</h1>
44+
<p>This domain is for use in illustrative examples in documents. You may use this
45+
domain in literature without prior coordination or asking for permission.</p>
46+
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
47+
</div>
48+
</body>
49+
</html>
50+
</div><!--/$-->"
51+
`;

packages/render/src/render-async-node.spec.tsx

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

5+
import usePromise from "react-promise-suspense";
6+
import { Suspense } from "react";
57
import { Template } from "./utils/template";
68
import { Preview } from "./utils/preview";
79
import { renderAsync } from "./render-async";
@@ -36,6 +38,25 @@ describe("renderAsync on node environments", () => {
3638
vi.resetAllMocks();
3739
});
3840

41+
it("that it properly waits for Suepsense boundaries to resolve before resolving", async () => {
42+
const EmailTemplate = () => {
43+
const html = usePromise(
44+
() => fetch("https://example.com").then((res) => res.text()),
45+
[],
46+
);
47+
48+
return <div dangerouslySetInnerHTML={{ __html: html }} />;
49+
};
50+
51+
const renderedTemplate = await renderAsync(
52+
<Suspense>
53+
<EmailTemplate />
54+
</Suspense>,
55+
);
56+
57+
expect(renderedTemplate).toMatchSnapshot();
58+
});
59+
3960
it("converts a React component into HTML", async () => {
4061
const actualOutput = await renderAsync(<Template firstName="Jim" />);
4162

packages/render/src/render-async.ts

Lines changed: 52 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,46 @@
1-
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
21
import { convert } from "html-to-text";
3-
import type { ReactDOMServerReadableStream } from "react-dom/server";
2+
import type {
3+
PipeableStream,
4+
ReactDOMServerReadableStream,
5+
} from "react-dom/server";
46
import { pretty } from "./utils/pretty";
57
import { plainTextSelectors } from "./plain-text-selectors";
68
import type { Options } from "./options";
79

810
const decoder = new TextDecoder("utf-8");
911

1012
const readStream = async (
11-
readableStream: NodeJS.ReadableStream | ReactDOMServerReadableStream,
13+
stream: PipeableStream | ReactDOMServerReadableStream,
1214
) => {
1315
let result = "";
1416

15-
if ("allReady" in readableStream) {
16-
const reader = readableStream.getReader();
17-
18-
// eslint-disable-next-line no-constant-condition
19-
while (true) {
20-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, no-await-in-loop
21-
const { value, done } = await reader.read();
22-
if (done) {
23-
break;
24-
}
25-
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
26-
result += decoder.decode(value);
27-
}
17+
if ("pipeTo" in stream) {
18+
// means it's a readable stream
19+
const writableStream = new WritableStream({
20+
write(chunk: BufferSource) {
21+
result += decoder.decode(chunk);
22+
},
23+
});
24+
await stream.pipeTo(writableStream);
2825
} else {
29-
for await (const chunk of readableStream) {
30-
result += decoder.decode(Buffer.from(chunk));
31-
}
26+
const {
27+
default: { Writable },
28+
} = await import("node:stream");
29+
const writable = new Writable({
30+
write(chunk: BufferSource, _encoding, callback) {
31+
result += decoder.decode(chunk);
32+
33+
callback();
34+
},
35+
});
36+
stream.pipe(writable);
37+
38+
return new Promise<string>((resolve, reject) => {
39+
writable.on("error", reject);
40+
writable.on("close", () => {
41+
resolve(result);
42+
});
43+
});
3244
}
3345

3446
return result;
@@ -39,18 +51,25 @@ export const renderAsync = async (
3951
options?: Options,
4052
) => {
4153
const reactDOMServer = await import("react-dom/server");
42-
const renderToStream = Object.hasOwn(reactDOMServer, "renderToReadableStream")
43-
? reactDOMServer.renderToReadableStream // means this is using react-dom/server.browser
44-
: reactDOMServer.renderToStaticNodeStream;
4554

46-
const doctype =
47-
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
48-
49-
const htmlOrReadableStream = await renderToStream(component);
50-
const html =
51-
typeof htmlOrReadableStream === "string"
52-
? htmlOrReadableStream
53-
: await readStream(htmlOrReadableStream);
55+
let html!: string;
56+
if (Object.hasOwn(reactDOMServer, "renderToReadableStream")) {
57+
html = await readStream(
58+
await reactDOMServer.renderToReadableStream(component),
59+
);
60+
} else {
61+
await new Promise<void>((resolve, reject) => {
62+
const stream = reactDOMServer.renderToPipeableStream(component, {
63+
async onAllReady() {
64+
html = await readStream(stream);
65+
resolve();
66+
},
67+
onError(error) {
68+
reject(error as Error);
69+
},
70+
});
71+
});
72+
}
5473

5574
if (options?.plainText) {
5675
return convert(html, {
@@ -59,6 +78,9 @@ export const renderAsync = async (
5978
});
6079
}
6180

81+
const doctype =
82+
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
83+
6284
const document = `${doctype}${html}`;
6385

6486
if (options?.pretty) {

pnpm-lock.yaml

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)