From 3ec1561ff4433e7406f2c254e6c81a59ee2b7c70 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Fri, 6 Jan 2023 16:54:07 -0800 Subject: [PATCH] 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/ReactDOMFloatServer.js | 9 +- .../src/server/ReactDOMServerFormatConfig.js | 672 ++++++++++++++-- .../ReactDOMServerLegacyFormatConfig.js | 24 +- 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 | 737 +++++++++++++++++- .../ReactDOMFizzServerBrowser-test.js | 24 +- .../__tests__/ReactDOMFizzServerNode-test.js | 18 +- .../ReactDOMFizzStaticBrowser-test.js | 2 +- .../__tests__/ReactDOMFizzStaticNode-test.js | 2 +- .../src/__tests__/ReactDOMFloat-test.js | 2 +- .../server/ReactDOMFizzServerBrowser.fb.js | 1 + .../src/server/ReactDOMFizzServerBrowser.js | 1 + .../server/ReactDOMFizzServerBrowserImpl.js | 94 ++- .../src/server/ReactDOMFizzServerBun.fb.js | 1 + .../src/server/ReactDOMFizzServerBun.js | 1 + .../src/server/ReactDOMFizzServerBunImpl.js | 91 +++ .../src/server/ReactDOMFizzServerNode.fb.js | 1 + .../src/server/ReactDOMFizzServerNode.js | 1 + .../src/server/ReactDOMFizzServerNodeImpl.js | 80 ++ .../src/server/ReactDOMFizzStaticBrowser.js | 1 + .../src/server/ReactDOMFizzStaticNode.js | 1 + .../src/server/ReactDOMLegacyServerImpl.js | 1 + .../server/ReactDOMLegacyServerNodeStream.js | 1 + .../server/ReactNativeServerFormatConfig.js | 44 +- .../src/ReactNoopServer.js | 10 +- .../src/ReactDOMServerFB.js | 1 + packages/react-server/src/ReactFizzServer.js | 139 +++- .../forks/ReactServerFormatConfig.custom.js | 7 +- 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 + scripts/error-codes/codes.json | 4 +- 43 files changed, 1843 insertions(+), 168 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 a2cfe93023e8e..7c27ada97ba85 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -119,10 +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, + rendered: DocumentStructureTag, + flushed: DocumentStructureTag, placeholderPrefix: PrecomputedChunk, segmentPrefix: PrecomputedChunk, boundaryPrefix: string, @@ -197,6 +210,7 @@ export function createResponseState( string | BootstrapScriptDescriptor, > | void, externalRuntimeConfig: string | BootstrapScriptDescriptor | void, + documentEmbedding: boolean | void, ): ResponseState { const idPrefix = identifierPrefix === undefined ? '' : identifierPrefix; const inlineScriptWithNonce = @@ -333,6 +347,11 @@ export function createResponseState( fallbackBootstrapChunks: fallbackBootstrapChunks.length ? fallbackBootstrapChunks : undefined, + htmlChunks: [], + headChunks: [], + requiresEmbedding: documentEmbedding === true, + rendered: NONE, + flushed: NONE, placeholderPrefix: stringToPrecomputedChunk(idPrefix + 'P:'), segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'), boundaryPrefix: idPrefix + 'B:', @@ -353,17 +372,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 = { @@ -465,12 +487,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; } @@ -1649,37 +1673,237 @@ function pushStartTitle( return children; } +function pushStartHtml( + target: 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, - preamble: Array, props: Object, - tag: string, responseState: ResponseState, + formatContext: FormatContext, ): ReactNodeList { - return pushStartGenericElement( - enableFloat ? preamble : target, - props, - tag, - responseState, - ); + if (enableFloat && formatContext.insertionMode <= HTML_HTML_MODE) { + responseState.rendered |= HEAD; + let children = null; + let innerHTML = null; + let attributePropsIncluded = false; + + 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]; + if (propValue == null) { + continue; + } + switch (propKey) { + case 'children': + children = propValue; + break; + case 'dangerouslySetInnerHTML': + innerHTML = propValue; + break; + default: + if (__DEV__) { + attributePropsIncluded = true; + } + pushAttribute(headChunks, responseState, propKey, propValue); + break; + } + } + } + headChunks.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__) { + attributePropsIncluded = true; + } + break; + } + } + } + } + + if (__DEV__) { + 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' + + ' 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( +function pushStartBody( target: Array, - preamble: Array, props: Object, - tag: string, responseState: ResponseState, formatContext: FormatContext, ): ReactNodeList { - 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, tag, responseState); + return pushStartGenericElement(target, props, 'body', responseState); } function pushScript( @@ -1973,7 +2197,6 @@ const DOCTYPE: PrecomputedChunk = stringToPrecomputedChunk(''); export function pushStartInstance( target: Array, - preamble: Array, type: string, props: Object, responseState: ResponseState, @@ -2017,6 +2240,10 @@ export function pushStartInstance( } } + if (formatContext.insertionMode === ROOT_HTML_MODE) { + responseState.rendered |= FLOW; + } + switch (type) { // Special tags case 'select': @@ -2104,19 +2331,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, type, responseState); - case 'html': { - return pushStartHtml( - target, - preamble, - props, - type, - 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 @@ -2134,9 +2355,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 @@ -2170,24 +2391,209 @@ 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); } +// 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, + resources: Resources, + responseState: ResponseState, + 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.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); + } + 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, + ); + } + } + } + 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.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 writePostamble( + destination: Destination, + responseState: ResponseState, +): void { + 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; + } + // 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( destination: Destination, responseState: ResponseState, @@ -2418,6 +2824,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); @@ -2475,6 +2882,7 @@ export function writeEndSegment( ): boolean { switch (formatContext.insertionMode) { case ROOT_HTML_MODE: + case HTML_HTML_MODE: case HTML_MODE: { return writeChunkAndReturn(destination, endSegmentHTML); } @@ -3018,7 +3426,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, @@ -3052,6 +3597,8 @@ export function writeInitialResources( bases, preconnects, fontPreloads, + firstPrecedence, + firstPrecedenceFlushed, precedences, usedStylePreloads, scripts, @@ -3089,13 +3636,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 { @@ -3157,7 +3715,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 2c2eb8f50bb0d..acb05e5bc3b4a 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js @@ -11,6 +11,7 @@ import type { BootstrapScriptDescriptor, FormatContext, StreamingFormat, + DocumentStructureTag, } from './ReactDOMServerFormatConfig'; import { @@ -22,6 +23,7 @@ import { writeEndCompletedSuspenseBoundary as writeEndCompletedSuspenseBoundaryImpl, writeEndClientRenderedSuspenseBoundary as writeEndClientRenderedSuspenseBoundaryImpl, HTML_MODE, + NONE, } from './ReactDOMServerFormatConfig'; import type { @@ -36,6 +38,13 @@ export type ResponseState = { // Keep this in sync with ReactDOMServerFormatConfig bootstrapChunks: Array, fallbackBootstrapChunks: void | Array, + htmlChunks: Array, + headChunks: Array, + requiresEmbedding: boolean, + rendered: DocumentStructureTag, + flushed: DocumentStructureTag, + charsetChunks: Array, + hoistableChunks: Array, placeholderPrefix: PrecomputedChunk, segmentPrefix: PrecomputedChunk, boundaryPrefix: string, @@ -73,6 +82,13 @@ export function createResponseState( // Keep this in sync with ReactDOMServerFormatConfig bootstrapChunks: responseState.bootstrapChunks, fallbackBootstrapChunks: responseState.fallbackBootstrapChunks, + htmlChunks: [], + headChunks: [], + requiresEmbedding: false, + rendered: NONE, + flushed: NONE, + charsetChunks: [], + hoistableChunks: [], placeholderPrefix: responseState.placeholderPrefix, segmentPrefix: responseState.segmentPrefix, boundaryPrefix: responseState.boundaryPrefix, @@ -126,15 +142,19 @@ export { writePlaceholder, writeCompletedRoot, writeErroredRoot, + createRootBoundaryID, createResources, createBoundaryResources, - writeInitialResources, - writeImmediateResources, + writeResources, hoistResources, hoistResourcesToRoot, setCurrentlyRenderingBoundaryResourcesTarget, prepareToRender, cleanupAfterRender, + writeEarlyPreamble, + writePreamble, + writePostamble, + prepareForFallback, } 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..335e4b8842af2 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -209,52 +209,86 @@ 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; + + document.__headOpen = + document.__headOpen || + ((bufferedContent.includes('') || + bufferedContent.includes('')); + + 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; + 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'); + temporaryHostElement.innerHTML = bufferedContent; + buffer = ''; + } + } - 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); } @@ -398,6 +432,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 +6164,613 @@ describe('ReactDOMFizzServer', () => { ); }); }); + + describe('renderIntoDocument', () => { + // @gate enableFloat && enableFizzIntoDocument + 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
+ + , + ); + }); + + // @gate enableFloat && enableFizzIntoDocument + 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 + , + ); + }); + + // @gate enableFloat && enableFizzIntoDocument + 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 + , + ); + }); + + // @gate enableFloat && enableFizzIntoDocument + 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 + , + ); + }); + + // @gate enableFloat && enableFizzIntoDocument + 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
+ + , + ); + }); + + // @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'); + } + + 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
+ + , + ); + }); + + // @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( + + + + + + + foobar', + 'foobar', '', ]); }); diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.fb.js b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.fb.js index 45ee152ab5d02..719808c33fd94 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.fb.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.fb.js @@ -9,6 +9,7 @@ export { renderIntoContainer, + renderIntoDocument, renderToReadableStream, version, } from './ReactDOMFizzServerBrowserImpl'; diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js index 45ee152ab5d02..719808c33fd94 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerBrowser.js @@ -9,6 +9,7 @@ export { renderIntoContainer, + renderIntoDocument, renderToReadableStream, version, } from './ReactDOMFizzServerBrowserImpl'; diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBrowserImpl.js b/packages/react-dom/src/server/ReactDOMFizzServerBrowserImpl.js index 1a87c6baa61e1..1990975745602 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerBrowserImpl.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerBrowserImpl.js @@ -82,6 +82,7 @@ function renderToReadableStream( } const request = createRequest( children, + undefined, // fallback createResponseState( options ? options.identifierPrefix : undefined, options ? options.nonce : undefined, @@ -92,6 +93,7 @@ function renderToReadableStream( undefined, // fallbackBootstrapScripts undefined, // fallbackBootstrapModules options ? options.unstable_externalRuntimeSrc : undefined, + undefined, // documentEmbedding ), createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, @@ -148,6 +150,7 @@ function renderIntoContainer( const request = createRequest( children, + undefined, // fallback createResponseState( options ? options.identifierPrefix : undefined, options ? options.nonce : undefined, @@ -158,6 +161,7 @@ function renderIntoContainer( options ? options.fallbackBootstrapScripts : undefined, options ? options.fallbackBootstrapModules : undefined, options ? options.unstable_externalRuntimeSrc : undefined, + undefined, // documentEmbedding ), createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, @@ -200,4 +204,92 @@ function renderIntoContainer( return stream; } -export {renderToReadableStream, renderIntoContainer, ReactVersion as version}; +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, + 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; +} + +export { + renderToReadableStream, + renderIntoContainer, + renderIntoDocument, + ReactVersion as version, +}; diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBun.fb.js b/packages/react-dom/src/server/ReactDOMFizzServerBun.fb.js index f42144ad49ff8..eac24ec8dafdf 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerBun.fb.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerBun.fb.js @@ -9,6 +9,7 @@ export { renderIntoContainer, + renderIntoDocument, renderToReadableStream, renderToNodeStream, renderToStaticNodeStream, diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBun.js b/packages/react-dom/src/server/ReactDOMFizzServerBun.js index f42144ad49ff8..eac24ec8dafdf 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerBun.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerBun.js @@ -9,6 +9,7 @@ export { renderIntoContainer, + renderIntoDocument, renderToReadableStream, renderToNodeStream, renderToStaticNodeStream, diff --git a/packages/react-dom/src/server/ReactDOMFizzServerBunImpl.js b/packages/react-dom/src/server/ReactDOMFizzServerBunImpl.js index a90f79ccc237e..be59bbdeee19b 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerBunImpl.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerBunImpl.js @@ -83,6 +83,7 @@ function renderToReadableStream( } const request = createRequest( children, + undefined, // fallback createResponseState( options ? options.identifierPrefix : undefined, options ? options.nonce : undefined, @@ -93,6 +94,7 @@ function renderToReadableStream( undefined, // fallbackBootstrapScripts undefined, // fallbackBootstrapModules options ? options.unstable_externalRuntimeSrc : undefined, + undefined, // documentEmbedding ), createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, @@ -150,6 +152,7 @@ function renderIntoContainer( const request = createRequest( children, + undefined, // fallback createResponseState( options ? options.identifierPrefix : undefined, options ? options.nonce : undefined, @@ -160,6 +163,7 @@ function renderIntoContainer( options ? options.fallbackBootstrapScripts : undefined, options ? options.fallbackBootstrapModules : undefined, options ? options.unstable_externalRuntimeSrc : undefined, + undefined, // documentEmbedding ), createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, @@ -204,6 +208,92 @@ function 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, + 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; + }); +} + function renderToNodeStream() { throw new Error( 'ReactDOMServer.renderToNodeStream(): The Node Stream API is not available ' + @@ -221,6 +311,7 @@ function renderToStaticNodeStream() { export { renderToReadableStream, renderIntoContainer, + renderIntoDocument, renderToNodeStream, renderToStaticNodeStream, ReactVersion as version, diff --git a/packages/react-dom/src/server/ReactDOMFizzServerNode.fb.js b/packages/react-dom/src/server/ReactDOMFizzServerNode.fb.js index eecdc78f6cb92..425cf49aa0bf7 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerNode.fb.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerNode.fb.js @@ -9,6 +9,7 @@ export { renderIntoContainerAsPipeableStream, + renderIntoDocumentAsPipeableStream, renderToPipeableStream, version, } from './ReactDOMFizzServerNodeImpl'; diff --git a/packages/react-dom/src/server/ReactDOMFizzServerNode.js b/packages/react-dom/src/server/ReactDOMFizzServerNode.js index eecdc78f6cb92..425cf49aa0bf7 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerNode.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerNode.js @@ -9,6 +9,7 @@ export { renderIntoContainerAsPipeableStream, + renderIntoDocumentAsPipeableStream, renderToPipeableStream, version, } from './ReactDOMFizzServerNodeImpl'; diff --git a/packages/react-dom/src/server/ReactDOMFizzServerNodeImpl.js b/packages/react-dom/src/server/ReactDOMFizzServerNodeImpl.js index cc48a7bdb4353..36141ecad2759 100644 --- a/packages/react-dom/src/server/ReactDOMFizzServerNodeImpl.js +++ b/packages/react-dom/src/server/ReactDOMFizzServerNodeImpl.js @@ -65,6 +65,7 @@ function renderToPipeableStream( ): PipeableStream { const request = createRequest( children, + undefined, // fallback createResponseState( options ? options.identifierPrefix : undefined, options ? options.nonce : undefined, @@ -75,6 +76,7 @@ function renderToPipeableStream( undefined, // fallbackBootstrapScripts undefined, // fallbackBootstrapModules options ? options.unstable_externalRuntimeSrc : undefined, + undefined, // documentEmbedding ), createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, @@ -139,6 +141,7 @@ function renderIntoContainerAsPipeableStream( ): PipeableStream { const request = createRequest( children, + undefined, // fallback createResponseState( options ? options.identifierPrefix : undefined, options ? options.nonce : undefined, @@ -149,6 +152,7 @@ function renderIntoContainerAsPipeableStream( options ? options.fallbackBootstrapScripts : undefined, options ? options.fallbackBootstrapModules : undefined, options ? options.unstable_externalRuntimeSrc : undefined, + undefined, // documentEmbedding ), createRootFormatContext(options ? options.namespaceURI : undefined), options ? options.progressiveChunkSize : undefined, @@ -190,8 +194,84 @@ function 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, + 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); + }, + }; +} + export { renderToPipeableStream, renderIntoContainerAsPipeableStream, + renderIntoDocumentAsPipeableStream, ReactVersion as version, }; diff --git a/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js b/packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js index e25ae8ee4249c..f291a3a292d7a 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 430a59768f30f..c29f1ce545334 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 d440e611de4a4..897fe339f98b4 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 75613acb51cc1..b4ca7d8455479 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 ec8562415d6d4..5e2d3337692f4 100644 --- a/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js +++ b/packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js @@ -140,7 +140,6 @@ export function pushTextInstance( export function pushStartInstance( target: Array, - preamble: Array, type: string, props: Object, responseState: ResponseState, @@ -158,9 +157,9 @@ export function pushStartInstance( export function pushEndInstance( target: Array, - postamble: Array, type: string, props: Object, + formatContext: FormatContext, ): void { target.push(END); } @@ -187,6 +186,29 @@ export function writeErroredRoot( 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) { @@ -334,16 +356,18 @@ export function writeClientRenderBoundaryInstruction( return writeChunkAndReturn(destination, formatID(boundaryID)); } -export function writeInitialResources( +export function writePreambleOpen( destination: Destination, - resources: Resources, + preamble: Array, responseState: ResponseState, - willEmitInstructions: boolean, -): boolean { - return true; -} +) {} + +export function writePreambleClose( + destination: Destination, + preamble: Array, +) {} -export function writeImmediateResources( +export function writeResources( destination: Destination, resources: Resources, responseState: ResponseState, @@ -352,6 +376,8 @@ export function writeImmediateResources( return true; } +export function prepareForFallback(responseState: ResponseState) {} + export function hoistResources( resources: Resources, boundaryResources: BoundaryResources, diff --git a/packages/react-noop-renderer/src/ReactNoopServer.js b/packages/react-noop-renderer/src/ReactNoopServer.js index 153786cb9919a..e2d1b49431f95 100644 --- a/packages/react-noop-renderer/src/ReactNoopServer.js +++ b/packages/react-noop-renderer/src/ReactNoopServer.js @@ -120,7 +120,6 @@ const ReactNoopServer = ReactFizzServer({ }, pushStartInstance( target: Array, - preamble: Array, type: string, props: Object, ): ReactNodeList { @@ -136,9 +135,9 @@ const ReactNoopServer = ReactFizzServer({ pushEndInstance( target: Array, - postamble: Array, type: string, props: Object, + formatContext: null, ): void { target.push(POP); }, @@ -271,8 +270,11 @@ const ReactNoopServer = ReactFizzServer({ boundary.status = 'client-render'; }, - writeInitialResources() {}, - writeImmediateResources() {}, + writeEarlyPreamble() {}, + writePreamble() {}, + writePostamble() {}, + + writeResources() {}, createResources(): Resources { return null; diff --git a/packages/react-server-dom-relay/src/ReactDOMServerFB.js b/packages/react-server-dom-relay/src/ReactDOMServerFB.js index b50a7e23afaec..af2f2a07e08b4 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 674cb813603bd..2445f4a106206 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -67,8 +67,7 @@ import { UNINITIALIZED_SUSPENSE_BOUNDARY_ID, assignSuspenseBoundaryID, getChildFormatContext, - writeInitialResources, - writeImmediateResources, + writeResources, hoistResources, hoistResourcesToRoot, prepareToRender, @@ -76,6 +75,10 @@ import { setCurrentlyRenderingBoundaryResourcesTarget, createResources, createBoundaryResources, + writeEarlyPreamble, + writePreamble, + writePostamble, + prepareForFallback, } from './ReactServerFormatConfig'; import { constructClassInstance, @@ -218,6 +221,7 @@ 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 @@ -225,8 +229,6 @@ export opaque type Request = { 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 @@ -270,6 +272,7 @@ function noop(): void {} export function createRequest( children: ReactNodeList, + fallback: void | ReactNodeList, responseState: ResponseState, rootFormatContext: FormatContext, progressiveChunkSize: void | number, @@ -283,7 +286,7 @@ export function createRequest( const pingedTasks = []; const abortSet: Set = new Set(); const resources: Resources = createResources(); - const request = { + const request: Request = { destination: null, responseState, progressiveChunkSize: @@ -297,14 +300,13 @@ export function createRequest( pendingRootTasks: 0, resources, completedRootSegment: null, + fallbackTask: null, abortableTasks: abortSet, pingedTasks: pingedTasks, rootBoundary: (null: null | SuspenseBoundary), 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, @@ -347,6 +349,39 @@ export function createRequest( emptyTreeContext, ); pingedTasks.push(rootTask); + + // null is a valid fallback so we distinguish between undefined and null here + // If you use renderIntoDocument without a fallback argument the Request still + // has a null fallback and will exhibit fallback behavior + if (fallback !== undefined) { + 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; } @@ -713,7 +748,6 @@ function renderHostElement( const children = pushStartInstance( segment.chunks, - request.preamble, type, props, request.responseState, @@ -731,7 +765,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); } @@ -1658,7 +1692,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) { @@ -1786,7 +1839,21 @@ function finishedTask( segment: Segment, ) { if (boundary === null) { - if (segment.parentFlushed) { + 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); + } + + // When the fallbackTask is aborted it still gets finished. We need to ensure we only + // set the completedRootSegment if the segment is not aborted to avoid having more than + // one set at a time. + if (segment.parentFlushed && segment.status !== ABORTED) { if (request.completedRootSegment !== null) { throw new Error( 'There can only be one root segment. This is a bug in React.', @@ -1795,7 +1862,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; @@ -2141,32 +2208,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( @@ -2334,14 +2382,8 @@ function flushCompletedQueues( if (completedRootSegment !== null) { 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]); - } - const willEmitInstructions = request.allPendingTasks > 0; - flushInitialResources( + writePreamble( destination, request.resources, request.responseState, @@ -2353,6 +2395,15 @@ function flushCompletedQueues( 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; } @@ -2465,10 +2516,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); @@ -2526,6 +2574,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 8199d9321d047..70a40c51ec64c 100644 --- a/packages/react-server/src/forks/ReactServerFormatConfig.custom.js +++ b/packages/react-server/src/forks/ReactServerFormatConfig.custom.js @@ -73,12 +73,15 @@ export const writeClientRenderBoundaryInstruction = $$$hostConfig.writeClientRenderBoundaryInstruction; export const prepareToRender = $$$hostConfig.prepareToRender; export const cleanupAfterRender = $$$hostConfig.cleanupAfterRender; +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/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 7950cd6948957..fc979d510382e 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 = true; +export const enableFizzIntoDocument = true; // 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 57712c2dc14cb..9cbedb7fa39f2 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 = true; +export const enableFizzIntoDocument = true; // 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 907d46a715990..0bd7ed1cd7b71 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 = true; +export const enableFizzIntoDocument = true; // 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 db1528e90a856..386bdfacb4308 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 = true; +export const enableFizzIntoDocument = true; // 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 64aaf3d9de838..c2ee633485ed9 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 = true; +export const enableFizzIntoDocument = true; // 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 6b1da74669b1d..50c78fc415787 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 = true; +export const enableFizzIntoDocument = true; // 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 322c2b3b3d967..ef958b1cc3e38 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 = true; +export const enableFizzIntoDocument = true; // 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 3197144bb18a3..1f896f807110a 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 = true; +export const enableFizzIntoDocument = true; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); 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" }