From 66ed3219b1307e652c41ccff6e4bb0a59d1d043d Mon Sep 17 00:00:00 2001 From: gabriel miranda Date: Wed, 7 May 2025 10:36:54 -0300 Subject: [PATCH 01/13] feat(render): Use `react-dom/server.edge` in a new `edge-light` export --- packages/render/package.json | 10 ++ packages/render/src/browser/render.tsx | 45 +------ packages/render/src/edge-light/index.ts | 14 ++ .../render/src/edge-light/render.spec.tsx | 120 ++++++++++++++++++ packages/render/src/edge-light/render.tsx | 55 ++++++++ packages/render/src/react-internals.d.ts | 3 + .../read-stream.browser.ts} | 2 +- packages/render/tsup.config.ts | 6 + 8 files changed, 210 insertions(+), 45 deletions(-) create mode 100644 packages/render/src/edge-light/index.ts create mode 100644 packages/render/src/edge-light/render.spec.tsx create mode 100644 packages/render/src/edge-light/render.tsx rename packages/render/src/{browser/read-stream.ts => shared/read-stream.browser.ts} (93%) diff --git a/packages/render/package.json b/packages/render/package.json index ed821c9f6b..d460f2e808 100644 --- a/packages/render/package.json +++ b/packages/render/package.json @@ -51,6 +51,16 @@ "default": "./dist/browser/index.js" } }, + "edge-light": { + "import": { + "types": "./dist/edge-light/index.d.mts", + "default": "./dist/edge-light/index.mjs" + }, + "require": { + "types": "./dist/edge-light/index.d.ts", + "default": "./dist/edge-light/index.js" + } + }, "default": { "import": { "types": "./dist/node/index.d.mts", diff --git a/packages/render/src/browser/render.tsx b/packages/render/src/browser/render.tsx index 9beb4cb3df..d0e7b0ed3d 100644 --- a/packages/render/src/browser/render.tsx +++ b/packages/render/src/browser/render.tsx @@ -1,52 +1,9 @@ import { convert } from 'html-to-text'; import { Suspense } from 'react'; -import type { - PipeableStream, - ReactDOMServerReadableStream, -} from 'react-dom/server'; import { pretty } from '../node'; import type { Options } from '../shared/options'; import { plainTextSelectors } from '../shared/plain-text-selectors'; - -const decoder = new TextDecoder('utf-8'); - -const readStream = async ( - stream: PipeableStream | ReactDOMServerReadableStream, -) => { - const chunks: Uint8Array[] = []; - - if ('pipeTo' in stream) { - // means it's a readable stream - const writableStream = new WritableStream({ - write(chunk: Uint8Array) { - chunks.push(chunk); - }, - }); - await stream.pipeTo(writableStream); - } else { - throw new Error( - 'For some reason, the Node version of `react-dom/server` has been imported instead of the browser one.', - { - cause: { - stream, - }, - }, - ); - } - - let length = 0; - chunks.forEach((item) => { - length += item.length; - }); - const mergedChunks = new Uint8Array(length); - let offset = 0; - chunks.forEach((item) => { - mergedChunks.set(item, offset); - offset += item.length; - }); - - return decoder.decode(mergedChunks); -}; +import { readStream } from '../shared/read-stream.browser'; export const render = async (node: React.ReactNode, options?: Options) => { const suspendedElement = {node}; diff --git a/packages/render/src/edge-light/index.ts b/packages/render/src/edge-light/index.ts new file mode 100644 index 0000000000..3d930a1cf7 --- /dev/null +++ b/packages/render/src/edge-light/index.ts @@ -0,0 +1,14 @@ +import type { Options } from '../shared/options'; +import { render } from './render'; + +/** + * @deprecated use {@link render} + */ +export const renderAsync = (element: React.ReactElement, options?: Options) => { + return render(element, options); +}; + +export * from '../shared/options'; +export * from '../shared/plain-text-selectors'; +export * from '../shared/utils/pretty'; +export * from './render'; diff --git a/packages/render/src/edge-light/render.spec.tsx b/packages/render/src/edge-light/render.spec.tsx new file mode 100644 index 0000000000..a907d70114 --- /dev/null +++ b/packages/render/src/edge-light/render.spec.tsx @@ -0,0 +1,120 @@ +import { Preview } from '../shared/utils/preview'; +import { Template } from '../shared/utils/template'; +import { render } from './render'; + +type Import = typeof import('react-dom/server') & { + default: typeof import('react-dom/server'); +}; + +describe('render on the edge', () => { + beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-extraneous-class + global.MessageChannel = class { + constructor() { + throw new Error('MessageChannel is not supported'); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + }); + + it('converts a React component into HTML with Next 14 error stubs', async () => { + vi.mock('react-dom/server', async () => { + const ReactDOMServer = await vi.importActual('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 render(