Skip to content

fix(render, tailwind): The renderAsync utility and the Tailwind component failing on the edge for next 14 #1079

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

Merged
merged 10 commits into from
Dec 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/utilities/render.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,12 @@ Click me [https://example.com]
<ResponseField name="plainText" type="boolean">
Generate plain text version
</ResponseField>

## Caveats

If you are using NextJS with the `render` function, be aware that
with Next **14** or above you might have errors using it.

So it is recommended that you use the `renderAsync` variant of this function.
It has the same API but uses different underlying React functions to avoid
problems like these.
1 change: 1 addition & 0 deletions packages/render/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ module.exports = {
extends: ["custom/react-internal"],
rules: {
"import/no-default-export": "off",
"tsdoc/syntax": "off"
},
};
7 changes: 5 additions & 2 deletions packages/render/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,14 @@
},
"devDependencies": {
"@babel/preset-react": "7.22.5",
"@edge-runtime/vm": "3.1.7",
"@types/html-to-text": "9.0.1",
"@types/js-beautify": "^1.14.3",
"@types/js-beautify": "1.14.3",
"eslint-config-custom": "workspace:*",
"jsdom": "23.0.1",
"tsconfig": "workspace:*",
"typescript": "5.1.6"
"typescript": "5.1.6",
"vitest": "0.34.6"
},
"publishConfig": {
"access": "public"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,40 +1,47 @@
/**
* @vitest-environment edge-runtime
*/

import { Template } from "./utils/template";
import { Preview } from "./utils/preview";
import { renderAsync } from "./render-async";

describe("renderAsync using renderToStaticMarkup", () => {
it("converts a React component into HTML", async () => {
const actualOutput = await renderAsync(<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\\"><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>"',
);
});
describe("renderAsync on the edge", () => {
it("converts a React component into HTML with Next 14 error stubs", async () => {
vi.mock("react-dom/server", async (_importOriginal) => {
const ReactDOMServer = await import("react-dom/server");
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.";

it("converts a React component into PlainText", async () => {
const actualOutput = await renderAsync(<Template firstName="Jim" />, {
plainText: true,
return {
...ReactDOMServer,
default: {
...ReactDOMServer.default,
renderToString() {
throw new Error(ERROR_MESSAGE);
},
renderToStaticMarkup() {
throw new Error(ERROR_MESSAGE);
},
},
renderToString() {
throw new Error(ERROR_MESSAGE);
},
renderToStaticMarkup() {
throw new Error(ERROR_MESSAGE);
},
};
});

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 renderAsync(<Preview />, {
plainText: true,
});
const actualOutput = await renderAsync(<Template firstName="Jim" />);

expect(actualOutput).toMatchInlineSnapshot(
`"THIS SHOULD BE RENDERED IN PLAIN TEXT"`,
'"<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Transitional//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\\"><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>"',
);

vi.resetAllMocks();
});
});

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

Expand Down
74 changes: 74 additions & 0 deletions packages/render/src/render-async-node.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* @vitest-environment node
*/

import { Template } from "./utils/template";
import { Preview } from "./utils/preview";
import { renderAsync } from "./render-async";

describe("renderAsync on node environments", () => {
it("converts a React component into HTML with Next 14 error stubs", async () => {
vi.mock("react-dom/server", async (_importOriginal) => {
const ReactDOMServer = await import("react-dom/server");
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 {
...ReactDOMServer,
default: {
...ReactDOMServer.default,
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 renderAsync(<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\\"><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>"',
);

vi.resetAllMocks();
});

it("converts a React component into HTML", async () => {
const actualOutput = await renderAsync(<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\\"><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 renderAsync(<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 renderAsync(<Preview />, {
plainText: true,
});

expect(actualOutput).toMatchInlineSnapshot(
`"THIS SHOULD BE RENDERED IN PLAIN TEXT"`,
);
});
});
89 changes: 89 additions & 0 deletions packages/render/src/render-async-web.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* @vitest-environment jsdom
*/

import { Template } from "./utils/template";
import { Preview } from "./utils/preview";
import { renderAsync } from "./render-async";

describe("renderAsync on the browser environment", () => {
beforeEach(() => {
vi.mock(
"react-dom/server",
// @ts-expect-error no type defs is an error thrown here that doesn't really matter
(_importOriginal) => import("react-dom/server.browser"),
);
// need this mock because this is the file that is exported as the browser
// version of react-dom/server this is necessary which is the only one that has
// renderToReadableStream implemented
});

it("converts a React component into HTML with Next 14 error stubs", async () => {
vi.mock("react-dom/server", async (_importOriginal) => {
const ReactDOMServerBrowser = await import("react-dom/server");
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.default,
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 renderAsync(<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\\"><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>"',
);

vi.resetAllMocks();
});

afterEach(() => {
vi.resetAllMocks();
});

it("converts a React component into HTML", async () => {
const actualOutput = await renderAsync(<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\\"><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 renderAsync(<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 renderAsync(<Preview />, {
plainText: true,
});

expect(actualOutput).toMatchInlineSnapshot(
`"THIS SHOULD BE RENDERED IN PLAIN TEXT"`,
);
});
});
50 changes: 29 additions & 21 deletions packages/render/src/render-async.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,35 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
import { convert } from "html-to-text";
import type { ReactDOMServerReadableStream } from "react-dom/server";

import { pretty } from "./utils/pretty";

const readStream = async (readableStream: ReactDOMServerReadableStream) => {
const reader = readableStream.getReader();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chunks: any[] = [];
const decoder = new TextDecoder("utf-8");

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

// 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;
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);
}
} else {
for await (const chunk of readableStream) {
result += decoder.decode(Buffer.from(chunk));
}
chunks.push(value);
}

// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
return chunks.map((chunk) => new TextDecoder("utf-8").decode(chunk)).join("");
return result;
};

export const renderAsync = async (
Expand All @@ -32,18 +41,17 @@ export const renderAsync = async (
) => {
const reactDOMServer = (await import("react-dom/server")).default;
const renderToStream =
reactDOMServer.renderToReadableStream ||
reactDOMServer.renderToString ||
reactDOMServer.renderToPipeableStream;
reactDOMServer.renderToReadableStream ??
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 readableStream = await renderToStream(component);
const htmlOrReadableStream = await renderToStream(component);
const html =
typeof readableStream === "string"
? readableStream
: await readStream(readableStream);
typeof htmlOrReadableStream === "string"
? htmlOrReadableStream
: await readStream(htmlOrReadableStream);

if (options?.plainText) {
return convert(html, {
Expand Down
7 changes: 4 additions & 3 deletions packages/tailwind/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"license": "MIT",
"scripts": {
"build": "tsc && vite build",
"dev": "vite build --watch",
"clean": "rm -rf dist",
"lint": "eslint .",
"test:watch": "vitest --config ../../vitest.config.ts",
Expand All @@ -43,8 +44,7 @@
"node": ">=18.0.0"
},
"dependencies": {
"react": "18.2.0",
"react-dom": "18.2.0"
"react": "18.2.0"
},
"peerDependencies": {
"react": "18.2.0"
Expand All @@ -64,10 +64,11 @@
"postcss": "8.4.31",
"postcss-css-variables": "0.19.0",
"process": "^0.11.10",
"react-dom": "18.2.0",
"tailwindcss": "3.3.2",
"tsconfig": "workspace:*",
"tsup": "7.2.0",
"typescript": "5.1.6",
"tsconfig": "workspace:*",
"vite": "4.0.0",
"vite-plugin-dts": "3.6.3",
"vite-plugin-node-polyfills": "0.17.0"
Expand Down
Loading