diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index eb5366a954f52..901ed17e74cda 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -123,6 +123,9 @@ const DataStreamingFormat: StreamingFormat = 1; export type ResponseState = { bootstrapChunks: Array, fallbackBootstrapChunks: void | Array, + requiresEmbedding: boolean, + hasHead: boolean, + hasHtml: boolean, placeholderPrefix: PrecomputedChunk, segmentPrefix: PrecomputedChunk, boundaryPrefix: string, @@ -199,6 +202,7 @@ export function createResponseState( > | void, externalRuntimeConfig: string | BootstrapScriptDescriptor | void, containerID: string | void, + documentEmbedding: boolean | void, ): ResponseState { const idPrefix = identifierPrefix === undefined ? '' : identifierPrefix; const inlineScriptWithNonce = @@ -335,6 +339,9 @@ export function createResponseState( fallbackBootstrapChunks: fallbackBootstrapChunks.length ? fallbackBootstrapChunks : undefined, + requiresEmbedding: documentEmbedding === true, + hasHead: false, + hasHtml: false, placeholderPrefix: stringToPrecomputedChunk(idPrefix + 'P:'), segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'), boundaryPrefix: idPrefix + 'B:', @@ -1660,25 +1667,92 @@ function pushStartHead( target: Array, preamble: Array, props: Object, - tag: string, responseState: ResponseState, ): ReactNodeList { - return pushStartGenericElement( - enableFloat ? preamble : target, - props, - tag, - responseState, - ); + if (enableFloat) { + let children = null; + let innerHTML = null; + let includedAttributeProps = false; + + if (!responseState.hasHead) { + responseState.hasHead = true; + preamble.push(startChunkForTag('head')); + for (const propKey in props) { + if (hasOwnProperty.call(props, propKey)) { + const propValue = props[propKey]; + if (propValue == null) { + continue; + } + switch (propKey) { + case 'children': + children = propValue; + break; + case 'dangerouslySetInnerHTML': + innerHTML = propValue; + break; + default: + if (__DEV__) { + includedAttributeProps = true; + } + pushAttribute(preamble, responseState, propKey, propValue); + break; + } + } + } + preamble.push(endOfStartTag); + } else { + // We elide the actual tag because it was previously rendered but we still need + // to render children/innerHTML + for (const propKey in props) { + if (hasOwnProperty.call(props, propKey)) { + const propValue = props[propKey]; + if (propValue == null) { + continue; + } + switch (propKey) { + case 'children': + children = propValue; + break; + case 'dangerouslySetInnerHTML': + innerHTML = propValue; + break; + default: + if (__DEV__) { + includedAttributeProps = true; + } + break; + } + } + } + } + + if (__DEV__) { + if ((responseState: any).isDocumentEmbedded && includedAttributeProps) { + // We use this embedded flag a heuristic for whether we are rendering with renderIntoDocument + console.error( + 'A tag was rendered with props when using "renderIntoDocument". In this rendering mode' + + ' React may emit the head tag early in some circumstances and therefore props on the tag are not' + + ' supported and may be missing in the rendered output for any particular render. In many cases props that' + + ' are set on a tag can be set on the tag instead.', + ); + } + } + + pushInnerHTML(target, innerHTML, children); + return children; + } else { + return pushStartGenericElement(target, props, 'head', responseState); + } } function pushStartHtml( target: Array, preamble: Array, props: Object, - tag: string, responseState: ResponseState, formatContext: FormatContext, ): ReactNodeList { + responseState.hasHtml = true; target = enableFloat ? preamble : target; if (formatContext.insertionMode === ROOT_HTML_MODE) { // If we're rendering the html tag and we're at the root (i.e. not in foreignObject) @@ -1686,7 +1760,7 @@ function pushStartHtml( // rendering the whole document. target.push(DOCTYPE); } - return pushStartGenericElement(target, props, tag, responseState); + return pushStartGenericElement(target, props, 'html', responseState); } function pushScript( @@ -1764,6 +1838,25 @@ function pushScriptImpl( return null; } +function pushHtmlEmbedding( + preamble: Array, + postamble: Array, + responseState: ResponseState, +): void { + responseState.hasHtml = true; + preamble.push(DOCTYPE); + preamble.push(startChunkForTag('html'), endOfStartTag); + postamble.push(endTag1, stringToChunk('html'), endTag2); +} + +function pushBodyEmbedding( + target: Array, + postamble: Array, +): void { + target.push(startChunkForTag('body'), endOfStartTag); + postamble.push(endTag1, stringToChunk('body'), endTag2); +} + function pushStartGenericElement( target: Array, props: Object, @@ -1981,6 +2074,7 @@ const DOCTYPE: PrecomputedChunk = stringToPrecomputedChunk(''); export function pushStartInstance( target: Array, preamble: Array, + postamble: Array, type: string, props: Object, responseState: ResponseState, @@ -2024,6 +2118,31 @@ export function pushStartInstance( } } + if (enableFloat) { + if (responseState.requiresEmbedding) { + responseState.requiresEmbedding = false; + if (__DEV__) { + // Dev only marker for later + (responseState: any).isDocumentEmbedded = true; + } + switch (type) { + case 'html': { + // noop + break; + } + case 'head': + case 'body': { + pushHtmlEmbedding(preamble, postamble, responseState); + break; + } + default: { + pushBodyEmbedding(target, postamble); + pushHtmlEmbedding(preamble, postamble, responseState); + } + } + } + } + switch (type) { // Special tags case 'select': @@ -2113,13 +2232,12 @@ export function pushStartInstance( } // Preamble start tags case 'head': - return pushStartHead(target, preamble, props, type, responseState); + return pushStartHead(target, preamble, props, responseState); case 'html': { return pushStartHtml( target, preamble, props, - type, responseState, formatContext, ); @@ -2195,6 +2313,35 @@ export function pushEndInstance( target.push(endTag1, stringToChunk(type), endTag2); } +export function writePreambleOpen( + destination: Destination, + preamble: Array, + responseState: ResponseState, +): void { + for (let i = 0; i < preamble.length; i++) { + writeChunk(destination, preamble[i]); + } + preamble.length = 0; + if (enableFloat) { + if (responseState.hasHtml && !responseState.hasHead) { + responseState.hasHead = true; + writeChunk(destination, startChunkForTag('head')); + writeChunk(destination, endOfStartTag); + preamble.push(endTag1, stringToChunk('head'), endTag2); + } + } +} + +export function writePreambleClose( + destination: Destination, + preamble: Array, +): void { + for (let i = 0; i < preamble.length; i++) { + writeChunk(destination, preamble[i]); + } + preamble.length = 0; +} + export function writeCompletedRoot( destination: Destination, responseState: ResponseState, diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js index cf864fafb16cc..8a15031b9d72f 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js @@ -37,6 +37,9 @@ export type ResponseState = { // Keep this in sync with ReactDOMServerFormatConfig bootstrapChunks: Array, fallbackBootstrapChunks: void | Array, + requiresEmbedding: boolean, + hasHead: boolean, + hasHtml: boolean, placeholderPrefix: PrecomputedChunk, segmentPrefix: PrecomputedChunk, boundaryPrefix: string, @@ -75,6 +78,9 @@ export function createResponseState( // Keep this in sync with ReactDOMServerFormatConfig bootstrapChunks: responseState.bootstrapChunks, fallbackBootstrapChunks: responseState.fallbackBootstrapChunks, + requiresEmbedding: false, + hasHead: false, + hasHtml: false, placeholderPrefix: responseState.placeholderPrefix, segmentPrefix: responseState.segmentPrefix, boundaryPrefix: responseState.boundaryPrefix, @@ -137,6 +143,8 @@ export { prepareToRender, cleanupAfterRender, getRootBoundaryID, + writePreambleOpen, + writePreambleClose, } from './ReactDOMServerFormatConfig'; import {stringToChunk} from 'react-server/src/ReactServerStreamConfig'; diff --git a/packages/react-dom/npm/server.browser.js b/packages/react-dom/npm/server.browser.js index 7b1a2d0bcbf4a..963c28d50d6a3 100644 --- a/packages/react-dom/npm/server.browser.js +++ b/packages/react-dom/npm/server.browser.js @@ -19,3 +19,6 @@ exports.renderToReadableStream = s.renderToReadableStream; if (typeof s.renderIntoContainer === 'function') { exports.renderIntoContainer = s.renderIntoContainer; } +if (typeof s.renderIntoDocument === 'function') { + exports.renderIntoDocument = s.renderIntoDocument; +} diff --git a/packages/react-dom/npm/server.bun.js b/packages/react-dom/npm/server.bun.js index f879de0a46580..eb0721533831e 100644 --- a/packages/react-dom/npm/server.bun.js +++ b/packages/react-dom/npm/server.bun.js @@ -19,3 +19,6 @@ exports.renderToReadableStream = s.renderToReadableStream; if (typeof s.renderIntoContainer === 'function') { exports.renderIntoContainer = s.renderIntoContainer; } +if (typeof s.renderIntoDocument === 'function') { + exports.renderIntoDocument = s.renderIntoDocument; +} diff --git a/packages/react-dom/npm/server.node.js b/packages/react-dom/npm/server.node.js index 61081ae3e5283..f8bce20818c9e 100644 --- a/packages/react-dom/npm/server.node.js +++ b/packages/react-dom/npm/server.node.js @@ -20,3 +20,7 @@ if (typeof s.renderIntoContainerAsPipeableStream === 'function') { exports.renderIntoContainerAsPipeableStream = s.renderIntoContainerAsPipeableStream; } +if (typeof s.renderIntoDocumentAsPipeableStream === 'function') { + exports.renderIntoDocumentAsPipeableStream = + s.renderIntoDocumentAsPipeableStream; +} diff --git a/packages/react-dom/server.browser.js b/packages/react-dom/server.browser.js index 715edc12adaed..654672f0f2641 100644 --- a/packages/react-dom/server.browser.js +++ b/packages/react-dom/server.browser.js @@ -49,3 +49,10 @@ export function renderIntoContainer() { arguments, ); } + +export function renderIntoDocument() { + return require('./src/server/ReactDOMFizzServerBrowser').renderIntoDocument.apply( + this, + arguments, + ); +} diff --git a/packages/react-dom/server.bun.js b/packages/react-dom/server.bun.js index 34a516ac3e4fd..3778267affb0a 100644 --- a/packages/react-dom/server.bun.js +++ b/packages/react-dom/server.bun.js @@ -45,9 +45,17 @@ export function renderToReadableStream() { arguments, ); } + export function renderIntoContainer() { return require('./src/server/ReactDOMFizzServerBun').renderIntoContainer.apply( this, arguments, ); } + +export function renderIntoDocument() { + return require('./src/server/ReactDOMFizzServerBun').renderIntoDocument.apply( + this, + arguments, + ); +} diff --git a/packages/react-dom/server.node.js b/packages/react-dom/server.node.js index 8734e7446b02e..882b7944781fe 100644 --- a/packages/react-dom/server.node.js +++ b/packages/react-dom/server.node.js @@ -49,3 +49,10 @@ export function renderIntoContainerAsPipeableStream() { arguments, ); } + +export function renderIntoDocumentAsPipeableStream() { + return require('./src/server/ReactDOMFizzServerNode').renderIntoDocumentAsPipeableStream.apply( + this, + arguments, + ); +} diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 9bd38c5177ee2..85c9de19faf40 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -398,6 +398,14 @@ describe('ReactDOMFizzServer', () => { mergeOptions(options, renderOptions), ); } + function renderIntoDocumentAsPipeableStream(jsx, fallback, options) { + // Merge options with renderOptions, which may contain featureFlag specific behavior + return ReactDOMFizzServer.renderIntoDocumentAsPipeableStream( + jsx, + fallback, + mergeOptions(options, renderOptions), + ); + } it('should asynchronously load a lazy component', async () => { const originalConsoleError = console.error; @@ -6122,4 +6130,109 @@ describe('ReactDOMFizzServer', () => { ); }); }); + + describe('renderIntoDocument', () => { + it('can render arbitrary HTML into a Document', async () => { + let content = ''; + writable.on('data', chunk => (content += chunk)); + await act(() => { + const {pipe} = renderIntoDocumentAsPipeableStream( +
foo
, + ); + pipe(writable); + }); + + expect(content.slice(0, 34)).toEqual( + '', + ); + + expect(getMeaningfulChildren(document)).toEqual( + + + +
foo
+ + , + ); + }); + + it('can render into a Document', async () => { + let content = ''; + writable.on('data', chunk => (content += chunk)); + await act(() => { + const {pipe} = renderIntoDocumentAsPipeableStream( + foo, + ); + pipe(writable); + }); + + expect(content.slice(0, 34)).toEqual( + '', + ); + + expect(getMeaningfulChildren(document)).toEqual( + + + foo + , + ); + }); + + it('can render into a Document', async () => { + let content = ''; + writable.on('data', chunk => (content += chunk)); + await expect(async () => { + await act(() => { + const {pipe} = renderIntoDocumentAsPipeableStream( + + + a title + + foo + , + ); + pipe(writable); + }); + }).toErrorDev( + 'A tag was rendered with props when using "renderIntoDocument". In this rendering mode React may emit the head tag early in some circumstances and therefore props on the tag are not supported and may be missing in the rendered output for any particular render. In many cases props that are set on a tag can be set on the tag instead.', + ); + + expect(content.slice(0, 47)).toEqual( + '', + ); + + expect(getMeaningfulChildren(document)).toEqual( + + + a title + + foo + , + ); + }); + + it('inserts an empty head when rendering if no is provided', async () => { + let content = ''; + writable.on('data', chunk => (content += chunk)); + await act(() => { + const {pipe} = renderIntoDocumentAsPipeableStream( + + foo + , + ); + pipe(writable); + }); + + expect(content.slice(0, 49)).toEqual( + ' + + foo + , + ); + }); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js index d1091136ed6c1..6ccc1cc3db380 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js @@ -182,7 +182,7 @@ describe('ReactDOMFizzServerBrowser', () => { ); const result = await readResult(stream); expect(result).toMatchInlineSnapshot( - `"hello world"`, + `"hello world"`, ); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js index 3b3b42b2525fa..2e92ae7cd750d 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js @@ -74,7 +74,7 @@ describe('ReactDOMFizzServerNode', () => { pipe(writable); jest.runAllTimers(); expect(output.result).toMatchInlineSnapshot( - `"hello world"`, + `"hello world"`, ); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js index d55b11154f280..a7441c5dc3c81 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js @@ -64,7 +64,7 @@ describe('ReactDOMFizzStaticBrowser', () => { ); const prelude = await readContent(result.prelude); expect(prelude).toMatchInlineSnapshot( - `"hello world"`, + `"hello world"`, ); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js index 64b14ef793596..1e7e5c5c4b6aa 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js @@ -66,7 +66,7 @@ describe('ReactDOMFizzStaticNode', () => { ); const prelude = await readContent(result.prelude); expect(prelude).toMatchInlineSnapshot( - `"hello world"`, + `"hello world"`, ); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 30373bbdf91b9..704cbe8b875b8 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -603,7 +603,7 @@ describe('ReactDOMFloat', () => { pipe(writable); }); expect(chunks).toEqual([ - 'foobar', + 'foobar', '', ]); }); diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js index 90fadb0a27977..e1551c76d456b 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js @@ -12,7 +12,10 @@ import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/Reac import ReactVersion from 'shared/ReactVersion'; -import {enableFizzIntoContainer} from 'shared/ReactFeatureFlags'; +import { + enableFizzIntoContainer, + enableFizzIntoDocument, +} from 'shared/ReactFeatureFlags'; import { createRequest, @@ -83,6 +86,7 @@ function renderToReadableStream( } const request = createRequest( children, + undefined, // fallback createResponseState( options ? options.identifierPrefix : undefined, options ? options.nonce : undefined, @@ -94,6 +98,7 @@ function renderToReadableStream( undefined, // fallbackBootstrapModules options ? options.unstable_externalRuntimeSrc : undefined, undefined, // containerID + false, // documentEmbedding ), createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, @@ -149,6 +154,7 @@ function renderIntoContainer( const request = createRequest( children, + undefined, // fallback createResponseState( options ? options.identifierPrefix : undefined, options ? options.nonce : undefined, @@ -160,6 +166,7 @@ function renderIntoContainer( options ? options.fallbackBootstrapModules : undefined, options ? options.unstable_externalRuntimeSrc : undefined, containerID, + false, // documentEmbedding ), createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, @@ -206,8 +213,98 @@ if (enableFizzIntoContainer) { renderIntoContainerExport = renderIntoContainer; } +type IntoDocumentOptions = { + identifierPrefix?: string, + namespaceURI?: string, + nonce?: string, + bootstrapScriptContent?: string, + bootstrapScripts?: Array, + bootstrapModules?: Array, + fallbackBootstrapScriptContent?: string, + fallbackBootstrapScripts?: Array, + fallbackBootstrapModules?: Array, + progressiveChunkSize?: number, + signal?: AbortSignal, + onError?: (error: mixed) => ?string, + unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, +}; + +function renderIntoDocument( + children: ReactNodeList, + fallback?: ReactNodeList, + options?: IntoDocumentOptions, +): ReactDOMServerReadableStream { + let onFatalError; + let onAllReady; + const allReady = new Promise((res, rej) => { + onAllReady = res; + onFatalError = rej; + }); + + const request = createRequest( + children, + fallback ? fallback : null, + createResponseState( + options ? options.identifierPrefix : undefined, + options ? options.nonce : undefined, + options ? options.bootstrapScriptContent : undefined, + options ? options.bootstrapScripts : undefined, + options ? options.bootstrapModules : undefined, + options ? options.fallbackBootstrapScriptContent : undefined, + options ? options.fallbackBootstrapScripts : undefined, + options ? options.fallbackBootstrapModules : undefined, + options ? options.unstable_externalRuntimeSrc : undefined, + undefined, // containerID, + true, // documentEmbedding + ), + createRootFormatContext(options ? options.namespaceURI : undefined), + options ? options.progressiveChunkSize : undefined, + options ? options.onError : undefined, + onAllReady, + undefined, // onShellReady + undefined, // onShellError + onFatalError, + ); + 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); + + const stream: ReactDOMServerReadableStream = (new ReadableStream( + { + type: 'bytes', + pull: (controller): ?Promise => { + startFlowing(request, controller); + }, + cancel: (reason): ?Promise => { + abort(request); + }, + }, + // $FlowFixMe size() methods are not allowed on byte streams. + {highWaterMark: 0}, + ): any); + stream.allReady = allReady; + + return stream; +} + +let renderIntoDocumentExport: void | typeof renderIntoDocument; +if (enableFizzIntoDocument) { + renderIntoDocumentExport = renderIntoDocument; +} + export { renderToReadableStream, renderIntoContainerExport as renderIntoContainer, + renderIntoDocumentExport as renderIntoDocument, ReactVersion as version, }; diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBun.js b/packages/react-dom/src/server/ReactDOMFizzServerBun.js index 017515d3852d1..274e8ba7f4d5e 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerBun.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerBun.js @@ -12,7 +12,10 @@ import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/Reac import ReactVersion from 'shared/ReactVersion'; -import {enableFizzIntoContainer} from 'shared/ReactFeatureFlags'; +import { + enableFizzIntoContainer, + enableFizzIntoDocument, +} from 'shared/ReactFeatureFlags'; import { createRequest, @@ -84,6 +87,7 @@ function renderToReadableStream( } const request = createRequest( children, + undefined, // fallback createResponseState( options ? options.identifierPrefix : undefined, options ? options.nonce : undefined, @@ -94,6 +98,8 @@ function renderToReadableStream( undefined, // fallbackBootstrapScripts undefined, // fallbackBootstrapModules options ? options.unstable_externalRuntimeSrc : undefined, + undefined, // containerID + false, // documentEmbedding ), createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, @@ -150,6 +156,7 @@ function renderIntoContainer( const request = createRequest( children, + undefined, // fallback createResponseState( options ? options.identifierPrefix : undefined, options ? options.nonce : undefined, @@ -161,6 +168,7 @@ function renderIntoContainer( options ? options.fallbackBootstrapModules : undefined, options ? options.unstable_externalRuntimeSrc : undefined, containerID, + false, // documentEmbedding ), createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, @@ -209,6 +217,98 @@ if (enableFizzIntoContainer) { renderIntoContainerExport = renderIntoContainer; } +type IntoDocumentOptions = { + identifierPrefix?: string, + namespaceURI?: string, + nonce?: string, + bootstrapScriptContent?: string, + bootstrapScripts?: Array, + bootstrapModules?: Array, + fallbackBootstrapScriptContent?: string, + fallbackBootstrapScripts?: Array, + fallbackBootstrapModules?: Array, + progressiveChunkSize?: number, + signal?: AbortSignal, + onError?: (error: mixed) => ?string, + unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, +}; + +function renderIntoDocument( + children: ReactNodeList, + fallback?: ReactNodeList, + options?: IntoDocumentOptions, +): Promise { + return new Promise((resolve, reject) => { + let onFatalError; + let onAllReady; + const allReady = new Promise((res, rej) => { + onAllReady = res; + onFatalError = rej; + }); + + const request = createRequest( + children, + fallback ? fallback : null, + createResponseState( + options ? options.identifierPrefix : undefined, + options ? options.nonce : undefined, + options ? options.bootstrapScriptContent : undefined, + options ? options.bootstrapScripts : undefined, + options ? options.bootstrapModules : undefined, + options ? options.fallbackBootstrapScriptContent : undefined, + options ? options.fallbackBootstrapScripts : undefined, + options ? options.fallbackBootstrapModules : undefined, + options ? options.unstable_externalRuntimeSrc : undefined, + undefined, // containerID + true, // documentEmbedding + ), + createRootFormatContext(options ? options.namespaceURI : undefined), + options ? options.progressiveChunkSize : undefined, + options ? options.onError : undefined, + onAllReady, + undefined, // onShellReady + undefined, // onShellError + onFatalError, + ); + 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); + + const stream: ReactDOMServerReadableStream = (new ReadableStream( + { + type: 'direct', + pull: (controller): ?Promise => { + // $FlowIgnore + startFlowing(request, controller); + }, + cancel: (reason): ?Promise => { + abort(request); + }, + }, + // $FlowFixMe size() methods are not allowed on byte streams. + {highWaterMark: 2048}, + ): any); + // TODO: Move to sub-classing ReadableStream. + stream.allReady = allReady; + return stream; + }); +} + +let renderIntoDocumentExport: void | typeof renderIntoDocument; +if (enableFizzIntoDocument) { + renderIntoDocumentExport = renderIntoDocument; +} + function renderToNodeStream() { throw new Error( 'ReactDOMServer.renderToNodeStream(): The Node Stream API is not available ' + @@ -226,6 +326,7 @@ function renderToStaticNodeStream() { export { renderToReadableStream, renderIntoContainerExport as renderIntoContainer, + renderIntoDocumentExport as renderIntoDocument, renderToNodeStream, renderToStaticNodeStream, ReactVersion as version, diff --git a/packages/react-dom/src/server/ReactDOMFizzServerNode.js b/packages/react-dom/src/server/ReactDOMFizzServerNode.js index 7811d122c2f75..9604544f8ef5f 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerNode.js @@ -14,7 +14,10 @@ import type {BootstrapScriptDescriptor} from 'react-dom-bindings/src/server/Reac import type {Destination} from 'react-server/src/ReactServerStreamConfigNode'; import ReactVersion from 'shared/ReactVersion'; -import {enableFizzIntoContainer} from 'shared/ReactFeatureFlags'; +import { + enableFizzIntoContainer, + enableFizzIntoDocument, +} from 'shared/ReactFeatureFlags'; import { createRequest, @@ -65,6 +68,7 @@ function renderToPipeableStream( ): PipeableStream { const request = createRequest( children, + undefined, // fallback createResponseState( options ? options.identifierPrefix : undefined, options ? options.nonce : undefined, @@ -76,6 +80,7 @@ function renderToPipeableStream( undefined, // fallbackBootstrapModules options ? options.unstable_externalRuntimeSrc : undefined, undefined, // containerID + false, // documentEmbedding ), createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, @@ -139,6 +144,7 @@ function renderIntoContainerAsPipeableStream( ): PipeableStream { const request = createRequest( children, + undefined, // fallback createResponseState( options ? options.identifierPrefix : undefined, options ? options.nonce : undefined, @@ -150,6 +156,7 @@ function renderIntoContainerAsPipeableStream( options ? options.fallbackBootstrapModules : undefined, options ? options.unstable_externalRuntimeSrc : undefined, containerID, + false, // documentEmbedding ), createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, @@ -197,8 +204,92 @@ if (enableFizzIntoContainer) { renderIntoContainerExport = renderIntoContainerAsPipeableStream; } +type IntoDocumentOptions = { + identifierPrefix?: string, + namespaceURI?: string, + nonce?: string, + bootstrapScriptContent?: string, + bootstrapScripts?: Array, + bootstrapModules?: Array, + fallbackBootstrapScriptContent?: string, + fallbackBootstrapScripts?: Array, + fallbackBootstrapModules?: Array, + progressiveChunkSize?: number, + onAllReady?: () => void, + onError?: (error: mixed) => ?string, + unstable_externalRuntimeSrc?: string | BootstrapScriptDescriptor, +}; + +function renderIntoDocumentAsPipeableStream( + children: ReactNodeList, + fallback?: ReactNodeList, + options?: IntoDocumentOptions, +): PipeableStream { + const request = createRequest( + children, + fallback ? fallback : null, + createResponseState( + options ? options.identifierPrefix : undefined, + options ? options.nonce : undefined, + options ? options.bootstrapScriptContent : undefined, + options ? options.bootstrapScripts : undefined, + options ? options.bootstrapModules : undefined, + options ? options.fallbackBootstrapScriptContent : undefined, + options ? options.fallbackBootstrapScripts : undefined, + options ? options.fallbackBootstrapModules : undefined, + options ? options.unstable_externalRuntimeSrc : undefined, + undefined, // containerID + true, // documentEmbedding + ), + createRootFormatContext(options ? options.namespaceURI : undefined), + options ? options.progressiveChunkSize : undefined, + options ? options.onError : undefined, + options ? options.onAllReady : undefined, + undefined, // onShellReady + undefined, // onShellError + undefined, // onFatalError + ); + let hasStartedFlowing = false; + startWork(request); + return { + pipe(destination: T): T { + if (hasStartedFlowing) { + throw new Error( + 'React currently only supports piping to one writable stream.', + ); + } + hasStartedFlowing = true; + startFlowing(request, destination); + destination.on('drain', createDrainHandler(destination, request)); + destination.on( + 'error', + createAbortHandler( + request, + 'The destination stream errored while writing data.', + ), + ); + destination.on( + 'close', + createAbortHandler(request, 'The destination stream closed early.'), + ); + return destination; + }, + abort(reason: mixed) { + abort(request, reason); + }, + }; +} + +let renderIntoDocumentAsPipeableStreamExport: + | void + | typeof renderIntoDocumentAsPipeableStream; +if (enableFizzIntoContainer) { + renderIntoDocumentAsPipeableStreamExport = renderIntoDocumentAsPipeableStream; +} + export { renderToPipeableStream, renderIntoContainerExport as renderIntoContainerAsPipeableStream, + renderIntoDocumentAsPipeableStreamExport as renderIntoDocumentAsPipeableStream, ReactVersion as version, }; diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js index 86576eb095b41..e3ff1af9a34e1 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js @@ -66,6 +66,7 @@ function prerender( } const request = createRequest( children, + undefined, // fallback createResponseState( options ? options.identifierPrefix : undefined, undefined, diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticNode.js b/packages/react-dom/src/server/ReactDOMFizzStaticNode.js index 5c360e0f26d83..ec69715d3c999 100644 --- a/packages/react-dom/src/server/ReactDOMFizzStaticNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzStaticNode.js @@ -81,6 +81,7 @@ function prerenderToNodeStreams( const request = createRequest( children, + undefined, // fallback createResponseState( options ? options.identifierPrefix : undefined, undefined, diff --git a/packages/react-dom/src/server/ReactDOMLegacyServerImpl.js b/packages/react-dom/src/server/ReactDOMLegacyServerImpl.js index 2d9fed4a556f1..6b1e7d7013fdd 100644 --- a/packages/react-dom/src/server/ReactDOMLegacyServerImpl.js +++ b/packages/react-dom/src/server/ReactDOMLegacyServerImpl.js @@ -63,6 +63,7 @@ function renderToStringImpl( } const request = createRequest( children, + undefined, // fallback createResponseState( generateStaticMarkup, options ? options.identifierPrefix : undefined, diff --git a/packages/react-dom/src/server/ReactDOMLegacyServerNodeStream.js b/packages/react-dom/src/server/ReactDOMLegacyServerNodeStream.js index 167d6cc1e1147..562d855875239 100644 --- a/packages/react-dom/src/server/ReactDOMLegacyServerNodeStream.js +++ b/packages/react-dom/src/server/ReactDOMLegacyServerNodeStream.js @@ -73,6 +73,7 @@ function renderToNodeStreamImpl( const destination = new ReactMarkupReadableStream(); const request = createRequest( children, + undefined, // fallback createResponseState(false, options ? options.identifierPrefix : undefined), createRootFormatContext(), Infinity, diff --git a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js index 6a56ea5b6c864..e12b94ccb1e73 100644 --- a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js +++ b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js @@ -141,6 +141,7 @@ export function pushTextInstance( export function pushStartInstance( target: Array, preamble: Array, + postamble: Array, type: string, props: Object, responseState: ResponseState, @@ -315,6 +316,17 @@ export function writeClientRenderBoundaryInstruction( return writeChunkAndReturn(destination, formatID(boundaryID)); } +export function writePreambleOpen( + destination: Destination, + preamble: Array, + responseState: ResponseState, +) {} + +export function writePreambleClose( + destination: Destination, + preamble: Array, +) {} + export function writeInitialResources( destination: Destination, resources: Resources, diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index 153786cb9919a..664bab5856bbc 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -121,6 +121,7 @@ const ReactNoopServer = ReactFizzServer({ pushStartInstance( target: Array, preamble: Array, + postamble: Array, type: string, props: Object, ): ReactNodeList { @@ -271,6 +272,9 @@ const ReactNoopServer = ReactFizzServer({ boundary.status = 'client-render'; }, + writePreambleOpen() {}, + writePreambleClose() {}, + writeInitialResources() {}, writeImmediateResources() {}, diff --git a/packages/react-server-dom-relay/src/ReactDOMServerFB.js b/packages/react-server-dom-relay/src/ReactDOMServerFB.js index 370815e8a5b59..afbeb57ac1265 100644 --- a/packages/react-server-dom-relay/src/ReactDOMServerFB.js +++ b/packages/react-server-dom-relay/src/ReactDOMServerFB.js @@ -51,6 +51,7 @@ function renderToStream(children: ReactNodeList, options: Options): Stream { }; const request = createRequest( children, + undefined, // fallback createResponseState( options ? options.identifierPrefix : undefined, undefined, diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 6a62011bb92f2..5bce431404e23 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -75,6 +75,8 @@ import { setCurrentlyRenderingBoundaryResourcesTarget, createResources, createBoundaryResources, + writePreambleOpen, + writePreambleClose, } from './ReactServerFormatConfig'; import { constructClassInstance, @@ -267,6 +269,7 @@ function noop(): void {} export function createRequest( children: ReactNodeList, + fallback: void | ReactNodeList, responseState: ResponseState, rootFormatContext: FormatContext, progressiveChunkSize: void | number, @@ -708,6 +711,7 @@ function renderHostElement( const children = pushStartInstance( segment.chunks, request.preamble, + request.postamble, type, props, request.responseState, @@ -2301,11 +2305,7 @@ function flushCompletedQueues( if (request.pendingRootTasks === 0) { if (enableFloat) { const preamble = request.preamble; - for (i = 0; i < preamble.length; i++) { - // we expect the preamble to be tiny and will ignore backpressure - writeChunk(destination, preamble[i]); - } - + writePreambleOpen(destination, preamble, request.responseState); const willEmitInstructions = request.allPendingTasks > 0; flushInitialResources( destination, @@ -2313,6 +2313,7 @@ function flushCompletedQueues( request.responseState, willEmitInstructions, ); + writePreambleClose(destination, preamble); } flushSegment(request, destination, completedRootSegment); diff --git a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js index 429d7e83bcb6d..3b10f37e5b82e 100644 --- a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js +++ b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js @@ -71,6 +71,8 @@ export const writeClientRenderBoundaryInstruction = export const prepareToRender = $$$hostConfig.prepareToRender; export const cleanupAfterRender = $$$hostConfig.cleanupAfterRender; export const getRootBoundaryID = $$$hostConfig.getRootBoundaryID; +export const writePreambleOpen = $$$hostConfig.writePreambleOpen; +export const writePreambleClose = $$$hostConfig.writePreambleClose; // ------------------------- // Resources diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 3cc5780e5b478..320e974b4f490 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -128,6 +128,7 @@ export const enableUseEffectEventHook = __EXPERIMENTAL__; export const enableFizzExternalRuntime = false; export const enableFizzIntoContainer = __EXPERIMENTAL__; +export const enableFizzIntoDocument = __EXPERIMENTAL__; // ----------------------------------------------------------------------------- // Chopping Block diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index e7e4af6698f58..8a82a66996e5b 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -87,6 +87,7 @@ export const useModernStrictMode = false; export const enableFizzExternalRuntime = false; export const enableFizzIntoContainer = false; +export const enableFizzIntoDocument = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index ea2c4c04b5df6..e31fcf25886af 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -77,6 +77,7 @@ export const useModernStrictMode = false; export const enableFizzExternalRuntime = false; export const enableFizzIntoContainer = false; +export const enableFizzIntoDocument = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 61697b1f1af9c..7a1f97201ba74 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -77,6 +77,7 @@ export const useModernStrictMode = false; export const enableFizzExternalRuntime = false; export const enableFizzIntoContainer = false; +export const enableFizzIntoDocument = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js index e69529f53f494..d90b1d8236d45 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js @@ -74,6 +74,7 @@ export const enableHostSingletons = true; export const useModernStrictMode = false; export const enableFizzIntoContainer = false; +export const enableFizzIntoDocument = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 4d5fe0f54f575..71cb06e10cd72 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -79,6 +79,7 @@ export const useModernStrictMode = false; export const enableFizzExternalRuntime = false; export const enableFizzIntoContainer = false; +export const enableFizzIntoDocument = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.testing.js b/packages/shared/forks/ReactFeatureFlags.testing.js index 090887f860abe..6edd46babc846 100644 --- a/packages/shared/forks/ReactFeatureFlags.testing.js +++ b/packages/shared/forks/ReactFeatureFlags.testing.js @@ -77,6 +77,7 @@ export const useModernStrictMode = false; export const enableFizzExternalRuntime = false; export const enableFizzIntoContainer = __EXPERIMENTAL__; +export const enableFizzIntoDocument = __EXPERIMENTAL__; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.testing.www.js b/packages/shared/forks/ReactFeatureFlags.testing.www.js index b460d80365502..d3a991d0b8ee7 100644 --- a/packages/shared/forks/ReactFeatureFlags.testing.www.js +++ b/packages/shared/forks/ReactFeatureFlags.testing.www.js @@ -78,6 +78,7 @@ export const useModernStrictMode = false; export const enableFizzExternalRuntime = false; export const enableFizzIntoContainer = __EXPERIMENTAL__; +export const enableFizzIntoDocument = __EXPERIMENTAL__; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 25f1c9f32a419..e7ea95c50f728 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -109,6 +109,7 @@ export const useModernStrictMode = false; export const enableFizzExternalRuntime = true; export const enableFizzIntoContainer = __EXPERIMENTAL__; +export const enableFizzIntoDocument = __EXPERIMENTAL__; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType);