From 3d4fec978822fad1275f8fe41a2d8664ebe84560 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 19 Jun 2024 15:15:42 +0200 Subject: [PATCH 1/8] Add react-html package skeleton --- packages/react-html/README.md | 32 +++++++++++++++++ packages/react-html/index.js | 5 +++ packages/react-html/npm/index.js | 5 +++ .../react-html/npm/react-html.react-server.js | 7 ++++ packages/react-html/package.json | 36 +++++++++++++++++++ .../react-html/react-html.react-server.js | 10 ++++++ packages/react-html/src/ReactHTMLServer.js | 25 +++++++++++++ .../src/__tests__/ReactHTMLServer-test.js | 36 +++++++++++++++++++ scripts/error-codes/codes.json | 3 +- 9 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 packages/react-html/README.md create mode 100644 packages/react-html/index.js create mode 100644 packages/react-html/npm/index.js create mode 100644 packages/react-html/npm/react-html.react-server.js create mode 100644 packages/react-html/package.json create mode 100644 packages/react-html/react-html.react-server.js create mode 100644 packages/react-html/src/ReactHTMLServer.js create mode 100644 packages/react-html/src/__tests__/ReactHTMLServer-test.js diff --git a/packages/react-html/README.md b/packages/react-html/README.md new file mode 100644 index 0000000000000..a4acd1fd84396 --- /dev/null +++ b/packages/react-html/README.md @@ -0,0 +1,32 @@ +# `react-html` + +This package provides the ability to render standalone HTML from Server Components for use in embedded contexts such as e-mails and RSS/Atom feeds. It cannot use Client Components and aren't hydrated. It is intended to be paired with the generic React package, which is shipped as `react` to npm. + +## Installation + +```sh +npm install react react-html +``` + +## Usage + +```js +import { renderToMarkup } from 'react-html'; +import EmailTemplate from './my-email-template-component.js' + +async function action(email, name) { + "use server"; + // ... in your server, e.g. a Server Action... + const htmlString = await renderToMarkup(); + // ... send e-mail using some e-mail provider + await sendEmail({ to: email, contentType: 'text/html', body: htmlString }); +} +``` + +Note that this is an async function that need to be awaited - unlike the legacy `renderToString` in `react-dom`. + +## API + +### `react-html` + +See https://react.dev/reference/react-html diff --git a/packages/react-html/index.js b/packages/react-html/index.js new file mode 100644 index 0000000000000..a1818e8c3bba6 --- /dev/null +++ b/packages/react-html/index.js @@ -0,0 +1,5 @@ +'use strict'; + +throw new Error( + 'react-html is not supported outside a React Server Components environment.', +); diff --git a/packages/react-html/npm/index.js b/packages/react-html/npm/index.js new file mode 100644 index 0000000000000..e567bb2c0aa21 --- /dev/null +++ b/packages/react-html/npm/index.js @@ -0,0 +1,5 @@ +'use strict'; + +throw new Error( + 'react-html is not supported outside a React Server Components environment.' +); diff --git a/packages/react-html/npm/react-html.react-server.js b/packages/react-html/npm/react-html.react-server.js new file mode 100644 index 0000000000000..be2af1e0c5838 --- /dev/null +++ b/packages/react-html/npm/react-html.react-server.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/react-html.react-server.production.js'); +} else { + module.exports = require('./cjs/react-html.react-server.development.js'); +} diff --git a/packages/react-html/package.json b/packages/react-html/package.json new file mode 100644 index 0000000000000..e175945ecf8fb --- /dev/null +++ b/packages/react-html/package.json @@ -0,0 +1,36 @@ +{ + "name": "react-html", + "version": "19.0.0", + "private": true, + "description": "React package generating embedded HTML markup such as e-mails using Server Components.", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/facebook/react.git", + "directory": "packages/react-html" + }, + "keywords": [ + "react" + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/facebook/react/issues" + }, + "homepage": "https://react.dev/", + "peerDependencies": { + "react": "^19.0.0" + }, + "files": [ + "LICENSE", + "README.md", + "index.js", + "react-html.react-server.js", + "cjs/" + ], + "exports": { + ".": { + "react-server": "./react-dom.react-server.js", + "default": "./index.js" + } + } +} diff --git a/packages/react-html/react-html.react-server.js b/packages/react-html/react-html.react-server.js new file mode 100644 index 0000000000000..fb30cd4200836 --- /dev/null +++ b/packages/react-html/react-html.react-server.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export * from './src/ReactHTMLServer'; diff --git a/packages/react-html/src/ReactHTMLServer.js b/packages/react-html/src/ReactHTMLServer.js new file mode 100644 index 0000000000000..09a179a612f30 --- /dev/null +++ b/packages/react-html/src/ReactHTMLServer.js @@ -0,0 +1,25 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ReactNodeList} from 'shared/ReactTypes'; + +import ReactVersion from 'shared/ReactVersion'; + +type MarkupOptions = { + identifierPrefix?: string, +}; + +export function renderToMarkup( + children: ReactNodeList, + options?: MarkupOptions, +): Promise { + return Promise.resolve('hi'); +} + +export {ReactVersion as version}; diff --git a/packages/react-html/src/__tests__/ReactHTMLServer-test.js b/packages/react-html/src/__tests__/ReactHTMLServer-test.js new file mode 100644 index 0000000000000..b1d2404e09d90 --- /dev/null +++ b/packages/react-html/src/__tests__/ReactHTMLServer-test.js @@ -0,0 +1,36 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +let React; +let ReactHTML; + +describe('ReactHTML', () => { + beforeEach(() => { + jest.resetModules(); + // We run in the react-server condition. + jest.mock('react', () => require('react/react.react-server')); + jest.mock('react-html', () => + require('react-html/react-html.react-server'), + ); + + React = require('react'); + ReactHTML = require('react-html'); + }); + + it('should be able to render a simple component', async () => { + function Component() { + return
hello world
; + } + + const html = await ReactHTML.renderToMarkup(); + expect(html).toBe('hi'); + }); +}); diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index ef4ae75a6d634..63c0e7061e882 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -515,5 +515,6 @@ "527": "Incompatible React versions: The \"react\" and \"react-dom\" packages must have the exact same version. Instead got:\n - react: %s\n - react-dom: %s\nLearn more: https://react.dev/warnings/version-mismatch", "528": "Expected not to update to be updated to a stylesheet with precedence. Check the `rel`, `href`, and `precedence` props of this component. Alternatively, check whether two different components render in the same slot or share the same key.%s", "529": "Expected stylesheet with precedence to not be updated to a different kind of . Check the `rel`, `href`, and `precedence` props of this component. Alternatively, check whether two different components render in the same slot or share the same key.%s", - "530": "The render was aborted by the server with a promise." + "530": "The render was aborted by the server with a promise.", + "531": "react-html is not supported outside a React Server Components environment." } From a7df40bc6f5de3f2e63dd8cda0e1d34a453586fa Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 19 Jun 2024 20:02:22 +0200 Subject: [PATCH 2/8] Implement renderMarkup as a Fizz renderer --- packages/react-html/src/ReactHTMLServer.js | 80 ++++++++++++++++++- .../src/__tests__/ReactHTMLServer-test.js | 2 +- scripts/shared/inlinedHostConfigs.js | 2 + 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/packages/react-html/src/ReactHTMLServer.js b/packages/react-html/src/ReactHTMLServer.js index 09a179a612f30..440f7ae337d0e 100644 --- a/packages/react-html/src/ReactHTMLServer.js +++ b/packages/react-html/src/ReactHTMLServer.js @@ -9,17 +9,95 @@ import type {ReactNodeList} from 'shared/ReactTypes'; +import type { + Request, + PostponedState, + ErrorInfo, +} from 'react-server/src/ReactFizzServer'; + import ReactVersion from 'shared/ReactVersion'; +import { + createRequest, + startWork, + startFlowing, + abort, +} from 'react-server/src/ReactFizzServer'; + +import { + createResumableState, + createRenderState, + createRootFormatContext, +} from 'react-dom-bindings/src/server/ReactFizzConfigDOMLegacy'; + type MarkupOptions = { identifierPrefix?: string, + signal?: AbortSignal, }; export function renderToMarkup( children: ReactNodeList, options?: MarkupOptions, ): Promise { - return Promise.resolve('hi'); + return new Promise((resolve, reject) => { + let didFatal = false; + let fatalError = null; + let buffer = ''; + const destination = { + // $FlowFixMe[missing-local-annot] + push(chunk) { + if (chunk !== null) { + buffer += chunk; + } else { + // null indicates that we finished + resolve(buffer); + } + return true; + }, + // $FlowFixMe[missing-local-annot] + destroy(error) { + reject(error); + }, + }; + function onError(error: mixed) { + // Any error rejects the promise, regardless of where it happened. + // Unlike other React SSR we don't want to put Suspense boundaries into + // client rendering mode because there's no client rendering here. + reject(error); + } + const resumableState = createResumableState( + options ? options.identifierPrefix : undefined, + undefined, + ); + const request = createRequest( + children, + resumableState, + createRenderState(resumableState, true), + createRootFormatContext(), + Infinity, + onError, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abort(request, (signal: any).reason); + } else { + const listener = () => { + abort(request, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + startWork(request); + startFlowing(request, destination); + }); } export {ReactVersion as version}; diff --git a/packages/react-html/src/__tests__/ReactHTMLServer-test.js b/packages/react-html/src/__tests__/ReactHTMLServer-test.js index b1d2404e09d90..592162663308e 100644 --- a/packages/react-html/src/__tests__/ReactHTMLServer-test.js +++ b/packages/react-html/src/__tests__/ReactHTMLServer-test.js @@ -31,6 +31,6 @@ describe('ReactHTML', () => { } const html = await ReactHTML.renderToMarkup(); - expect(html).toBe('hi'); + expect(html).toBe('
hello world
'); }); }); diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index a63da44c36f29..d138e1acc8568 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -395,6 +395,7 @@ module.exports = [ entryPoints: [ 'react-dom/src/server/ReactDOMLegacyServerBrowser.js', // react-dom/server.browser 'react-dom/src/server/ReactDOMLegacyServerNode.js', // react-dom/server.node + 'react-html/react-html.react-server.js', ], paths: [ 'react-dom', @@ -404,6 +405,7 @@ module.exports = [ 'react-dom/src/server/ReactDOMLegacyServerImpl.js', // not an entrypoint, but only usable in *Browser and *Node files 'react-dom/src/server/ReactDOMLegacyServerBrowser.js', // react-dom/server.browser 'react-dom/src/server/ReactDOMLegacyServerNode.js', // react-dom/server.node + 'react-html', 'shared/ReactDOMSharedInternals', ], isFlowTyped: true, From 4def38f98d24e165c8e6e0802d815e23d36a4d29 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 20 Jun 2024 10:20:15 +0200 Subject: [PATCH 3/8] Add bundle --- packages/react-html/package.json | 4 +++- scripts/rollup/bundles.js | 13 +++++++++++++ scripts/rollup/forks.js | 9 ++++++++- scripts/shared/inlinedHostConfigs.js | 2 +- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/react-html/package.json b/packages/react-html/package.json index e175945ecf8fb..c9c4205304b53 100644 --- a/packages/react-html/package.json +++ b/packages/react-html/package.json @@ -31,6 +31,8 @@ ".": { "react-server": "./react-dom.react-server.js", "default": "./index.js" - } + }, + "./src/*": "./src/*", + "./package.json": "./package.json" } } diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 66e59124ca2d5..67f1c237b209f 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -363,6 +363,19 @@ const bundles = [ externals: [], }, + /******* React HTML *******/ + { + bundleTypes: [NODE_DEV, NODE_PROD], + moduleType: RENDERER, + entry: 'react-html/src/ReactHTMLServer.js', + name: 'react-html.react-server', + condition: 'react-server', + global: 'ReactHTML', + minifyWithProdErrorCodes: false, + wrapWithModuleBoundaries: false, + externals: ['react'], + }, + /******* React Server DOM Webpack Server *******/ { bundleTypes: [NODE_DEV, NODE_PROD], diff --git a/scripts/rollup/forks.js b/scripts/rollup/forks.js index a83b8b1b1a969..0ba6827bba7a2 100644 --- a/scripts/rollup/forks.js +++ b/scripts/rollup/forks.js @@ -65,6 +65,12 @@ const forks = Object.freeze({ if (entry === 'react/src/ReactServer.js') { return './packages/react/src/ReactSharedInternalsServer.js'; } + if (entry === 'react-html/src/ReactHTMLServer.js') { + // Inside the ReactHTMLServer render we don't refer to any shared internals + // but instead use our own internal copy of the state because you cannot use + // any of this state from a component anyway. E.g. you can't use a client hook. + return './packages/react/src/ReactSharedInternalsClient.js'; + } if (bundle.condition === 'react-server') { return './packages/react-server/src/ReactSharedInternalsServer.js'; } @@ -93,7 +99,8 @@ const forks = Object.freeze({ entry === 'react-dom' || entry === 'react-dom/src/ReactDOMFB.js' || entry === 'react-dom/src/ReactDOMTestingFB.js' || - entry === 'react-dom/src/ReactDOMServer.js' + entry === 'react-dom/src/ReactDOMServer.js' || + entry === 'react-html/src/ReactHTMLServer.js' ) { if ( bundleType === FB_WWW_DEV || diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index d138e1acc8568..d9c21ad480a4a 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -395,7 +395,7 @@ module.exports = [ entryPoints: [ 'react-dom/src/server/ReactDOMLegacyServerBrowser.js', // react-dom/server.browser 'react-dom/src/server/ReactDOMLegacyServerNode.js', // react-dom/server.node - 'react-html/react-html.react-server.js', + 'react-html/src/ReactHTMLServer.js', ], paths: [ 'react-dom', From 23a7c2dc3d59b84e8576732ac64bb310ccf29a9b Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 20 Jun 2024 11:50:53 +0200 Subject: [PATCH 4/8] Tests We cannot use JSX because we need to use the react-server runtime. --- .../src/__tests__/ReactHTMLServer-test.js | 77 ++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/react-html/src/__tests__/ReactHTMLServer-test.js b/packages/react-html/src/__tests__/ReactHTMLServer-test.js index 592162663308e..8d74e38f24940 100644 --- a/packages/react-html/src/__tests__/ReactHTMLServer-test.js +++ b/packages/react-html/src/__tests__/ReactHTMLServer-test.js @@ -27,10 +27,83 @@ describe('ReactHTML', () => { it('should be able to render a simple component', async () => { function Component() { - return
hello world
; + // We can't use JSX because that's client-JSX in our tests. + return React.createElement('div', null, 'hello world'); } - const html = await ReactHTML.renderToMarkup(); + const html = await ReactHTML.renderToMarkup(React.createElement(Component)); expect(html).toBe('
hello world
'); }); + + it('supports the useId Hook', async () => { + function Component() { + const firstNameId = React.useId(); + const lastNameId = React.useId(); + // We can't use JSX because that's client-JSX in our tests. + return React.createElement( + 'div', + null, + React.createElement( + 'h2', + { + id: firstNameId, + }, + 'First', + ), + React.createElement( + 'p', + { + 'aria-labelledby': firstNameId, + }, + 'Sebastian', + ), + React.createElement( + 'h2', + { + id: lastNameId, + }, + 'Last', + ), + React.createElement( + 'p', + { + 'aria-labelledby': lastNameId, + }, + 'Smith', + ), + ); + } + + const html = await ReactHTML.renderToMarkup(React.createElement(Component)); + const container = document.createElement('div'); + container.innerHTML = html; + + expect(container.getElementsByTagName('h2')[0].id).toBe( + container.getElementsByTagName('p')[0].getAttribute('aria-labelledby'), + ); + expect(container.getElementsByTagName('h2')[1].id).toBe( + container.getElementsByTagName('p')[1].getAttribute('aria-labelledby'), + ); + + // It's not the same id between them. + expect(container.getElementsByTagName('h2')[0].id).not.toBe( + container.getElementsByTagName('p')[1].getAttribute('aria-labelledby'), + ); + }); + + // @gate enableCache + it('supports cache', async () => { + let counter = 0; + const getCount = React.cache(() => { + return counter++; + }); + function Component() { + const a = getCount(); + const b = getCount(); + return React.createElement('div', null, a, b); + } + + const html = await ReactHTML.renderToMarkup(React.createElement(Component)); + expect(html).toBe('
00
'); + }); }); From 5a7d35a3894c2ca35f57309e1fdff850424e234b Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 20 Jun 2024 17:59:23 +0200 Subject: [PATCH 5/8] Configure Flight rendering We render into an RSC payload using FlightServer, then parse it with FlightClient and then render the result using Fizz. --- .../forks/ReactFlightClientConfig.dom-bun.js | 1 - .../ReactFlightClientConfig.dom-legacy.js | 92 +++++++++++--- .../src/ReactHTMLLegacyClientStreamConfig.js | 32 +++++ packages/react-html/src/ReactHTMLServer.js | 115 +++++++++++++++--- .../src/__tests__/ReactHTMLServer-test.js | 3 + .../ReactFlightServerConfig.dom-legacy.js | 75 +++++++++++- scripts/error-codes/codes.json | 6 +- scripts/shared/inlinedHostConfigs.js | 33 +++++ 8 files changed, 318 insertions(+), 39 deletions(-) create mode 100644 packages/react-html/src/ReactHTMLLegacyClientStreamConfig.js diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js index 461996a2e0887..0a8027e3e12aa 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js @@ -11,7 +11,6 @@ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; export * from 'react-client/src/ReactClientConsoleConfigPlain'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; -export type Response = any; export opaque type ModuleLoading = mixed; export opaque type SSRModuleMap = mixed; export opaque type ServerManifest = mixed; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js index ddf6440a20e48..8e91e3a8062fd 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js @@ -7,20 +7,82 @@ * @flow */ -export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; -export * from 'react-client/src/ReactClientConsoleConfigBrowser'; -export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; - -export type Response = any; -export opaque type ModuleLoading = mixed; -export opaque type SSRModuleMap = mixed; -export opaque type ServerManifest = mixed; +import type {Thenable} from 'shared/ReactTypes'; + +export * from 'react-html/src/ReactHTMLLegacyClientStreamConfig.js'; +export * from 'react-client/src/ReactClientConsoleConfigPlain'; + +export type ModuleLoading = null; +export type SSRModuleMap = null; +export opaque type ServerManifest = null; export opaque type ServerReferenceId = string; -export opaque type ClientReferenceMetadata = mixed; -export opaque type ClientReference = mixed; // eslint-disable-line no-unused-vars -export const resolveClientReference: any = null; -export const resolveServerReference: any = null; -export const preloadModule: any = null; -export const requireModule: any = null; -export const prepareDestinationForModule: any = null; +export opaque type ClientReferenceMetadata = null; +export opaque type ClientReference = null; // eslint-disable-line no-unused-vars + +export function prepareDestinationForModule( + moduleLoading: ModuleLoading, + nonce: ?string, + metadata: ClientReferenceMetadata, +) { + throw new Error( + 'renderToMarkup should not have emitted Client References. This is a bug in React.', + ); +} + +export function resolveClientReference( + bundlerConfig: SSRModuleMap, + metadata: ClientReferenceMetadata, +): ClientReference { + throw new Error( + 'renderToMarkup should not have emitted Client References. This is a bug in React.', + ); +} + +export function resolveServerReference( + config: ServerManifest, + id: ServerReferenceId, +): ClientReference { + throw new Error( + 'renderToMarkup should not have emitted Server References. This is a bug in React.', + ); +} + +export function preloadModule( + metadata: ClientReference, +): null | Thenable { + return null; +} + +export function requireModule(metadata: ClientReference): T { + throw new Error( + 'renderToMarkup should not have emitted Client References. This is a bug in React.', + ); +} + export const usedWithSSR = true; + +type HintCode = string; +type HintModel = null; // eslint-disable-line no-unused-vars + +export function dispatchHint( + code: Code, + model: HintModel, +): void { + // Should never happen. +} + +export function preinitModuleForSSR( + href: string, + nonce: ?string, + crossOrigin: ?string, +) { + // Should never happen. +} + +export function preinitScriptForSSR( + href: string, + nonce: ?string, + crossOrigin: ?string, +) { + // Should never happen. +} diff --git a/packages/react-html/src/ReactHTMLLegacyClientStreamConfig.js b/packages/react-html/src/ReactHTMLLegacyClientStreamConfig.js new file mode 100644 index 0000000000000..74b0503590462 --- /dev/null +++ b/packages/react-html/src/ReactHTMLLegacyClientStreamConfig.js @@ -0,0 +1,32 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// TODO: The legacy one should not use binary. + +export type StringDecoder = TextDecoder; + +export function createStringDecoder(): StringDecoder { + return new TextDecoder(); +} + +const decoderOptions = {stream: true}; + +export function readPartialStringChunk( + decoder: StringDecoder, + buffer: Uint8Array, +): string { + return decoder.decode(buffer, decoderOptions); +} + +export function readFinalStringChunk( + decoder: StringDecoder, + buffer: Uint8Array, +): string { + return decoder.decode(buffer); +} diff --git a/packages/react-html/src/ReactHTMLServer.js b/packages/react-html/src/ReactHTMLServer.js index 440f7ae337d0e..c4eebe3f51054 100644 --- a/packages/react-html/src/ReactHTMLServer.js +++ b/packages/react-html/src/ReactHTMLServer.js @@ -8,20 +8,29 @@ */ import type {ReactNodeList} from 'shared/ReactTypes'; - -import type { - Request, - PostponedState, - ErrorInfo, -} from 'react-server/src/ReactFizzServer'; +import type {LazyComponent} from 'react/src/ReactLazy'; import ReactVersion from 'shared/ReactVersion'; import { - createRequest, - startWork, - startFlowing, - abort, + createRequest as createFlightRequest, + startWork as startFlightWork, + startFlowing as startFlightFlowing, + abort as abortFlight, +} from 'react-server/src/ReactFlightServer'; + +import { + createResponse as createFlightResponse, + getRoot as getFlightRoot, + processBinaryChunk as processFlightBinaryChunk, + close as closeFlight, +} from 'react-client/src/ReactFlightClient'; + +import { + createRequest as createFizzRequest, + startWork as startFizzWork, + startFlowing as startFizzFlowing, + abort as abortFizz, } from 'react-server/src/ReactFizzServer'; import { @@ -30,20 +39,60 @@ import { createRootFormatContext, } from 'react-dom-bindings/src/server/ReactFizzConfigDOMLegacy'; +type ReactMarkupNodeList = + // This is the intersection of ReactNodeList and ReactClientValue minus + // Client/ServerReferences. + | React$Element> + | LazyComponent + | React$Element + | string + | boolean + | number + | symbol + | null + | void + | bigint + | $AsyncIterable + | $AsyncIterator + | Iterable + | Iterator + | Array + | Promise; // Thenable + type MarkupOptions = { identifierPrefix?: string, signal?: AbortSignal, }; +function noServerCallOrFormAction() { + throw new Error( + 'renderToMarkup should not have emitted Server References. This is a bug in React.', + ); +} + export function renderToMarkup( - children: ReactNodeList, + children: ReactMarkupNodeList, options?: MarkupOptions, ): Promise { return new Promise((resolve, reject) => { - let didFatal = false; - let fatalError = null; + const textEncoder = new TextEncoder(); + const flightDestination = { + push(chunk: string | null): boolean { + if (chunk !== null) { + // TODO: Legacy should not use binary streams. + processFlightBinaryChunk(flightResponse, textEncoder.encode(chunk)); + } else { + closeFlight(flightResponse); + } + return true; + }, + destroy(error: mixed): void { + abortFizz(fizzRequest, error); + reject(error); + }, + }; let buffer = ''; - const destination = { + const fizzDestination = { // $FlowFixMe[missing-local-annot] push(chunk) { if (chunk !== null) { @@ -56,6 +105,7 @@ export function renderToMarkup( }, // $FlowFixMe[missing-local-annot] destroy(error) { + abortFlight(flightRequest, error); reject(error); }, }; @@ -65,12 +115,33 @@ export function renderToMarkup( // client rendering mode because there's no client rendering here. reject(error); } + const flightRequest = createFlightRequest( + // $FlowFixMe: This should be a subtype but not everything is typed covariant. + children, + null, + onError, + options ? options.identifierPrefix : undefined, + undefined, + 'Markup', + undefined, + ); + const flightResponse = createFlightResponse( + null, + null, + noServerCallOrFormAction, + noServerCallOrFormAction, + undefined, + undefined, + undefined, + ); const resumableState = createResumableState( options ? options.identifierPrefix : undefined, undefined, ); - const request = createRequest( - children, + const root = getFlightRoot(flightResponse); + const fizzRequest = createFizzRequest( + // $FlowFixMe: Thenables as children are supported. + root, resumableState, createRenderState(resumableState, true), createRootFormatContext(), @@ -86,17 +157,21 @@ export function renderToMarkup( if (options && options.signal) { const signal = options.signal; if (signal.aborted) { - abort(request, (signal: any).reason); + abortFlight(flightRequest, (signal: any).reason); + abortFizz(fizzRequest, (signal: any).reason); } else { const listener = () => { - abort(request, (signal: any).reason); + abortFlight(flightRequest, (signal: any).reason); + abortFizz(fizzRequest, (signal: any).reason); signal.removeEventListener('abort', listener); }; signal.addEventListener('abort', listener); } } - startWork(request); - startFlowing(request, destination); + startFlightWork(flightRequest); + startFlightFlowing(flightRequest, flightDestination); + startFizzWork(fizzRequest); + startFizzFlowing(fizzRequest, fizzDestination); }); } diff --git a/packages/react-html/src/__tests__/ReactHTMLServer-test.js b/packages/react-html/src/__tests__/ReactHTMLServer-test.js index 8d74e38f24940..503e233301c9a 100644 --- a/packages/react-html/src/__tests__/ReactHTMLServer-test.js +++ b/packages/react-html/src/__tests__/ReactHTMLServer-test.js @@ -9,6 +9,9 @@ 'use strict'; +global.TextDecoder = require('util').TextDecoder; +global.TextEncoder = require('util').TextEncoder; + let React; let ReactHTML; diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js index bfe794c9a2c55..0c0d5113d994c 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js @@ -9,9 +9,19 @@ import type {Request} from 'react-server/src/ReactFlightServer'; import type {ReactComponentInfo} from 'shared/ReactTypes'; +import type {ReactClientValue} from 'react-server/src/ReactFlightServer'; -export * from '../ReactFlightServerConfigBundlerCustom'; -export * from 'react-dom-bindings/src/server/ReactFlightServerConfigDOM'; +// Used to distinguish these contexts from ones used in other renderers. +// E.g. this can be used to distinguish legacy renderers from this modern one. +export const isPrimaryRenderer = true; + +export type HintCode = string; +export type HintModel = null; // eslint-disable-line no-unused-vars +export type Hints = null; + +export function createHints(): Hints { + return null; +} export const supportsRequestStorage = false; export const requestStorage: AsyncLocalStorage = (null: any); @@ -21,3 +31,64 @@ export const componentStorage: AsyncLocalStorage = (null: any); export * from '../ReactFlightServerConfigDebugNoop'; + +export type ClientManifest = null; +export opaque type ClientReference = null; // eslint-disable-line no-unused-vars +export opaque type ServerReference = null; // eslint-disable-line no-unused-vars +export opaque type ClientReferenceMetadata: any = null; +export opaque type ServerReferenceId: string = string; +export opaque type ClientReferenceKey: any = string; + +const CLIENT_REFERENCE_TAG = Symbol.for('react.client.reference'); +const SERVER_REFERENCE_TAG = Symbol.for('react.server.reference'); + +export function isClientReference(reference: Object): boolean { + return reference.$$typeof === CLIENT_REFERENCE_TAG; +} + +export function isServerReference(reference: Object): boolean { + return reference.$$typeof === SERVER_REFERENCE_TAG; +} + +export function getClientReferenceKey( + reference: ClientReference, +): ClientReferenceKey { + throw new Error( + 'Attempted to render a Client Component from renderToMarkup. ' + + 'This is not supported since it will never hydrate. ' + + 'Only render Server Components with renderToMarkup.', + ); +} + +export function resolveClientReferenceMetadata( + config: ClientManifest, + clientReference: ClientReference, +): ClientReferenceMetadata { + throw new Error( + 'Attempted to render a Client Component from renderToMarkup. ' + + 'This is not supported since it will never hydrate. ' + + 'Only render Server Components with renderToMarkup.', + ); +} + +export function getServerReferenceId( + config: ClientManifest, + serverReference: ServerReference, +): ServerReferenceId { + throw new Error( + 'Attempted to render a Server Action from renderToMarkup. ' + + 'This is not supported since it varies by version of the app. ' + + 'Use a fixed URL for any forms instead.', + ); +} + +export function getServerReferenceBoundArguments( + config: ClientManifest, + serverReference: ServerReference, +): null | Array { + throw new Error( + 'Attempted to render a Server Action from renderToMarkup. ' + + 'This is not supported since it varies by version of the app. ' + + 'Use a fixed URL for any forms instead.', + ); +} diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 63c0e7061e882..9d8fb4ed3739f 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -516,5 +516,9 @@ "528": "Expected not to update to be updated to a stylesheet with precedence. Check the `rel`, `href`, and `precedence` props of this component. Alternatively, check whether two different components render in the same slot or share the same key.%s", "529": "Expected stylesheet with precedence to not be updated to a different kind of . Check the `rel`, `href`, and `precedence` props of this component. Alternatively, check whether two different components render in the same slot or share the same key.%s", "530": "The render was aborted by the server with a promise.", - "531": "react-html is not supported outside a React Server Components environment." + "531": "react-html is not supported outside a React Server Components environment.", + "532": "Attempted to render a Client Component from renderToMarkup. This is not supported since it will never hydrate. Only render Server Components with renderToMarkup.", + "533": "Attempted to render a Server Action from renderToMarkup. This is not supported since it varies by version of the app. Use a fixed URL for any forms instead.", + "534": "renderToMarkup should not have emitted Client References. This is a bug in React.", + "535": "renderToMarkup should not have emitted Server References. This is a bug in React." } diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index d9c21ad480a4a..a21b624150aac 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -36,6 +36,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.node', 'react-dom/src/server/ReactDOMFizzServerNode.js', // react-dom/server.node 'react-dom/src/server/ReactDOMFizzStaticNode.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-webpack', 'react-server-dom-webpack/client.node.unbundled', 'react-server-dom-webpack/server', @@ -71,6 +74,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.node', 'react-dom/src/server/ReactDOMFizzServerNode.js', // react-dom/server.node 'react-dom/src/server/ReactDOMFizzStaticNode.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-webpack', 'react-server-dom-webpack/client.node', 'react-server-dom-webpack/server', @@ -108,6 +114,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.node', 'react-dom/src/server/ReactDOMFizzServerNode.js', // react-dom/server.node 'react-dom/src/server/ReactDOMFizzStaticNode.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-turbopack', 'react-server-dom-turbopack/client.node.unbundled', 'react-server-dom-turbopack/server', @@ -145,6 +154,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.node', 'react-dom/src/server/ReactDOMFizzServerNode.js', // react-dom/server.node 'react-dom/src/server/ReactDOMFizzStaticNode.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-turbopack', 'react-server-dom-turbopack/client.node', 'react-server-dom-turbopack/server', @@ -182,6 +194,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.bun', 'react-dom/src/server/ReactDOMFizzServerBun.js', 'react-dom-bindings', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'shared/ReactDOMSharedInternals', ], isFlowTyped: true, @@ -212,6 +227,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.browser', 'react-dom/src/server/ReactDOMFizzServerBrowser.js', // react-dom/server.browser 'react-dom/src/server/ReactDOMFizzStaticBrowser.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-webpack', 'react-server-dom-webpack/client', 'react-server-dom-webpack/client.browser', @@ -240,6 +258,9 @@ module.exports = [ 'react-dom/server', 'react-dom/server.node', 'react-dom-bindings', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-esm', 'react-server-dom-esm/client', 'react-server-dom-esm/client.browser', @@ -274,6 +295,9 @@ module.exports = [ 'react-server-dom-turbopack/src/ReactFlightDOMServerBrowser.js', // react-server-dom-turbopack/server.browser 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopack.js', 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopackBrowser.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-devtools', 'react-devtools-core', 'react-devtools-shell', @@ -303,6 +327,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.edge', 'react-dom/src/server/ReactDOMFizzServerEdge.js', // react-dom/server.edge 'react-dom/src/server/ReactDOMFizzStaticEdge.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-webpack', 'react-server-dom-webpack/client.edge', 'react-server-dom-webpack/server.edge', @@ -338,6 +365,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.edge', 'react-dom/src/server/ReactDOMFizzServerEdge.js', // react-dom/server.edge 'react-dom/src/server/ReactDOMFizzStaticEdge.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-turbopack', 'react-server-dom-turbopack/client.edge', 'react-server-dom-turbopack/server.edge', @@ -374,6 +404,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.node', 'react-dom/src/server/ReactDOMFizzServerNode.js', // react-dom/server.node 'react-dom/src/server/ReactDOMFizzStaticNode.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-esm', 'react-server-dom-esm/client.node', 'react-server-dom-esm/server', From 2829604ebd1bbd25ab14d4a89a440822aa31f536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Thu, 27 Jun 2024 09:47:48 -0400 Subject: [PATCH 6/8] Update packages/react-html/package.json Typo for npm. This isn't covered by tests since our tests manually alias instead of looking at the conditions. Co-authored-by: Sebastian Silbermann --- packages/react-html/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-html/package.json b/packages/react-html/package.json index c9c4205304b53..92dfc38512338 100644 --- a/packages/react-html/package.json +++ b/packages/react-html/package.json @@ -29,7 +29,7 @@ ], "exports": { ".": { - "react-server": "./react-dom.react-server.js", + "react-server": "./react-html.react-server.js", "default": "./index.js" }, "./src/*": "./src/*", From 7817ea558c16fb37273eb025b6271bd253b85f22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Thu, 27 Jun 2024 09:48:01 -0400 Subject: [PATCH 7/8] Update README Co-authored-by: Sebastian Silbermann --- packages/react-html/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-html/README.md b/packages/react-html/README.md index a4acd1fd84396..c0794aac104c5 100644 --- a/packages/react-html/README.md +++ b/packages/react-html/README.md @@ -1,6 +1,6 @@ # `react-html` -This package provides the ability to render standalone HTML from Server Components for use in embedded contexts such as e-mails and RSS/Atom feeds. It cannot use Client Components and aren't hydrated. It is intended to be paired with the generic React package, which is shipped as `react` to npm. +This package provides the ability to render standalone HTML from Server Components for use in embedded contexts such as e-mails and RSS/Atom feeds. It cannot use Client Components and does not hydrate. It is intended to be paired with the generic React package, which is shipped as `react` to npm. ## Installation @@ -23,7 +23,7 @@ async function action(email, name) { } ``` -Note that this is an async function that need to be awaited - unlike the legacy `renderToString` in `react-dom`. +Note that this is an async function that needs to be awaited - unlike the legacy `renderToString` in `react-dom`. ## API From 471c13ba1c3a98c5b741f1803d0959d170a66a64 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 27 Jun 2024 09:59:09 -0400 Subject: [PATCH 8/8] Remove isPrimaryRenderer No longer used since there's no Context on the Server. --- .../src/forks/ReactFlightServerConfig.dom-legacy.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js index 0c0d5113d994c..99591bb954ea9 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js @@ -11,10 +11,6 @@ import type {Request} from 'react-server/src/ReactFlightServer'; import type {ReactComponentInfo} from 'shared/ReactTypes'; import type {ReactClientValue} from 'react-server/src/ReactFlightServer'; -// Used to distinguish these contexts from ones used in other renderers. -// E.g. this can be used to distinguish legacy renderers from this modern one. -export const isPrimaryRenderer = true; - export type HintCode = string; export type HintModel = null; // eslint-disable-line no-unused-vars export type Hints = null;