From fc2b82317f3e62b0a85f0a5878a84313fb4e518c Mon Sep 17 00:00:00 2001 From: Josh Story Date: Fri, 6 Jan 2023 16:54:07 -0800 Subject: [PATCH 1/7] Implement renderIntoDocument This commit adds the function renderIntoDocument in react-dom/server and adds the ability to embed the rendered children in the necessary html tags to repereset a full document. this means you can render "..." or "
...
" and either way the render will emit html, head, and body tags as necessary to describe a valid and complete HTML page. Like renderIntoContainer, renderIntoDocument provides a stream immediately. While there is a shell of sorts this fucntion will start writing content from the preamble (html and head tags, plus resources that flush in the head) before finishing the shell. Additionally renderIntoContainer accepts fallback children and fallback bootstrap script options. If the Shell errors the fallback children will render instead of children. The expectation is that the client will attempt to render fresh on the client. --- .../src/server/ReactDOMServerFormatConfig.js | 169 ++++++++++++++++-- .../ReactDOMServerLegacyFormatConfig.js | 8 + packages/react-dom/npm/server.browser.js | 3 + packages/react-dom/npm/server.bun.js | 3 + packages/react-dom/npm/server.node.js | 4 + packages/react-dom/server.browser.js | 7 + packages/react-dom/server.bun.js | 8 + packages/react-dom/server.node.js | 7 + .../src/__tests__/ReactDOMFizzServer-test.js | 113 ++++++++++++ .../ReactDOMFizzServerBrowser-test.js | 2 +- .../__tests__/ReactDOMFizzServerNode-test.js | 2 +- .../ReactDOMFizzStaticBrowser-test.js | 2 +- .../__tests__/ReactDOMFizzStaticNode-test.js | 2 +- .../src/__tests__/ReactDOMFloat-test.js | 2 +- .../src/server/ReactDOMFizzServerBrowser.js | 99 +++++++++- .../src/server/ReactDOMFizzServerBun.js | 103 ++++++++++- .../src/server/ReactDOMFizzServerNode.js | 93 +++++++++- .../src/server/ReactDOMFizzStaticBrowser.js | 1 + .../src/server/ReactDOMFizzStaticNode.js | 1 + .../src/server/ReactDOMLegacyServerImpl.js | 1 + .../server/ReactDOMLegacyServerNodeStream.js | 1 + .../server/ReactNativeServerFormatConfig.js | 12 ++ .../src/ReactNoopServer.js | 4 + .../src/ReactDOMServerFB.js | 1 + packages/react-server/src/ReactFizzServer.js | 11 +- .../forks/ReactServerFormatConfig.custom.js | 2 + packages/shared/ReactFeatureFlags.js | 1 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + .../ReactFeatureFlags.test-renderer.native.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../shared/forks/ReactFeatureFlags.testing.js | 1 + .../forks/ReactFeatureFlags.testing.www.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 1 + 35 files changed, 646 insertions(+), 24 deletions(-) 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); From 307c090bde197d570668d4b25cb3b3b4997031d8 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Tue, 10 Jan 2023 11:30:03 -0800 Subject: [PATCH 2/7] Implement fallback children When you pass a fallback to renderIntoDocument it will emit the fallback if the shell of the primary children errors. It is possible for the fallback to also error in the shell in which case the result is a fatalError as if you did not provide a fallback. Included in this change is a refactoring of how preamble and postamble work. They are now interned within the resposneState where we can have more careful control over what is flushed and when. There is also a new concept of an early preamble. the early preamble is the html and head tags plus any resources that can flush (most things plus stylesheets up to the first precedence) --- .../src/server/ReactDOMFloatServer.js | 9 +- .../src/server/ReactDOMServerFormatConfig.js | 659 ++++++++++++++---- .../ReactDOMServerLegacyFormatConfig.js | 25 +- .../src/__tests__/ReactDOMFizzServer-test.js | 222 ++++-- .../server/ReactNativeServerFormatConfig.js | 38 +- .../src/ReactNoopServer.js | 12 +- packages/react-server/src/ReactFizzServer.js | 130 ++-- .../forks/ReactServerFormatConfig.custom.js | 9 +- scripts/error-codes/codes.json | 4 +- 9 files changed, 863 insertions(+), 245 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js index 320bb1ef7c551..828bce02f081b 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js +++ b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js @@ -129,6 +129,8 @@ export type Resources = { preconnects: Set, fontPreloads: Set, // usedImagePreloads: Set, + firstPrecedence: string, + firstPrecedenceFlushed: boolean, precedences: Map>, usedStylePreloads: Set, scripts: Set, @@ -161,6 +163,8 @@ export function createResources(): Resources { preconnects: new Set(), fontPreloads: new Set(), // usedImagePreloads: new Set(), + firstPrecedence: '', + firstPrecedenceFlushed: false, precedences: new Map(), usedStylePreloads: new Set(), scripts: new Set(), @@ -485,7 +489,7 @@ function createStyleResource( ); } } - const {stylesMap, preloadsMap, precedences} = resources; + const {stylesMap, preloadsMap, precedences, firstPrecedence} = resources; // If this is the first time we've seen this precedence we encode it's position in our set even though // we don't add the resource to this set yet @@ -493,6 +497,9 @@ function createStyleResource( if (!precedenceSet) { precedenceSet = new Set(); precedences.set(precedence, precedenceSet); + if (!firstPrecedence) { + resources.firstPrecedence = precedence; + } } let hint = preloadsMap.get(href); diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index 901ed17e74cda..e5f39dfe0b168 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -119,13 +119,23 @@ export type StreamingFormat = 0 | 1; const ScriptStreamingFormat: StreamingFormat = 0; const DataStreamingFormat: StreamingFormat = 1; +export type DocumentStructureTag = number; +export const NONE: /* */ DocumentStructureTag = 0b0000; +const HTML: /* */ DocumentStructureTag = 0b0001; +const HEAD: /* */ DocumentStructureTag = 0b0010; +const BODY: /* */ DocumentStructureTag = 0b0100; +const HTML_HEAD_OR_BODY: /* */ DocumentStructureTag = 0b0111; +const FLOW: /* */ DocumentStructureTag = 0b1000; + // Per response, global state that is not contextual to the rendering subtree. export type ResponseState = { bootstrapChunks: Array, fallbackBootstrapChunks: void | Array, + htmlChunks: Array, + headChunks: Array, requiresEmbedding: boolean, - hasHead: boolean, - hasHtml: boolean, + rendered: DocumentStructureTag, + flushed: DocumentStructureTag, placeholderPrefix: PrecomputedChunk, segmentPrefix: PrecomputedChunk, boundaryPrefix: string, @@ -339,9 +349,11 @@ export function createResponseState( fallbackBootstrapChunks: fallbackBootstrapChunks.length ? fallbackBootstrapChunks : undefined, + htmlChunks: [], + headChunks: [], requiresEmbedding: documentEmbedding === true, - hasHead: false, - hasHtml: false, + rendered: NONE, + flushed: NONE, placeholderPrefix: stringToPrecomputedChunk(idPrefix + 'P:'), segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'), boundaryPrefix: idPrefix + 'B:', @@ -365,17 +377,20 @@ export function createResponseState( // modes. We only include the variants as they matter for the sake of our purposes. // We don't actually provide the namespace therefore we use constants instead of the string. const ROOT_HTML_MODE = 0; // Used for the root most element tag. -export const HTML_MODE = 1; -const SVG_MODE = 2; -const MATHML_MODE = 3; -const HTML_TABLE_MODE = 4; -const HTML_TABLE_BODY_MODE = 5; -const HTML_TABLE_ROW_MODE = 6; -const HTML_COLGROUP_MODE = 7; +const HTML_HTML_MODE = 1; // mode for top level element. +// We have a less than HTML_HTML_MODE check elsewhere. If you add more cases make cases here, make sure it +// still makes sense +export const HTML_MODE = 2; +const SVG_MODE = 3; +const MATHML_MODE = 4; +const HTML_TABLE_MODE = 5; +const HTML_TABLE_BODY_MODE = 6; +const HTML_TABLE_ROW_MODE = 7; +const HTML_COLGROUP_MODE = 8; // We have a greater than HTML_TABLE_MODE check elsewhere. If you add more cases here, make sure it // still makes sense -type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; +type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; // Lets us keep track of contextual state and pick it back up after suspending. export type FormatContext = { @@ -477,12 +492,14 @@ export function getChildFormatContext( ); } if (parentContext.insertionMode === ROOT_HTML_MODE) { + // in ROOT_HTML_MODE it's not possible for a noscript tag to be + // in scope so we use a false literal rather than forwarding + // the parentContext value + if (type === 'html') { + return createFormatContext(HTML_HTML_MODE, null, false); + } // We've emitted the root and is now in plain HTML mode. - return createFormatContext( - HTML_MODE, - null, - parentContext.noscriptTagInScope, - ); + return createFormatContext(HTML_MODE, null, false); } return parentContext; } @@ -1663,20 +1680,159 @@ function pushStartTitle( return children; } -function pushStartHead( +function pushStartHtml( target: Array, - preamble: Array, props: Object, responseState: ResponseState, + formatContext: FormatContext, ): ReactNodeList { if (enableFloat) { + if (formatContext.insertionMode === ROOT_HTML_MODE) { + responseState.rendered |= HTML; + if ( + responseState.requiresEmbedding && + hasOwnProperty.call(props, 'dangerouslySetInnerHTML') + ) { + // We only enforce this restriction with new APIs like `renderIntoDocument` which + // we currently feature detect with `requiresEmbedding`. + // @TODO In a major version lets enforce this restriction globally + throw new Error( + 'An tag was rendered with a `dangerouslySetInnerHTML` prop while using `renderIntoDocument`. React does not support this; use a `children` prop instead', + ); + } + + let children = null; + let innerHTML = null; + let renderedAttributeProps: Map; + if (__DEV__) { + renderedAttributeProps = new Map(); + } + + const htmlChunks = responseState.htmlChunks; + + if (htmlChunks.length === 0) { + htmlChunks.push(DOCTYPE); + htmlChunks.push(startChunkForTag('html')); + 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__ && renderedAttributeProps) { + renderedAttributeProps.set(propKey, propValue); + } + pushAttribute(htmlChunks, responseState, propKey, propValue); + break; + } + } + } + htmlChunks.push(endOfStartTag); + } else { + // If we have already flushed the preamble then we elide the + // tag itself but still return children and handle 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__ && renderedAttributeProps) { + renderedAttributeProps.set(propKey, propValue); + } + break; + } + } + } + } + if (__DEV__) { + const priorHtmlAttributes = (responseState: any).htmlAttributeMap; + const inFallback = (responseState: any).inFallbackDEV === true; + if (inFallback && priorHtmlAttributes && renderedAttributeProps) { + let differentProps = ''; + priorHtmlAttributes.forEach(([propKey, propValue]) => { + if (renderedAttributeProps.get(propKey) !== propValue) { + if (differentProps.length === 0) { + differentProps += '\n ' + propKey; + } else { + differentProps += ', ' + propKey; + } + } + }); + if (differentProps) { + console.error( + 'React encountered differing props when rendering the root element of' + + ' the fallback children when using `renderIntoDocument`. When using `renderIntoDocument`' + + ' React will often emit the tag early, before the we know whether the' + + ' Shell has finished. If the Shell errors and the fallback children are rendered' + + ' the props used on the tag of the fallback tree will be ignored.' + + ' The props that differed in this instance are provided below.%s', + differentProps, + ); + } + } + } + pushInnerHTML(target, innerHTML, children); + return children; + } else { + // This is an element deeper in the tree and should be rendered in place + return pushStartGenericElement(target, props, 'html', responseState); + } + } else { + if (formatContext.insertionMode === ROOT_HTML_MODE) { + // If we're rendering the html tag and we're at the root (i.e. not in foreignObject) + // then we also emit the DOCTYPE as part of the root content as a convenience for + // rendering the whole document. + target.push(DOCTYPE); + } + return pushStartGenericElement(target, props, 'html', responseState); + } +} + +function pushStartHead( + target: Array, + props: Object, + responseState: ResponseState, + formatContext: FormatContext, +): ReactNodeList { + if (enableFloat && formatContext.insertionMode <= HTML_HTML_MODE) { + responseState.rendered |= HEAD; let children = null; let innerHTML = null; - let includedAttributeProps = false; + let attributePropsIncluded = false; - if (!responseState.hasHead) { - responseState.hasHead = true; - preamble.push(startChunkForTag('head')); + if ( + responseState.requiresEmbedding && + hasOwnProperty.call(props, 'dangerouslySetInnerHTML') + ) { + // We only enforce this restriction with new APIs like `renderIntoDocument` which + // we currently feature detect with `requiresEmbedding`. + // @TODO In a major version lets enforce this restriction globally + throw new Error( + 'A tag was rendered with a `dangerouslySetInnerHTML` prop while using `renderIntoDocument`. React does not support this; use a `children` prop instead', + ); + } + + const headChunks = responseState.headChunks; + + if (headChunks.length === 0) { + headChunks.push(startChunkForTag('head')); for (const propKey in props) { if (hasOwnProperty.call(props, propKey)) { const propValue = props[propKey]; @@ -1692,17 +1848,17 @@ function pushStartHead( break; default: if (__DEV__) { - includedAttributeProps = true; + attributePropsIncluded = true; } - pushAttribute(preamble, responseState, propKey, propValue); + pushAttribute(headChunks, responseState, propKey, propValue); break; } } } - preamble.push(endOfStartTag); + headChunks.push(endOfStartTag); } else { - // We elide the actual tag because it was previously rendered but we still need - // to render children/innerHTML + // If we have already flushed the preamble then we elide the + // tag itself but still return children and handle innerHTML for (const propKey in props) { if (hasOwnProperty.call(props, propKey)) { const propValue = props[propKey]; @@ -1718,7 +1874,7 @@ function pushStartHead( break; default: if (__DEV__) { - includedAttributeProps = true; + attributePropsIncluded = true; } break; } @@ -1727,10 +1883,10 @@ function pushStartHead( } if (__DEV__) { - if ((responseState: any).isDocumentEmbedded && includedAttributeProps) { - // We use this embedded flag a heuristic for whether we are rendering with renderIntoDocument + if (responseState.requiresEmbedding && attributePropsIncluded) { + // We use this requiresEmbedding 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' + + '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.', @@ -1745,22 +1901,16 @@ function pushStartHead( } } -function pushStartHtml( +function pushStartBody( target: Array, - preamble: Array, props: Object, 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) - // then we also emit the DOCTYPE as part of the root content as a convenience for - // rendering the whole document. - target.push(DOCTYPE); + if (enableFloat && formatContext.insertionMode <= HTML_HTML_MODE) { + responseState.rendered |= BODY; } - return pushStartGenericElement(target, props, 'html', responseState); + return pushStartGenericElement(target, props, 'body', responseState); } function pushScript( @@ -1838,25 +1988,6 @@ 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, @@ -2073,8 +2204,6 @@ const DOCTYPE: PrecomputedChunk = stringToPrecomputedChunk(''); export function pushStartInstance( target: Array, - preamble: Array, - postamble: Array, type: string, props: Object, responseState: ResponseState, @@ -2118,29 +2247,8 @@ 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); - } - } - } + if (formatContext.insertionMode === ROOT_HTML_MODE) { + responseState.rendered |= FLOW; } switch (type) { @@ -2230,18 +2338,13 @@ export function pushStartInstance( case 'missing-glyph': { return pushStartGenericElement(target, props, type, responseState); } - // Preamble start tags + // Tags needing special handling for preambe/postamble or embedding + case 'html': + return pushStartHtml(target, props, responseState, formatContext); case 'head': - return pushStartHead(target, preamble, props, responseState); - case 'html': { - return pushStartHtml( - target, - preamble, - props, - responseState, - formatContext, - ); - } + return pushStartHead(target, props, responseState, formatContext); + case 'body': + return pushStartBody(target, props, responseState, formatContext); default: { if (type.indexOf('-') === -1 && typeof props.is !== 'string') { // Generic element @@ -2259,9 +2362,9 @@ const endTag2 = stringToPrecomputedChunk('>'); export function pushEndInstance( target: Array, - postamble: Array, type: string, props: Object, + formatContext: FormatContext, ): void { switch (type) { // When float is on we expect title and script tags to always be pushed in @@ -2295,51 +2398,203 @@ export function pushEndInstance( // No close tag needed. return; } - // Postamble end tags + // Postamble end tags* case 'body': { if (enableFloat) { - postamble.unshift(endTag1, stringToChunk(type), endTag2); - return; + if (formatContext.insertionMode <= HTML_HTML_MODE) { + // If we are at the top level we omit the trailing tag + // because it will be managed in the postamble + return; + } } break; } case 'html': if (enableFloat) { - postamble.push(endTag1, stringToChunk(type), endTag2); - return; + if (formatContext.insertionMode === ROOT_HTML_MODE) { + // If we are at the top level we omit the trailing tag + // because it will be managed in the postamble + return; + } } break; } target.push(endTag1, stringToChunk(type), endTag2); } -export function writePreambleOpen( +// In some render modes (such as `renderIntoDocument`) WriteEarlyPreamble +// is called to allow flushing of the preamble and Resources as early as possible. +// It is possible for this to be called more than once and needs to be +// resilient to that. For instance by not writing the preamble open tags +// more than once +export function writeEarlyPreamble( destination: Destination, - preamble: Array, + resources: Resources, responseState: ResponseState, -): void { - for (let i = 0; i < preamble.length; i++) { - writeChunk(destination, preamble[i]); + willEmitInstructions: boolean, +): boolean { + if (enableFloat) { + // We use `requiresEmbedding` as a hueristic for `renderIntoDocument` + // which is the only render method which should emit an early preamble + // In the future other render methods might and this hueristic may need + // to change + if (responseState.requiresEmbedding) { + // If we emitted a preamble early it will have flushed and . + // We check that we haven't flushed anything yet which is equivalent + // to checking whether we have not flushed an or + if (responseState.flushed === NONE && responseState.rendered !== NONE) { + let i = 0; + const {htmlChunks, headChunks} = responseState; + if (htmlChunks.length) { + for (i = 0; i < htmlChunks.length; i++) { + writeChunk(destination, htmlChunks[i]); + } + } else { + writeChunk(destination, DOCTYPE); + writeChunk(destination, startChunkForTag('html')); + writeChunk(destination, endOfStartTag); + } + if (headChunks.length) { + for (i = 0; i < headChunks.length; i++) { + writeChunk(destination, headChunks[i]); + } + } else { + writeChunk(destination, startChunkForTag('head')); + writeChunk(destination, endOfStartTag); + } + responseState.flushed |= HTML | HEAD; + + return writeEarlyResources( + destination, + resources, + responseState, + willEmitInstructions, + ); + } + } } - preamble.length = 0; + return true; +} + +// Regardless of render mode, writePreamble must only be called at most once. +// It will emit the preamble open tags if they have not already been written +// and will close the preamble if necessary. After this function completes +// the shell will flush. In modes that do not have a shell such as `renderIntoContainer` +// this function is not called. In modes that render a shell fallback such as +// `renderIntoDocument` this function is still only called once, either for the +// primary shell (no fallback possible at this point) or for the fallback shell +// (was not called for the primary children). +export function writePreamble( + destination: Destination, + resources: Resources, + responseState: ResponseState, + willEmitInstructions: boolean, +): boolean { if (enableFloat) { - if (responseState.hasHtml && !responseState.hasHead) { - responseState.hasHead = true; - writeChunk(destination, startChunkForTag('head')); - writeChunk(destination, endOfStartTag); - preamble.push(endTag1, stringToChunk('head'), endTag2); + if (responseState.flushed === NONE) { + const {htmlChunks, headChunks} = responseState; + let i = 0; + if (htmlChunks.length) { + responseState.flushed |= HTML; + for (i = 0; i < htmlChunks.length; i++) { + writeChunk(destination, htmlChunks[i]); + } + } else if (responseState.requiresEmbedding) { + responseState.flushed |= HTML; + writeChunk(destination, DOCTYPE); + writeChunk(destination, startChunkForTag('html')); + writeChunk(destination, endOfStartTag); + } + + if (headChunks.length) { + responseState.flushed |= HEAD; + for (i = 0; i < headChunks.length; i++) { + writeChunk(destination, headChunks[i]); + } + } else if (responseState.flushed & HTML) { + // We insert a missing head if an was emitted. + // This encompasses cases where we require embedding + // so we leave that check out + responseState.flushed |= HEAD; + // This render has not produced a yet. we emit + // a open tag so we can start to flush resources. + writeChunk(destination, startChunkForTag('head')); + writeChunk(destination, endOfStartTag); + } } + + // Write all remaining resources that should flush with the Shell + let r = writeInitialResources( + destination, + resources, + responseState, + willEmitInstructions, + ); + + // If we did not render a but we did flush one we need to emit + // the closing tag now after writing resources. We know we won't get + // a head in the shell so we can assume all shell content belongs after + // the closed head tag + if ( + (responseState.rendered & HEAD) === NONE && + responseState.flushed & HEAD + ) { + writeChunk(destination, endTag1); + writeChunk(destination, stringToChunk('head')); + r = writeChunkAndReturn(destination, endTag2); + } + + // If the shell needs to be embedded and the rendered embedding is body + // we need to emit an open tag and prepare the postamble to close + // the body tag + if ( + responseState.requiresEmbedding && + (responseState.rendered & HTML_HEAD_OR_BODY) === NONE + ) { + responseState.flushed |= BODY; + writeChunk(destination, startChunkForTag('body')); + r = writeChunkAndReturn(destination, endOfStartTag); + } else { + // If we rendered a we mark it as flushed here so we can emit + // the closing tag in the postamble + responseState.flushed |= responseState.rendered & BODY; + } + + return r; } + return true; } -export function writePreambleClose( +export function writePostamble( destination: Destination, - preamble: Array, + responseState: ResponseState, ): void { - for (let i = 0; i < preamble.length; i++) { - writeChunk(destination, preamble[i]); + if (enableFloat) { + if ((responseState.flushed & BODY) !== NONE) { + writeChunk(destination, endTag1); + writeChunk(destination, stringToChunk('body')); + writeChunk(destination, endTag2); + } + if ((responseState.flushed & HTML) !== NONE) { + writeChunk(destination, endTag1); + writeChunk(destination, stringToChunk('html')); + writeChunk(destination, endTag2); + } + } +} + +export function prepareForFallback(responseState: ResponseState): void { + if (__DEV__) { + (responseState: any).inFallbackDEV = true; } - preamble.length = 0; + // This function would ideally check somethign to see whether embedding + // was required however at the moment the only time we use a Request Fallback + // is when we use renderIntoDocument which is the only variant where which + // utilizes fallback children. We assume we're in that mode if this function + // is called and update the requirement accordingly + responseState.htmlChunks = []; + responseState.headChunks = []; + responseState.rendered = NONE; } export function writeCompletedRoot( @@ -2554,6 +2809,7 @@ export function writeStartSegment( ): boolean { switch (formatContext.insertionMode) { case ROOT_HTML_MODE: + case HTML_HTML_MODE: case HTML_MODE: { writeChunk(destination, startSegmentHTML); writeChunk(destination, responseState.segmentPrefix); @@ -2611,6 +2867,7 @@ export function writeEndSegment( ): boolean { switch (formatContext.insertionMode) { case ROOT_HTML_MODE: + case HTML_HTML_MODE: case HTML_MODE: { return writeChunkAndReturn(destination, endSegmentHTML); } @@ -3151,7 +3408,144 @@ const precedencePlaceholderStart = stringToPrecomputedChunk( ); const precedencePlaceholderEnd = stringToPrecomputedChunk('">'); -export function writeInitialResources( +export function writeEarlyResources( + destination: Destination, + resources: Resources, + responseState: ResponseState, + willEmitInstructions: boolean, +): boolean { + // Write initially discovered resources after the shell completes + if ( + enableFizzExternalRuntime && + responseState.externalRuntimeConfig && + willEmitInstructions + ) { + // If the root segment is incomplete due to suspended tasks + // (e.g. willFlushAllSegments = false) and we are using data + // streaming format, ensure the external runtime is sent. + // (User code could choose to send this even earlier by calling + // preinit(...), if they know they will suspend). + const {src, integrity} = responseState.externalRuntimeConfig; + preinitImpl(resources, src, {as: 'script', integrity}); + } + function flushLinkResource(resource: LinkTagResource) { + if (!resource.flushed) { + pushLinkImpl(target, resource.props, responseState); + resource.flushed = true; + } + } + + const target = []; + + const { + charset, + bases, + preconnects, + fontPreloads, + firstPrecedence, + precedences, + usedStylePreloads, + scripts, + usedScriptPreloads, + explicitStylePreloads, + explicitScriptPreloads, + headResources, + } = resources; + + if (charset) { + pushSelfClosing(target, charset.props, 'meta', responseState); + charset.flushed = true; + resources.charset = null; + } + + bases.forEach(r => { + pushSelfClosing(target, r.props, 'base', responseState); + r.flushed = true; + }); + bases.clear(); + + preconnects.forEach(r => { + // font preload Resources should not already be flushed so we elide this check + pushLinkImpl(target, r.props, responseState); + r.flushed = true; + }); + preconnects.clear(); + + fontPreloads.forEach(r => { + // font preload Resources should not already be flushed so we elide this check + pushLinkImpl(target, r.props, responseState); + r.flushed = true; + }); + fontPreloads.clear(); + + // Flush stylesheets first by earliest precedence + if (firstPrecedence) { + const precedenceSet = precedences.get(firstPrecedence); + if (precedenceSet && precedenceSet.size) { + precedenceSet.forEach(r => { + if (!r.flushed) { + pushLinkImpl(target, r.props, responseState); + r.flushed = true; + r.inShell = true; + r.hint.flushed = true; + } + }); + resources.firstPrecedenceFlushed = true; + precedenceSet.clear(); + } + } + + usedStylePreloads.forEach(flushLinkResource); + usedStylePreloads.clear(); + + scripts.forEach(r => { + // should never be flushed already + pushScriptImpl(target, r.props, responseState); + r.flushed = true; + r.hint.flushed = true; + }); + scripts.clear(); + + usedScriptPreloads.forEach(flushLinkResource); + usedScriptPreloads.clear(); + + explicitStylePreloads.forEach(flushLinkResource); + explicitStylePreloads.clear(); + + explicitScriptPreloads.forEach(flushLinkResource); + explicitScriptPreloads.clear(); + + headResources.forEach(r => { + switch (r.type) { + case 'title': { + pushTitleImpl(target, r.props, responseState); + break; + } + case 'meta': { + pushSelfClosing(target, r.props, 'meta', responseState); + break; + } + case 'link': { + pushLinkImpl(target, r.props, responseState); + break; + } + } + r.flushed = true; + }); + headResources.clear(); + + let i; + let r = true; + for (i = 0; i < target.length - 1; i++) { + writeChunk(destination, target[i]); + } + if (i < target.length) { + r = writeChunkAndReturn(destination, target[i]); + } + return r; +} + +function writeInitialResources( destination: Destination, resources: Resources, responseState: ResponseState, @@ -3185,6 +3579,8 @@ export function writeInitialResources( bases, preconnects, fontPreloads, + firstPrecedence, + firstPrecedenceFlushed, precedences, usedStylePreloads, scripts, @@ -3222,13 +3618,24 @@ export function writeInitialResources( // Flush stylesheets first by earliest precedence precedences.forEach((p, precedence) => { + if ( + precedence === firstPrecedence && + firstPrecedenceFlushed && + p.size === 0 + ) { + // We don't have anything to flush for the first precedence now but + // we already emitted items for this precedence and do not need a + // placeholder + return; + } if (p.size) { p.forEach(r => { - // resources should not already be flushed so we elide this check - pushLinkImpl(target, r.props, responseState); - r.flushed = true; - r.inShell = true; - r.hint.flushed = true; + if (!r.flushed) { + pushLinkImpl(target, r.props, responseState); + r.flushed = true; + r.inShell = true; + r.hint.flushed = true; + } }); p.clear(); } else { @@ -3290,7 +3697,7 @@ export function writeInitialResources( return r; } -export function writeImmediateResources( +export function writeResources( destination: Destination, resources: Resources, responseState: ResponseState, diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js index 8a15031b9d72f..84dd47e706f94 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js @@ -12,6 +12,7 @@ import type { FormatContext, StreamingFormat, SuspenseBoundaryID, + DocumentStructureTag, } from './ReactDOMServerFormatConfig'; import { @@ -23,6 +24,7 @@ import { writeEndCompletedSuspenseBoundary as writeEndCompletedSuspenseBoundaryImpl, writeEndClientRenderedSuspenseBoundary as writeEndClientRenderedSuspenseBoundaryImpl, HTML_MODE, + NONE, } from './ReactDOMServerFormatConfig'; import type { @@ -37,14 +39,16 @@ export type ResponseState = { // Keep this in sync with ReactDOMServerFormatConfig bootstrapChunks: Array, fallbackBootstrapChunks: void | Array, + htmlChunks: Array, + headChunks: Array, requiresEmbedding: boolean, - hasHead: boolean, - hasHtml: boolean, + rendered: DocumentStructureTag, + flushed: DocumentStructureTag, placeholderPrefix: PrecomputedChunk, segmentPrefix: PrecomputedChunk, boundaryPrefix: string, - idPrefix: string, containerBoundaryID: SuspenseBoundaryID, + idPrefix: string, nextSuspenseID: number, streamingFormat: StreamingFormat, startInlineScript: PrecomputedChunk, @@ -78,9 +82,11 @@ export function createResponseState( // Keep this in sync with ReactDOMServerFormatConfig bootstrapChunks: responseState.bootstrapChunks, fallbackBootstrapChunks: responseState.fallbackBootstrapChunks, + htmlChunks: [], + headChunks: [], requiresEmbedding: false, - hasHead: false, - hasHtml: false, + rendered: NONE, + flushed: NONE, placeholderPrefix: responseState.placeholderPrefix, segmentPrefix: responseState.segmentPrefix, boundaryPrefix: responseState.boundaryPrefix, @@ -135,16 +141,17 @@ export { writeCompletedRoot, createResources, createBoundaryResources, - writeInitialResources, - writeImmediateResources, + writeResources, hoistResources, hoistResourcesToRoot, setCurrentlyRenderingBoundaryResourcesTarget, prepareToRender, cleanupAfterRender, getRootBoundaryID, - writePreambleOpen, - writePreambleClose, + writeEarlyPreamble, + writePreamble, + writePostamble, + prepareForFallback, } from './ReactDOMServerFormatConfig'; import {stringToChunk} from 'react-server/src/ReactServerStreamConfig'; diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 85c9de19faf40..6510c8fea2a35 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -209,52 +209,74 @@ describe('ReactDOMFizzServer', () => { // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. // We also want to execute any scripts that are embedded. // We assume that we have now received a proper fragment of HTML. - const bufferedContent = buffer; - buffer = ''; + while (true) { + const bufferedContent = buffer; + + let parent; + let temporaryHostElement; + if (bufferedContent.startsWith('')) { + parent = document; + document.removeChild(document.documentElement); + + // Parse the buffered content into a temporary document + const jsdom = new JSDOM(bufferedContent); + temporaryHostElement = jsdom.window.document; + + // Remove the Doctype node + temporaryHostElement.removeChild(temporaryHostElement.firstChild); + buffer = ''; + } else if (bufferedContent.startsWith(' tag but does not contain the Doctype declaration. This is likely a bug in React', + ); + } else if (bufferedContent.startsWith('')) { - parent = document; - document.removeChild(document.documentElement); - - // Parse the buffered content into a temporary document - const jsdom = new JSDOM(bufferedContent); - temporaryHostElement = jsdom.window.document; - - // Remove the Doctype node - temporaryHostElement.removeChild(temporaryHostElement.firstChild); - } else if (bufferedContent.startsWith(' tag but does not contain the Doctype declaration. This is likely a bug in React', - ); - } else if (bufferedContent.startsWith(''); + if (closingHeadIndex > -1) { + const [headContent, bodyContent] = bufferedContent.split(''); + parent = document.head; + temporaryHostElement = document.createElement('head'); + temporaryHostElement.innerHTML = headContent; + buffer = bodyContent; + } else { + parent = document.body; + temporaryHostElement = document.createElement('body'); + temporaryHostElement.innerHTML = bufferedContent; + buffer = ''; + } } - temporaryHostElement = document.createElement('html'); - temporaryHostElement.innerHTML = bufferedContent; - } else { - parent = document.body; - temporaryHostElement = document.createElement('body'); - temporaryHostElement.innerHTML = bufferedContent; - } - await withLoadingReadyState(async () => { - while (temporaryHostElement.firstChild) { - parent.appendChild(temporaryHostElement.firstChild); + await withLoadingReadyState(async () => { + while (temporaryHostElement.firstChild) { + parent.appendChild(temporaryHostElement.firstChild); + } + // If there is any async work to do to execute these scripts we await that now. We want + // to do this while the document loading state is overriden so the fizz runtime will + // install it's own mutation observer + await pendingWork(window); + }, document); + + if (buffer === '') { + break; } - // If there is any async work to do to execute these scripts we await that now. We want - // to do this while the document loading state is overriden so the fizz runtime will - // install it's own mutation observer - await pendingWork(window); - }, document); + } removeScriptObserver(document); } @@ -6194,7 +6216,7 @@ describe('ReactDOMFizzServer', () => { 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.', + '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( @@ -6234,5 +6256,119 @@ describe('ReactDOMFizzServer', () => { , ); }); + + it('can render a fallback if the shell errors', async () => { + function Throw() { + throw new Error('uh oh'); + } + + let content = ''; + writable.on('data', chunk => (content += chunk)); + + const errors = []; + await act(() => { + const {pipe} = renderIntoDocumentAsPipeableStream( + + + + + , +
Some Skeleton UI while client renders
, + { + onError(err) { + errors.push(err.message); + }, + }, + ); + pipe(writable); + }); + + expect(errors).toEqual(['uh oh']); + + expect(content.slice(0, 49)).toEqual( + '
Some', + ); + + expect(getMeaningfulChildren(document)).toEqual( + + + +
Some Skeleton UI while client renders
+ + , + ); + }); + + it('can render a fallback if the shell errors even if the preamble has already been flushed', async () => { + function Throw() { + throw new Error('uh oh'); + } + + function BlockOn({value, children}) { + readText(value); + return children; + } + + let content = ''; + writable.on('data', chunk => (content += chunk)); + + const errors = []; + await act(() => { + const {pipe} = renderIntoDocumentAsPipeableStream( + + + + + + + + + , +
Some Skeleton UI while client renders
, + { + onError(err) { + errors.push(err.message); + }, + }, + ); + pipe(writable); + }); + + expect(errors).toEqual([]); + + expect(content.slice(0, 37)).toEqual( + '', + ); + content = ''; + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + , + ); + + await act(() => { + resolveText('foo'); + }); + + expect(errors).toEqual(['uh oh']); + expect(content.slice(0, 33)).toEqual(' + + + + + + +
Some Skeleton UI while client renders
+ + , + ); + }); }); }); diff --git a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js index e12b94ccb1e73..d5071181960c6 100644 --- a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js +++ b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js @@ -140,8 +140,6 @@ export function pushTextInstance( export function pushStartInstance( target: Array, - preamble: Array, - postamble: Array, type: string, props: Object, responseState: ResponseState, @@ -159,9 +157,9 @@ export function pushStartInstance( export function pushEndInstance( target: Array, - postamble: Array, type: string, props: Object, + formatContext: FormatContext, ): void { target.push(END); } @@ -181,6 +179,29 @@ export function writeCompletedRoot( return true; } +export function writeEarlyPreamble( + destination: Destination, + resources: Resources, + responseState: ResponseState, + willEmitInstructions: boolean, +): boolean { + return true; +} + +export function writePreamble( + destination: Destination, + resources: Resources, + responseState: ResponseState, + willEmitInstructions: boolean, +): boolean { + return true; +} + +export function writePostamble( + destination: Destination, + responseState: ResponseState, +): void {} + // IDs are formatted as little endian Uint16 function formatID(id: number): Uint8Array { if (id > 0xffff) { @@ -327,7 +348,7 @@ export function writePreambleClose( preamble: Array, ) {} -export function writeInitialResources( +export function writeResources( destination: Destination, resources: Resources, responseState: ResponseState, @@ -336,14 +357,7 @@ export function writeInitialResources( return true; } -export function writeImmediateResources( - destination: Destination, - resources: Resources, - responseState: ResponseState, - willEmitInstructions: boolean, -): boolean { - return true; -} +export function prepareForFallback(responseState: ResponseState) {} export function hoistResources( resources: Resources, diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index 664bab5856bbc..e2d1b49431f95 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -120,8 +120,6 @@ const ReactNoopServer = ReactFizzServer({ }, pushStartInstance( target: Array, - preamble: Array, - postamble: Array, type: string, props: Object, ): ReactNodeList { @@ -137,9 +135,9 @@ const ReactNoopServer = ReactFizzServer({ pushEndInstance( target: Array, - postamble: Array, type: string, props: Object, + formatContext: null, ): void { target.push(POP); }, @@ -272,11 +270,11 @@ const ReactNoopServer = ReactFizzServer({ boundary.status = 'client-render'; }, - writePreambleOpen() {}, - writePreambleClose() {}, + writeEarlyPreamble() {}, + writePreamble() {}, + writePostamble() {}, - writeInitialResources() {}, - writeImmediateResources() {}, + writeResources() {}, createResources(): Resources { return null; diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 5bce431404e23..319eec5484d31 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -66,8 +66,7 @@ import { assignSuspenseBoundaryID, getRootBoundaryID, getChildFormatContext, - writeInitialResources, - writeImmediateResources, + writeResources, hoistResources, hoistResourcesToRoot, prepareToRender, @@ -75,8 +74,10 @@ import { setCurrentlyRenderingBoundaryResourcesTarget, createResources, createBoundaryResources, - writePreambleOpen, - writePreambleClose, + writeEarlyPreamble, + writePreamble, + writePostamble, + prepareForFallback, } from './ReactServerFormatConfig'; import { constructClassInstance, @@ -218,14 +219,13 @@ export opaque type Request = { pendingRootTasks: number, // when this reaches zero, we've finished at least the root boundary. resources: Resources, completedRootSegment: null | Segment, // Completed but not yet flushed root segments. + fallbackTask: null | Task, // If a Shell fallback is used the task will be stored here abortableTasks: Set, pingedTasks: Array, // High priority tasks that should be worked on first. // Queues to flush in order of priority clientRenderedBoundaries: Array, // Errored or client rendered but not yet flushed. completedBoundaries: Array, // Completed but not yet fully flushed boundaries to show. partialBoundaries: Array, // Partially completed boundaries that can flush its segments early. - +preamble: Array, // Chunks that need to be emitted before any segment chunks. - +postamble: Array, // Chunks that need to be emitted after segments, waiting for all pending root tasks to finish // onError is called when an error happens anywhere in the tree. It might recover. // The return string is used in production primarily to avoid leaking internals, secondarily to save bytes. // Returning null/undefined will cause a defualt error message in production @@ -282,7 +282,7 @@ export function createRequest( const pingedTasks = []; const abortSet: Set = new Set(); const resources: Resources = createResources(); - const request = { + const request: Request = { destination: null, responseState, progressiveChunkSize: @@ -296,13 +296,12 @@ export function createRequest( pendingRootTasks: 0, resources, completedRootSegment: null, + fallbackTask: null, abortableTasks: abortSet, pingedTasks: pingedTasks, clientRenderedBoundaries: ([]: Array), completedBoundaries: ([]: Array), partialBoundaries: ([]: Array), - preamble: ([]: Array), - postamble: ([]: Array), onError: onError === undefined ? defaultErrorHandler : onError, onAllReady: onAllReady === undefined ? noop : onAllReady, onShellReady: onShellReady === undefined ? noop : onShellReady, @@ -344,6 +343,36 @@ export function createRequest( emptyTreeContext, ); pingedTasks.push(rootTask); + + if (fallback) { + const fallbackRootSegment = createPendingSegment( + request, + 0, + null, + rootFormatContext, + // Root segments are never embedded in Text on either edge + false, + false, + ); + fallbackRootSegment.parentFlushed = true; + + // The fallback task is created eagerly with the Request but is + // not queued unless the primary task errors. We give it a separate + // abortSet because we do not want to abort it alongside other tasks + // when something in the shell errors. + const fallbackAbortSet: Set = new Set(); + request.fallbackTask = createTask( + request, + null, + fallback, + null, + fallbackRootSegment, + fallbackAbortSet, + emptyContextObject, + rootContextSnapshot, + emptyTreeContext, + ); + } return request; } @@ -710,8 +739,6 @@ function renderHostElement( const children = pushStartInstance( segment.chunks, - request.preamble, - request.postamble, type, props, request.responseState, @@ -729,7 +756,7 @@ function renderHostElement( // We expect that errors will fatal the whole task and that we don't need // the correct context. Therefore this is not in a finally. segment.formatContext = prevContext; - pushEndInstance(segment.chunks, request.postamble, type, props); + pushEndInstance(segment.chunks, type, props, prevContext); segment.lastPushedText = false; popComponentStackInDEV(task); } @@ -1656,7 +1683,26 @@ function erroredTask( // Report the error to a global handler. const errorDigest = logRecoverableError(request, error); if (boundary === null) { - fatalError(request, error); + // The task is finished so we decrement pendingRootTasks + // We also need to unset the completedRootSegment if it + // was previously set so we don't try to flush the errored + // segment + request.pendingRootTasks--; + request.completedRootSegment = null; + + const fallbackTask = request.fallbackTask; + if (fallbackTask !== null) { + request.fallbackTask = null; + const abortableTasks = request.abortableTasks; + if (abortableTasks.size > 0) { + abortableTasks.forEach(abortTaskSoft, request); + abortableTasks.clear(); + } + prepareForFallback(request.responseState); + request.pingedTasks.push(fallbackTask); + } else { + fatalError(request, error); + } } else { boundary.pendingTasks--; if (!boundary.forceClientRender) { @@ -1784,6 +1830,17 @@ function finishedTask( segment: Segment, ) { if (boundary === null) { + request.pendingRootTasks--; + + const fallbackTask = request.fallbackTask; + if (request.pendingRootTasks === 1 && fallbackTask) { + // When using the fallbackTask we know it is the last root task + // when the pending count hits 1 because we do not enqueue it + // unless the root errors + request.fallbackTask = null; + abortTaskSoft.call(request, fallbackTask); + } + if (segment.parentFlushed) { if (request.completedRootSegment !== null) { throw new Error( @@ -1793,7 +1850,7 @@ function finishedTask( request.completedRootSegment = segment; } - request.pendingRootTasks--; + if (request.pendingRootTasks === 0) { // We have completed the shell so the shell can't error anymore. request.onShellError = noop; @@ -2139,32 +2196,13 @@ function flushSegment( } } -function flushInitialResources( - destination: Destination, - resources: Resources, - responseState: ResponseState, - willEmitInstructions: boolean, -): void { - writeInitialResources( - destination, - resources, - responseState, - willEmitInstructions, - ); -} - function flushImmediateResources( destination: Destination, resources: Resources, responseState: ResponseState, willEmitInstructions: boolean, ): void { - writeImmediateResources( - destination, - resources, - responseState, - willEmitInstructions, - ); + writeResources(destination, resources, responseState, willEmitInstructions); } function flushClientRenderedBoundary( @@ -2304,22 +2342,28 @@ function flushCompletedQueues( if (completedRootSegment !== null) { if (request.pendingRootTasks === 0) { if (enableFloat) { - const preamble = request.preamble; - writePreambleOpen(destination, preamble, request.responseState); const willEmitInstructions = request.allPendingTasks > 0; - flushInitialResources( + writePreamble( destination, request.resources, request.responseState, willEmitInstructions, ); - writePreambleClose(destination, preamble); } flushSegment(request, destination, completedRootSegment); request.completedRootSegment = null; writeCompletedRoot(destination, request.responseState); } else { + if (enableFloat) { + const willEmitInstructions = request.allPendingTasks > 0; + writeEarlyPreamble( + destination, + request.resources, + request.responseState, + willEmitInstructions, + ); + } // We haven't flushed the root yet so we don't need to check any other branches further down return; } @@ -2414,10 +2458,7 @@ function flushCompletedQueues( // either they have pending task or they're complete. ) { if (enableFloat) { - const postamble = request.postamble; - for (let i = 0; i < postamble.length; i++) { - writeChunk(destination, postamble[i]); - } + writePostamble(destination, request.responseState); } completeWriting(destination); flushBuffered(destination); @@ -2475,6 +2516,11 @@ export function abort(request: Request, reason: mixed): void { abortableTasks.forEach(task => abortTask(task, request, error)); abortableTasks.clear(); } + const fallbackTask = request.fallbackTask; + if (fallbackTask) { + request.fallbackTask = null; + abortTaskSoft.call(request, fallbackTask); + } if (request.destination !== null) { flushCompletedQueues(request, request.destination); } diff --git a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js index 3b10f37e5b82e..fd4996e7e1b0d 100644 --- a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js +++ b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js @@ -71,14 +71,15 @@ 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; +export const prepareForFallback = $$$hostConfig.prepareForFallback; // ------------------------- // Resources // ------------------------- -export const writeInitialResources = $$$hostConfig.writeInitialResources; -export const writeImmediateResources = $$$hostConfig.writeImmediateResources; +export const writeEarlyPreamble = $$$hostConfig.writeEarlyPreamble; +export const writePreamble = $$$hostConfig.writePreamble; +export const writePostamble = $$$hostConfig.writePostamble; +export const writeResources = $$$hostConfig.writeResources; export const hoistResources = $$$hostConfig.hoistResources; export const hoistResourcesToRoot = $$$hostConfig.hoistResourcesToRoot; export const createResources = $$$hostConfig.createResources; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 6934cd8a97b90..60401bfd2dabc 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -452,5 +452,7 @@ "464": "ReactDOMServer.renderToStaticNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead.", "465": "enableFizzExternalRuntime without enableFloat is not supported. This should never appear in production, since it means you are using a misconfigured React bundle.", "466": "The root container experienced an error before hydration could begin. The root will switch to client rendering.\n Container Error: \"%s\"", - "467": "React encountered a render instruction it did not recognize." + "467": "React encountered a render instruction it did not recognize.", + "468": "An tag was rendered with a `dangerouslySetInnerHTML` prop while using `renderIntoDocument`. React does not support this; use a `children` prop instead", + "469": "A tag was rendered with a `dangerouslySetInnerHTML` prop while using `renderIntoDocument`. React does not support this; use a `children` prop instead" } From 45a1577c2b8b26fa1dce68337edc0b809c5d8ca2 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Tue, 10 Jan 2023 13:25:35 -0800 Subject: [PATCH 3/7] ensure fallback bootstrap scripts get used in fallback cases --- .../src/server/ReactDOMServerFormatConfig.js | 50 ++- .../ReactDOMServerLegacyFormatConfig.js | 4 + .../src/__tests__/ReactDOMFizzServer-test.js | 402 ++++++++++++++++++ .../ReactDOMFizzServerBrowser-test.js | 22 + .../__tests__/ReactDOMFizzServerNode-test.js | 16 + .../src/server/ReactDOMFizzServerNode.js | 2 +- packages/react-server/src/ReactFizzServer.js | 10 +- 7 files changed, 480 insertions(+), 26 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index e5f39dfe0b168..1d3a9913517d3 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -2442,27 +2442,29 @@ export function writeEarlyPreamble( // If we emitted a preamble early it will have flushed and . // We check that we haven't flushed anything yet which is equivalent // to checking whether we have not flushed an or - if (responseState.flushed === NONE && responseState.rendered !== NONE) { - let i = 0; - const {htmlChunks, headChunks} = responseState; - if (htmlChunks.length) { - for (i = 0; i < htmlChunks.length; i++) { - writeChunk(destination, htmlChunks[i]); + if (responseState.rendered !== NONE) { + if (responseState.flushed === NONE) { + let i = 0; + const {htmlChunks, headChunks} = responseState; + if (htmlChunks.length) { + for (i = 0; i < htmlChunks.length; i++) { + writeChunk(destination, htmlChunks[i]); + } + } else { + writeChunk(destination, DOCTYPE); + writeChunk(destination, startChunkForTag('html')); + writeChunk(destination, endOfStartTag); } - } else { - writeChunk(destination, DOCTYPE); - writeChunk(destination, startChunkForTag('html')); - writeChunk(destination, endOfStartTag); - } - if (headChunks.length) { - for (i = 0; i < headChunks.length; i++) { - writeChunk(destination, headChunks[i]); + if (headChunks.length) { + for (i = 0; i < headChunks.length; i++) { + writeChunk(destination, headChunks[i]); + } + } else { + writeChunk(destination, startChunkForTag('head')); + writeChunk(destination, endOfStartTag); } - } else { - writeChunk(destination, startChunkForTag('head')); - writeChunk(destination, endOfStartTag); + responseState.flushed |= HTML | HEAD; } - responseState.flushed |= HTML | HEAD; return writeEarlyResources( destination, @@ -2587,14 +2589,16 @@ export function prepareForFallback(responseState: ResponseState): void { if (__DEV__) { (responseState: any).inFallbackDEV = true; } - // This function would ideally check somethign to see whether embedding - // was required however at the moment the only time we use a Request Fallback - // is when we use renderIntoDocument which is the only variant where which - // utilizes fallback children. We assume we're in that mode if this function - // is called and update the requirement accordingly + // Reset rendered states responseState.htmlChunks = []; responseState.headChunks = []; responseState.rendered = NONE; + + // Move fallback bootstrap to bootstrap if configured + const fallbackBootstrapChunks = responseState.fallbackBootstrapChunks; + if (fallbackBootstrapChunks && fallbackBootstrapChunks.length) { + responseState.bootstrapChunks = fallbackBootstrapChunks; + } } export function writeCompletedRoot( diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js index 84dd47e706f94..cfc0d3140c435 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js @@ -44,6 +44,8 @@ export type ResponseState = { requiresEmbedding: boolean, rendered: DocumentStructureTag, flushed: DocumentStructureTag, + charsetChunks: Array, + hoistableChunks: Array, placeholderPrefix: PrecomputedChunk, segmentPrefix: PrecomputedChunk, boundaryPrefix: string, @@ -87,6 +89,8 @@ export function createResponseState( requiresEmbedding: false, rendered: NONE, flushed: NONE, + charsetChunks: [], + hoistableChunks: [], placeholderPrefix: responseState.placeholderPrefix, segmentPrefix: responseState.segmentPrefix, boundaryPrefix: responseState.boundaryPrefix, diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 6510c8fea2a35..335e4b8842af2 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -212,6 +212,12 @@ describe('ReactDOMFizzServer', () => { while (true) { const bufferedContent = buffer; + document.__headOpen = + document.__headOpen || + ((bufferedContent.includes('') || + bufferedContent.includes('')); + let parent; let temporaryHostElement; if (bufferedContent.startsWith('')) { @@ -255,6 +261,12 @@ describe('ReactDOMFizzServer', () => { temporaryHostElement = document.createElement('head'); temporaryHostElement.innerHTML = headContent; buffer = bodyContent; + document.__headOpen = false; + } else if (document.__headOpen) { + parent = document.head; + temporaryHostElement = document.createElement('head'); + temporaryHostElement.innerHTML = bufferedContent; + buffer = ''; } else { parent = document.body; temporaryHostElement = document.createElement('body'); @@ -6154,6 +6166,7 @@ describe('ReactDOMFizzServer', () => { }); describe('renderIntoDocument', () => { + // @gate enableFloat && enableFizzIntoDocument it('can render arbitrary HTML into a Document', async () => { let content = ''; writable.on('data', chunk => (content += chunk)); @@ -6178,6 +6191,7 @@ describe('ReactDOMFizzServer', () => { ); }); + // @gate enableFloat && enableFizzIntoDocument it('can render into a Document', async () => { let content = ''; writable.on('data', chunk => (content += chunk)); @@ -6200,6 +6214,7 @@ describe('ReactDOMFizzServer', () => { ); }); + // @gate enableFloat && enableFizzIntoDocument it('can render into a Document', async () => { let content = ''; writable.on('data', chunk => (content += chunk)); @@ -6233,6 +6248,7 @@ describe('ReactDOMFizzServer', () => { ); }); + // @gate enableFloat && enableFizzIntoDocument it('inserts an empty head when rendering if no is provided', async () => { let content = ''; writable.on('data', chunk => (content += chunk)); @@ -6257,6 +6273,7 @@ describe('ReactDOMFizzServer', () => { ); }); + // @gate enableFloat && enableFizzIntoDocument it('can render a fallback if the shell errors', async () => { function Throw() { throw new Error('uh oh'); @@ -6299,6 +6316,7 @@ describe('ReactDOMFizzServer', () => { ); }); + // @gate enableFloat && enableFizzIntoDocument it('can render a fallback if the shell errors even if the preamble has already been flushed', async () => { function Throw() { throw new Error('uh oh'); @@ -6370,5 +6388,389 @@ describe('ReactDOMFizzServer', () => { , ); }); + + // @gate enableFloat && enableFizzIntoDocument + it('can render an empty fallback', async () => { + function Throw() { + throw new Error('uh oh'); + } + + let content = ''; + writable.on('data', chunk => (content += chunk)); + + let didBootstrap = false; + function bootstrap() { + didBootstrap = true; + } + window.__INIT__ = bootstrap; + + const errors = []; + await act(() => { + const {pipe} = renderIntoDocumentAsPipeableStream( + + + + + + + , + undefined, + { + onError(err) { + errors.push(err.message); + }, + fallbackBootstrapScriptContent: '__INIT__()', + }, + ); + pipe(writable); + }); + + expect(errors).toEqual(['uh oh']); + expect(didBootstrap).toBe(true); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + , + ); + }); + + // @gate enableFloat && enableFizzIntoDocument + it('emits fallback bootstrap scripts if configured when rendering the fallback shell', async () => { + function Throw() { + throw new Error('uh oh'); + } + + let didBootstrap = false; + function bootstrap() { + didBootstrap = true; + } + window.__INIT__ = bootstrap; + + let didFallback = false; + function fallback() { + didFallback = true; + } + window.__FALLBACK_INIT__ = fallback; + + const errors = []; + await act(() => { + const {pipe} = renderIntoDocumentAsPipeableStream( + + + hello world + + + , +
fallback
, + { + onError(err) { + errors.push(err.message); + }, + bootstrapScriptContent: '__INIT__();', + fallbackBootstrapScriptContent: '__FALLBACK_INIT__();', + }, + ); + pipe(writable); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + +
fallback
+ + , + ); + + expect(didBootstrap).toBe(false); + expect(didFallback).toBe(true); + }); + + // @gate enableFloat && enableFizzIntoDocument + it('emits bootstrap scripts if no fallback bootstrap scripts are configured when rendering the fallback shell', async () => { + function Throw() { + throw new Error('uh oh'); + } + + let didBootstrap = false; + function bootstrap() { + didBootstrap = true; + } + window.__INIT__ = bootstrap; + + const errors = []; + await act(() => { + const {pipe} = renderIntoDocumentAsPipeableStream( + + + + hello world + + , +
fallback
, + { + onError(err) { + errors.push(err.message); + }, + bootstrapScriptContent: '__INIT__();', + }, + ); + pipe(writable); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + +
fallback
+ + , + ); + + expect(didBootstrap).toBe(true); + }); + + // @gate enableFloat && enableFizzIntoDocument + it('does not work on the fallback unless the primary children error in the shell', async () => { + function Throw() { + throw new Error('uh oh'); + } + + const logs = []; + function BlockOn({value, children}) { + readText(value); + logs.push(value); + return children; + } + + const errors = []; + await act(() => { + const {pipe} = renderIntoDocumentAsPipeableStream( + + + + + + + + + hello world + + , +
+ fallback +
, + { + onError(err) { + errors.push(err.message); + }, + }, + ); + pipe(writable); + }); + + expect(logs).toEqual([]); + logs.length = 0; + expect(getMeaningfulChildren(document)).toEqual( + + + + , + ); + + // Even though we unblock fallback since the task is not scheduled no log is observed + await act(() => { + resolveText('fallback'); + }); + expect(logs).toEqual([]); + logs.length = 0; + expect(getMeaningfulChildren(document)).toEqual( + + + + , + ); + + // When we resolve the resource it is emitted in the open preamble. + await act(() => { + resolveText('resource'); + }); + expect(logs).toEqual(['resource']); + logs.length = 0; + expect(getMeaningfulChildren(document)).toEqual( + + + + + + , + ); + + // When we resolve the resource it is emitted in the open preamble. + await act(() => { + resolveText('error'); + }); + expect(logs).toEqual(['error', 'fallback']); + logs.length = 0; + expect(getMeaningfulChildren(document)).toEqual( + + + + + +
fallback
+ + , + ); + }); + + // @gate enableFloat && enableFizzIntoDocument + it('only emits stylesheets up to the first precedence during the early preamble', async () => { + function BlockOn({value, children}) { + readText(value); + return children; + } + + await act(() => { + const {pipe} = renderIntoDocumentAsPipeableStream( + + + + + + + ']); }); - describe('HostResource', () => { - // @gate enableFloat - it('warns when you update props to an invalid type', async () => { - const root = ReactDOMClient.createRoot(container); - root.render( -
- - -
, - ); - expect(Scheduler).toFlushWithoutYielding(); - root.render( -
- {}} href="bar" /> - {}} /> -
, - ); - expect(() => { - expect(Scheduler).toFlushWithoutYielding(); - }).toErrorDev([ - 'Warning: A previously rendered as a Resource with href "foo" with rel ""stylesheet"" but was updated with an invalid rel: something with type "function". When a link does not have a valid rel prop it is not represented in the DOM. If this is intentional, instead do not render the anymore.', - 'Warning: A previously rendered as a Resource with href "bar" but was updated with an invalid href prop: something with type "function". When a link does not have a valid href prop it is not represented in the DOM. If this is intentional, instead do not render the anymore.', - ]); - expect(getMeaningfulChildren(document)).toEqual( - - - - - - - - -
-
-
- - , - ); - }); - }); - describe('ReactDOM.preload', () => { // @gate enableFloat it('inserts a preload resource into the stream when called during server rendering', async () => { @@ -1426,11 +1385,11 @@ describe('ReactDOMFloat', () => { expect(getMeaningfulChildren(document)).toEqual( - - + + @@ -1467,11 +1426,11 @@ describe('ReactDOMFloat', () => { expect(getMeaningfulChildren(document)).toEqual( - - + + @@ -1506,64 +1465,6 @@ describe('ReactDOMFloat', () => { ); }); - // @gate enableFloat - it('can render as a Resource', async () => { - await actIntoEmptyDocument(() => { - const {pipe} = renderToPipeableStream( - - - - - - -
hello world
- - , - ); - pipe(writable); - }); - expect(getMeaningfulChildren(document)).toEqual( - - - - - - - -
hello world
- - , - ); - - ReactDOMClient.hydrateRoot( - document, - - - - - - - -
hello world
- - , - ); - expect(Scheduler).toFlushWithoutYielding(); - expect(getMeaningfulChildren(document)).toEqual( - - - - - - - - -
hello world
- - , - ); - }); - // @gate enableFloat it('can render icons and apple-touch-icons as Resources', async () => { await actIntoEmptyDocument(() => { @@ -1840,11 +1741,6 @@ describe('ReactDOMFloat', () => { property="og:description" content="my site" /> - @@ -1854,169 +1750,12 @@ describe('ReactDOMFloat', () => { - - -
hello world
- - , - ); - }); - - // @gate enableFloat - it('can render meta tags with og properties with structured data', async () => { - await actIntoEmptyDocument(() => { - const {pipe} = renderToPipeableStream( - <> - - - -
hello world
- - - - - - - - - , - ); - pipe(writable); - }); - expect(getMeaningfulChildren(document)).toEqual( - - - - - - - - - - -
hello world
- - , - ); - - const root = ReactDOMClient.hydrateRoot( - document, - - - - - - - - - -
hello world
- - , - ); - expect(Scheduler).toFlushWithoutYielding(); - expect(getMeaningfulChildren(document)).toEqual( - - - - - - - - - - -
hello world
- - , - ); - - root.render( - - - - - - - - -
hello world
- - , - ); - expect(Scheduler).toFlushWithoutYielding(); - expect(getMeaningfulChildren(document)).toEqual( - - - - - - - - - -
hello world
- - , - ); - - root.render( - - - - - - - - - -
hello world
- - , - ); - expect(Scheduler).toFlushWithoutYielding(); - expect(getMeaningfulChildren(document)).toEqual( - - - - - - - - - - -
hello world
- - , - ); - - root.render( - - - - - - - - - - -
hello world
- - , - ); - expect(Scheduler).toFlushWithoutYielding(); - expect(getMeaningfulChildren(document)).toEqual( - - - - - - - - - + +
hello world
@@ -2221,64 +1960,6 @@ describe('ReactDOMFloat', () => { ); }); - // @gate enableFloat - it('keys titles on text children and only removes them when no more instances refer to that title', async () => { - const root = ReactDOMClient.createRoot(container); - root.render( -
- {[2]}hello world2 -
, - ); - expect(Scheduler).toFlushWithoutYielding(); - expect(getMeaningfulChildren(document)).toEqual( - - - 2 - - -
-
hello world
-
- - , - ); - - root.render( -
- {null}hello world2 -
, - ); - expect(Scheduler).toFlushWithoutYielding(); - expect(getMeaningfulChildren(document)).toEqual( - - - 2 - - -
-
hello world
-
- - , - ); - root.render( -
- {null}hello world{null} -
, - ); - expect(Scheduler).toFlushWithoutYielding(); - expect(getMeaningfulChildren(document)).toEqual( - - - -
-
hello world
-
- - , - ); - }); - // @gate enableFloat && enableHostSingletons && (enableClientRenderFallbackOnTextMismatch || !__DEV__) it('can render a title before a singleton even if that singleton clears its contents', async () => { await actIntoEmptyDocument(() => { @@ -5758,7 +5439,6 @@ describe('ReactDOMFloat', () => { bar