diff --git a/packages/render/package.json b/packages/render/package.json
index 483bcc3ad9..636f708a44 100644
--- a/packages/render/package.json
+++ b/packages/render/package.json
@@ -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",
diff --git a/packages/render/src/__snapshots__/render-async-node.spec.tsx.snap b/packages/render/src/__snapshots__/render-async-node.spec.tsx.snap
new file mode 100644
index 0000000000..b2d543c0ca
--- /dev/null
+++ b/packages/render/src/__snapshots__/render-async-node.spec.tsx.snap
@@ -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`] = `
+"
+
+
+
Example Domain
+
+
+
+
+
+
+
+
+
+
Example Domain
+
This domain is for use in illustrative examples in documents. You may use this
+ domain in literature without prior coordination or asking for permission.
+
More information...
+
+
+
+
"
+`;
diff --git a/packages/render/src/render-async-node.spec.tsx b/packages/render/src/render-async-node.spec.tsx
index 1aad42c89c..405166528e 100644
--- a/packages/render/src/render-async-node.spec.tsx
+++ b/packages/render/src/render-async-node.spec.tsx
@@ -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";
@@ -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 ;
+ };
+
+ const renderedTemplate = await renderAsync(
+
+
+ ,
+ );
+
+ expect(renderedTemplate).toMatchSnapshot();
+ });
+
it("converts a React component into HTML", async () => {
const actualOutput = await renderAsync();
diff --git a/packages/render/src/render-async.ts b/packages/render/src/render-async.ts
index 8ba87f0539..6f70a870c7 100644
--- a/packages/render/src/render-async.ts
+++ b/packages/render/src/render-async.ts
@@ -1,6 +1,8 @@
-/* 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";
@@ -8,27 +10,37 @@ 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({
+ write(chunk: BufferSource, _encoding, callback) {
+ result += decoder.decode(chunk);
+
+ callback();
+ },
+ });
+ stream.pipe(writable);
+
+ return new Promise((resolve, reject) => {
+ writable.on("error", reject);
+ writable.on("close", () => {
+ resolve(result);
+ });
+ });
}
return result;
@@ -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 =
- '';
-
- 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((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, {
@@ -59,6 +78,9 @@ export const renderAsync = async (
});
}
+ const doctype =
+ '';
+
const document = `${doctype}${html}`;
if (options?.pretty) {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 433daefa0e..42da9d4d03 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -811,6 +811,9 @@ importers:
react-dom:
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
+ react-promise-suspense:
+ specifier: 0.3.4
+ version: 0.3.4
devDependencies:
'@babel/preset-react':
specifier: 7.23.3
@@ -5864,6 +5867,10 @@ packages:
/extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
+ /fast-deep-equal@2.0.1:
+ resolution: {integrity: sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==}
+ dev: false
+
/fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -7974,6 +7981,12 @@ packages:
resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
dev: true
+ /react-promise-suspense@0.3.4:
+ resolution: {integrity: sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==}
+ dependencies:
+ fast-deep-equal: 2.0.1
+ dev: false
+
/react-remove-scroll-bar@2.3.6(@types/react@18.2.33)(react@18.2.0):
resolution: {integrity: sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==}
engines: {node: '>=10'}