diff --git a/packages/react-dom/npm/server.node.js b/packages/react-dom/npm/server.node.js index 0373a33b3a7..f5e7b82597c 100644 --- a/packages/react-dom/npm/server.node.js +++ b/packages/react-dom/npm/server.node.js @@ -1,12 +1,14 @@ 'use strict'; -var l, s; +var l, s, w; if (process.env.NODE_ENV === 'production') { l = require('./cjs/react-dom-server-legacy.node.production.js'); s = require('./cjs/react-dom-server.node.production.js'); + w = require('./cjs/react-dom-server.node-webstreams.production.js'); } else { l = require('./cjs/react-dom-server-legacy.node.development.js'); s = require('./cjs/react-dom-server.node.development.js'); + w = require('./cjs/react-dom-server.node-webstreams.development.js'); } exports.version = l.version; @@ -16,3 +18,7 @@ exports.renderToPipeableStream = s.renderToPipeableStream; if (s.resumeToPipeableStream) { exports.resumeToPipeableStream = s.resumeToPipeableStream; } +exports.renderToReadableStream = w.renderToReadableStream; +if (w.resume) { + exports.resume = w.resume; +} diff --git a/packages/react-dom/npm/static.node.js b/packages/react-dom/npm/static.node.js index 5dc47d472ba..60936401c9b 100644 --- a/packages/react-dom/npm/static.node.js +++ b/packages/react-dom/npm/static.node.js @@ -1,12 +1,16 @@ 'use strict'; -var s; +var s, w; if (process.env.NODE_ENV === 'production') { s = require('./cjs/react-dom-server.node.production.js'); + w = require('./cjs/react-dom-server.node-webstreams.production.js'); } else { s = require('./cjs/react-dom-server.node.development.js'); + w = require('./cjs/react-dom-server.node-webstreams.development.js'); } exports.version = s.version; exports.prerenderToNodeStream = s.prerenderToNodeStream; exports.resumeAndPrerenderToNodeStream = s.resumeAndPrerenderToNodeStream; +exports.prerender = w.prerender; +exports.resumeAndPrerender = w.resumeAndPrerender; diff --git a/packages/react-dom/server.node.js b/packages/react-dom/server.node.js index 5f9c78f6dbd..2e25bc044b6 100644 --- a/packages/react-dom/server.node.js +++ b/packages/react-dom/server.node.js @@ -37,3 +37,17 @@ export function resumeToPipeableStream() { arguments, ); } + +export function renderToReadableStream() { + return require('./src/server/react-dom-server.node-webstreams').renderToReadableStream.apply( + this, + arguments, + ); +} + +export function resume() { + return require('./src/server/react-dom-server.node-webstreams').resume.apply( + this, + arguments, + ); +} diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerNodeWebStreams-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerNodeWebStreams-test.js new file mode 100644 index 00000000000..403beefeda4 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerNodeWebStreams-test.js @@ -0,0 +1,43 @@ +/** + * 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 + * @jest-environment node + */ + +'use strict'; + +let React; +let ReactDOMFizzServer; + +describe('ReactDOMFizzServerNodeWebStreams', () => { + beforeEach(() => { + jest.resetModules(); + jest.useRealTimers(); + React = require('react'); + ReactDOMFizzServer = require('react-dom/server.node'); + }); + + async function readResult(stream) { + const reader = stream.getReader(); + let result = ''; + while (true) { + const {done, value} = await reader.read(); + if (done) { + return result; + } + result += Buffer.from(value).toString('utf8'); + } + } + + it('should call renderToPipeableStream', async () => { + const stream = await ReactDOMFizzServer.renderToReadableStream( +
hello world
, + ); + const result = await readResult(stream); + expect(result).toMatchInlineSnapshot(`"
hello world
"`); + }); +}); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticNodeWebStreams-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticNodeWebStreams-test.js new file mode 100644 index 00000000000..9b328f6bbf4 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticNodeWebStreams-test.js @@ -0,0 +1,177 @@ +/** + * 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 + * @jest-environment node + */ + +'use strict'; + +import { + getVisibleChildren, + insertNodesAndExecuteScripts, +} from '../test-utils/FizzTestUtils'; + +let JSDOM; +let React; +let ReactDOMFizzServer; +let ReactDOMFizzStatic; +let Suspense; +let container; +let serverAct; + +describe('ReactDOMFizzStaticNodeWebStreams', () => { + beforeEach(() => { + jest.resetModules(); + serverAct = require('internal-test-utils').serverAct; + + JSDOM = require('jsdom').JSDOM; + + React = require('react'); + ReactDOMFizzServer = require('react-dom/server.node'); + ReactDOMFizzStatic = require('react-dom/static.node'); + Suspense = React.Suspense; + + const jsdom = new JSDOM( + // The Fizz runtime assumes requestAnimationFrame exists so we need to polyfill it. + '', + { + runScripts: 'dangerously', + }, + ); + global.window = jsdom.window; + global.document = jsdom.window.document; + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + async function readContent(stream) { + const reader = stream.getReader(); + let content = ''; + while (true) { + const {done, value} = await reader.read(); + if (done) { + return content; + } + content += Buffer.from(value).toString('utf8'); + } + } + + async function readIntoContainer(stream) { + const reader = stream.getReader(); + let result = ''; + while (true) { + const {done, value} = await reader.read(); + if (done) { + break; + } + result += Buffer.from(value).toString('utf8'); + } + const temp = document.createElement('div'); + temp.innerHTML = result; + await insertNodesAndExecuteScripts(temp, container, null); + jest.runAllTimers(); + } + + it('should call prerender', async () => { + const result = await serverAct(() => + ReactDOMFizzStatic.prerender(
hello world
), + ); + const prelude = await readContent(result.prelude); + expect(prelude).toMatchInlineSnapshot(`"
hello world
"`); + }); + + // @gate enableHalt + it('can resume render of a prerender', async () => { + const errors = []; + + let resolveA; + const promiseA = new Promise(r => (resolveA = r)); + let resolveB; + const promiseB = new Promise(r => (resolveB = r)); + + async function ComponentA() { + await promiseA; + return ( + + + + ); + } + + async function ComponentB() { + await promiseB; + return 'Hello'; + } + + function App() { + return ( +
+ + + +
+ ); + } + + const controller = new AbortController(); + let pendingResult; + await serverAct(async () => { + pendingResult = ReactDOMFizzStatic.prerender(, { + signal: controller.signal, + onError(x) { + errors.push(x.message); + }, + }); + }); + + controller.abort(); + const prerendered = await pendingResult; + const postponedState = JSON.stringify(prerendered.postponed); + + await readIntoContainer(prerendered.prelude); + expect(getVisibleChildren(container)).toEqual(
Loading A
); + + await resolveA(); + + expect(prerendered.postponed).not.toBe(null); + + const controller2 = new AbortController(); + await serverAct(async () => { + pendingResult = ReactDOMFizzStatic.resumeAndPrerender( + , + JSON.parse(postponedState), + { + signal: controller2.signal, + onError(x) { + errors.push(x.message); + }, + }, + ); + }); + + controller2.abort(); + + const prerendered2 = await pendingResult; + const postponedState2 = JSON.stringify(prerendered2.postponed); + + await readIntoContainer(prerendered2.prelude); + expect(getVisibleChildren(container)).toEqual(
Loading B
); + + await resolveB(); + + const dynamic = await serverAct(() => + ReactDOMFizzServer.resume(, JSON.parse(postponedState2)), + ); + + await readIntoContainer(dynamic); + expect(getVisibleChildren(container)).toEqual(
Hello
); + }); +}); diff --git a/packages/react-dom/src/server/react-dom-server.node-webstreams.js b/packages/react-dom/src/server/react-dom-server.node-webstreams.js new file mode 100644 index 00000000000..e70e8fd4cbe --- /dev/null +++ b/packages/react-dom/src/server/react-dom-server.node-webstreams.js @@ -0,0 +1,11 @@ +/** + * 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 './ReactDOMFizzServerEdge.js'; +export {prerender, resumeAndPrerender} from './ReactDOMFizzStaticEdge.js'; diff --git a/packages/react-dom/src/server/react-dom-server.node-webstreams.stable.js b/packages/react-dom/src/server/react-dom-server.node-webstreams.stable.js new file mode 100644 index 00000000000..5f47ecafd37 --- /dev/null +++ b/packages/react-dom/src/server/react-dom-server.node-webstreams.stable.js @@ -0,0 +1,11 @@ +/** + * 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 {renderToReadableStream, version} from './ReactDOMFizzServerEdge.js'; +export {prerender} from './ReactDOMFizzStaticEdge.js'; diff --git a/packages/react-dom/static.node.js b/packages/react-dom/static.node.js index a25c88af4d9..b9d037bdaa0 100644 --- a/packages/react-dom/static.node.js +++ b/packages/react-dom/static.node.js @@ -3,12 +3,51 @@ * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. - * - * @flow */ -export { - prerenderToNodeStream, - resumeAndPrerenderToNodeStream, - version, -} from './src/server/react-dom-server.node'; +// This file is only used for tests. +// It lazily loads the implementation so that we get the correct set of host configs. + +import ReactVersion from 'shared/ReactVersion'; +export {ReactVersion as version}; + +export function renderToString() { + return require('./src/server/ReactDOMLegacyServerNode').renderToString.apply( + this, + arguments, + ); +} +export function renderToStaticMarkup() { + return require('./src/server/ReactDOMLegacyServerNode').renderToStaticMarkup.apply( + this, + arguments, + ); +} + +export function prerenderToNodeStream() { + return require('./src/server/react-dom-server.node').prerenderToNodeStream.apply( + this, + arguments, + ); +} + +export function resumeAndPrerenderToNodeStream() { + return require('./src/server/react-dom-server.node').resumeAndPrerenderToNodeStream.apply( + this, + arguments, + ); +} + +export function prerender() { + return require('./src/server/react-dom-server.node-webstreams').prerender.apply( + this, + arguments, + ); +} + +export function resumeAndPrerender() { + return require('./src/server/react-dom-server.node-webstreams').resumeAndPrerender.apply( + this, + arguments, + ); +} diff --git a/packages/react-server/src/ReactServerStreamConfigNodeWebStreams.js b/packages/react-server/src/ReactServerStreamConfigNodeWebStreams.js new file mode 100644 index 00000000000..16df0dfc371 --- /dev/null +++ b/packages/react-server/src/ReactServerStreamConfigNodeWebStreams.js @@ -0,0 +1,186 @@ +/** + * 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 {TextEncoder} from 'util'; +import {createHash} from 'crypto'; + +export type Destination = ReadableStreamController; + +export type PrecomputedChunk = Uint8Array; +export opaque type Chunk = Uint8Array; +export type BinaryChunk = Uint8Array; + +export function scheduleWork(callback: () => void) { + setImmediate(callback); +} + +export const scheduleMicrotask = queueMicrotask; + +export function flushBuffered(destination: Destination) { + // WHATWG Streams do not yet have a way to flush the underlying + // transform streams. https://github.com/whatwg/streams/issues/960 +} + +const VIEW_SIZE = 2048; +let currentView = null; +let writtenBytes = 0; + +export function beginWriting(destination: Destination) { + currentView = new Uint8Array(VIEW_SIZE); + writtenBytes = 0; +} + +export function writeChunk( + destination: Destination, + chunk: PrecomputedChunk | Chunk | BinaryChunk, +): void { + if (chunk.byteLength === 0) { + return; + } + + if (chunk.byteLength > VIEW_SIZE) { + // this chunk may overflow a single view which implies it was not + // one that is cached by the streaming renderer. We will enqueu + // it directly and expect it is not re-used + if (writtenBytes > 0) { + destination.enqueue( + new Uint8Array( + ((currentView: any): Uint8Array).buffer, + 0, + writtenBytes, + ), + ); + currentView = new Uint8Array(VIEW_SIZE); + writtenBytes = 0; + } + destination.enqueue(chunk); + return; + } + + let bytesToWrite = chunk; + const allowableBytes = ((currentView: any): Uint8Array).length - writtenBytes; + if (allowableBytes < bytesToWrite.byteLength) { + // this chunk would overflow the current view. We enqueue a full view + // and start a new view with the remaining chunk + if (allowableBytes === 0) { + // the current view is already full, send it + destination.enqueue(currentView); + } else { + // fill up the current view and apply the remaining chunk bytes + // to a new view. + ((currentView: any): Uint8Array).set( + bytesToWrite.subarray(0, allowableBytes), + writtenBytes, + ); + // writtenBytes += allowableBytes; // this can be skipped because we are going to immediately reset the view + destination.enqueue(currentView); + bytesToWrite = bytesToWrite.subarray(allowableBytes); + } + currentView = new Uint8Array(VIEW_SIZE); + writtenBytes = 0; + } + ((currentView: any): Uint8Array).set(bytesToWrite, writtenBytes); + writtenBytes += bytesToWrite.byteLength; +} + +export function writeChunkAndReturn( + destination: Destination, + chunk: PrecomputedChunk | Chunk | BinaryChunk, +): boolean { + writeChunk(destination, chunk); + // in web streams there is no backpressure so we can alwas write more + return true; +} + +export function completeWriting(destination: Destination) { + if (currentView && writtenBytes > 0) { + destination.enqueue(new Uint8Array(currentView.buffer, 0, writtenBytes)); + currentView = null; + writtenBytes = 0; + } +} + +export function close(destination: Destination) { + destination.close(); +} + +const textEncoder = new TextEncoder(); + +export function stringToChunk(content: string): Chunk { + return textEncoder.encode(content); +} + +export function stringToPrecomputedChunk(content: string): PrecomputedChunk { + const precomputedChunk = textEncoder.encode(content); + + if (__DEV__) { + if (precomputedChunk.byteLength > VIEW_SIZE) { + console.error( + 'precomputed chunks must be smaller than the view size configured for this host. This is a bug in React.', + ); + } + } + + return precomputedChunk; +} + +export function typedArrayToBinaryChunk( + content: $ArrayBufferView, +): BinaryChunk { + // Convert any non-Uint8Array array to Uint8Array. We could avoid this for Uint8Arrays. + // If we passed through this straight to enqueue we wouldn't have to convert it but since + // we need to copy the buffer in that case, we need to convert it to copy it. + // When we copy it into another array using set() it needs to be a Uint8Array. + const buffer = new Uint8Array( + content.buffer, + content.byteOffset, + content.byteLength, + ); + // We clone large chunks so that we can transfer them when we write them. + // Others get copied into the target buffer. + return content.byteLength > VIEW_SIZE ? buffer.slice() : buffer; +} + +export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number { + return chunk.byteLength; +} + +export function byteLengthOfBinaryChunk(chunk: BinaryChunk): number { + return chunk.byteLength; +} + +export function closeWithError(destination: Destination, error: mixed): void { + // $FlowFixMe[method-unbinding] + if (typeof destination.error === 'function') { + // $FlowFixMe[incompatible-call]: This is an Error object or the destination accepts other types. + destination.error(error); + } else { + // Earlier implementations doesn't support this method. In that environment you're + // supposed to throw from a promise returned but we don't return a promise in our + // approach. We could fork this implementation but this is environment is an edge + // case to begin with. It's even less common to run this in an older environment. + // Even then, this is not where errors are supposed to happen and they get reported + // to a global callback in addition to this anyway. So it's fine just to close this. + destination.close(); + } +} + +export function createFastHash(input: string): string | number { + const hash = createHash('md5'); + hash.update(input); + return hash.digest('hex'); +} + +export function readAsDataURL(blob: Blob): Promise { + return blob.arrayBuffer().then(arrayBuffer => { + const encoded = Buffer.from(arrayBuffer).toString('base64'); + const mimeType = blob.type || 'application/octet-stream'; + return 'data:' + mimeType + ';base64,' + encoded; + }); +} diff --git a/packages/react-server/src/forks/ReactServerStreamConfig.dom-node-webstreams.js b/packages/react-server/src/forks/ReactServerStreamConfig.dom-node-webstreams.js new file mode 100644 index 00000000000..7862f283eee --- /dev/null +++ b/packages/react-server/src/forks/ReactServerStreamConfig.dom-node-webstreams.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 '../ReactServerStreamConfigNodeWebStreams'; diff --git a/packages/react/src/__tests__/ReactMismatchedVersions-test.js b/packages/react/src/__tests__/ReactMismatchedVersions-test.js index 234df6c8e48..d815f298641 100644 --- a/packages/react/src/__tests__/ReactMismatchedVersions-test.js +++ b/packages/react/src/__tests__/ReactMismatchedVersions-test.js @@ -109,6 +109,7 @@ describe('ReactMismatchedVersions-test', () => { ); }); + // @gate !source it('importing "react-dom/static.node" throws if version does not match React version', async () => { expect(() => require('react-dom/static.node')).toThrow( 'Incompatible React versions: The "react" and "react-dom" packages ' + diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 824a1bbd52f..73a29cc8600 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -365,6 +365,16 @@ const bundles = [ wrapWithModuleBoundaries: false, externals: ['react', 'util', 'crypto', 'async_hooks', 'react-dom'], }, + { + bundleTypes: [NODE_DEV, NODE_PROD], + moduleType: RENDERER, + entry: 'react-dom/src/server/react-dom-server.node-webstreams.js', + name: 'react-dom-server.node-webstreams', + global: 'ReactDOMServer', + minifyWithProdErrorCodes: false, + wrapWithModuleBoundaries: false, + externals: ['react', 'util', 'crypto', 'async_hooks', 'react-dom'], + }, { bundleTypes: __EXPERIMENTAL__ ? [FB_WWW_DEV, FB_WWW_PROD] : [], moduleType: RENDERER, diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index a6748114e09..cb065afbdc4 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -60,7 +60,6 @@ module.exports = [ entryPoints: [ 'react-dom/src/ReactDOMReactServer.js', 'react-dom/src/server/react-dom-server.node.js', - 'react-dom/static.node', 'react-dom/test-utils', 'react-dom/unstable_server-external-runtime', 'react-server-dom-webpack/client.node.unbundled', @@ -103,6 +102,42 @@ module.exports = [ isFlowTyped: true, isServerSupported: true, }, + { + shortName: 'dom-node-webstreams', + entryPoints: ['react-dom/src/server/react-dom-server.node-webstreams.js'], + paths: [ + 'react-dom', + 'react-dom/src/ReactDOMReactServer.js', + 'react-dom-bindings', + 'react-dom/client', + 'react-dom/profiling', + 'react-dom/server', + 'react-dom/server.node', + 'react-dom/static', + 'react-dom/static.node', + 'react-dom/test-utils', + 'react-dom/src/server/react-dom-server.node-webstreams', + 'react-dom/src/server/ReactDOMFizzServerEdge.js', + '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.node.unbundled', + 'react-server-dom-webpack/server', + 'react-server-dom-webpack/server.node.unbundled', + 'react-server-dom-webpack/static', + 'react-server-dom-webpack/static.node.unbundled', + 'react-server-dom-webpack/src/client/ReactFlightDOMClientEdge.js', // react-server-dom-webpack/client.node + 'react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerNode.js', + 'react-server-dom-webpack/src/server/react-flight-dom-server.node.unbundled', + 'react-server-dom-webpack/src/server/ReactFlightDOMServerEdge.js', // react-server-dom-webpack/src/server/react-flight-dom-server.node + 'shared/ReactDOMSharedInternals', + 'react-server/src/ReactFlightServerConfigDebugNode.js', + ], + isFlowTyped: true, + isServerSupported: true, + }, { shortName: 'dom-node-webpack', entryPoints: [ @@ -221,8 +256,6 @@ module.exports = [ 'react-server-dom-parcel/src/client/ReactFlightClientConfigBundlerParcel.js', 'react-server-dom-parcel/src/server/react-flight-dom-server.node', 'react-server-dom-parcel/src/server/ReactFlightDOMServerNode.js', // react-server-dom-parcel/src/server/react-flight-dom-server.node - 'react-server-dom-parcel/node-register', - 'react-server-dom-parcel/src/ReactFlightParcelNodeRegister.js', 'react-devtools', 'react-devtools-core', 'react-devtools-shell',