Skip to content
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

chore(render): Use renderToPipeableStream instead of renderToStaticNodeStream #1443

Merged
merged 8 commits into from
May 13, 2024
3 changes: 2 additions & 1 deletion packages/render/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@
"html-to-text": "9.0.5",
"js-beautify": "^1.14.11",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"react-promise-suspense": "0.3.4"
},
"devDependencies": {
"@babel/preset-react": "7.23.3",
Expand Down
51 changes: 51 additions & 0 deletions packages/render/src/__snapshots__/render-async-node.spec.tsx.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`renderAsync on node environments > 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"><!--$--><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><!--/$-->"
`;
21 changes: 21 additions & 0 deletions packages/render/src/render-async-node.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
* @vitest-environment node
*/

import usePromise from "react-promise-suspense";
import { Suspense } from "react";
import { Template } from "./utils/template";
import { Preview } from "./utils/preview";
import { renderAsync } from "./render-async";
Expand Down Expand Up @@ -36,6 +38,25 @@ describe("renderAsync on node environments", () => {
vi.resetAllMocks();
});

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

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

const renderedTemplate = await renderAsync(
<Suspense>
<EmailTemplate />
</Suspense>,
);

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

it("converts a React component into HTML", async () => {
const actualOutput = await renderAsync(<Template firstName="Jim" />);

Expand Down
82 changes: 52 additions & 30 deletions packages/render/src/render-async.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,46 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
import { convert } from "html-to-text";
import type { ReactDOMServerReadableStream } from "react-dom/server";
import type {
PipeableStream,
ReactDOMServerReadableStream,
} from "react-dom/server";
import { pretty } from "./utils/pretty";
import { plainTextSelectors } from "./plain-text-selectors";
import type { Options } from "./options";

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

const readStream = async (
readableStream: NodeJS.ReadableStream | ReactDOMServerReadableStream,
stream: PipeableStream | ReactDOMServerReadableStream,
) => {
let result = "";

if ("allReady" in readableStream) {
const reader = readableStream.getReader();

// eslint-disable-next-line no-constant-condition
while (true) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, no-await-in-loop
const { value, done } = await reader.read();
if (done) {
break;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
result += decoder.decode(value);
}
if ("pipeTo" in stream) {
// means it's a readable stream
const writableStream = new WritableStream({
write(chunk: BufferSource) {
result += decoder.decode(chunk);
},
});
await stream.pipeTo(writableStream);
} else {
for await (const chunk of readableStream) {
result += decoder.decode(Buffer.from(chunk));
}
const {
default: { Writable },
} = await import("node:stream");
const writable = new Writable({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When this gets compiled to a require statement by the build, I get the following in my native ESM project:

Error: Dynamic require of "stream" is not supported

All server-side JavaScript environment support dynamic imports. Could we not transpile the dynamic import?

write(chunk: BufferSource, _encoding, callback) {
result += decoder.decode(chunk);

callback();
},
});
stream.pipe(writable);

return new Promise<string>((resolve, reject) => {
writable.on("error", reject);
writable.on("close", () => {
resolve(result);
});
});
}

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

const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';

const htmlOrReadableStream = await renderToStream(component);
const html =
typeof htmlOrReadableStream === "string"
? htmlOrReadableStream
: await readStream(htmlOrReadableStream);
let html!: string;
if (Object.hasOwn(reactDOMServer, "renderToReadableStream")) {
html = await readStream(
await reactDOMServer.renderToReadableStream(component),
);
} else {
await new Promise<void>((resolve, reject) => {
const stream = reactDOMServer.renderToPipeableStream(component, {
async onAllReady() {
html = await readStream(stream);
resolve();
},
onError(error) {
reject(error as Error);
},
});
});
}

if (options?.plainText) {
return convert(html, {
Expand All @@ -59,6 +78,9 @@ export const renderAsync = async (
});
}

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}`;

if (options?.pretty) {
Expand Down
13 changes: 13 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading