diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 042d6897c6aae..7e9a43056c649 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -206,6 +206,8 @@ const SUSPENSE_START_DATA = '$'; const SUSPENSE_END_DATA = '/$'; const SUSPENSE_PENDING_START_DATA = '$?'; const SUSPENSE_FALLBACK_START_DATA = '$!'; +const PREAMBLE_CONTRIBUTION_MARKER = 'P'; +const PREAMBLE_CONTRIBUTION_INDICATOR = '1'; const FORM_STATE_IS_MATCHING = 'F!'; const FORM_STATE_IS_NOT_MATCHING = 'F'; @@ -986,6 +988,34 @@ export function clearSuspenseBoundary( data === SUSPENSE_FALLBACK_START_DATA ) { depth++; + } else if (data[0] === PREAMBLE_CONTRIBUTION_MARKER) { + const ownerDocument = parentInstance.ownerDocument; + const documentElement: Element = (ownerDocument.documentElement: any); + const head: Element = (ownerDocument.head: any); + const body: Element = (ownerDocument.body: any); + + if (data.length === 1) { + // this boundary contributed the entire preamble. + releaseSingletonInstance(documentElement); + releaseSingletonInstance(head); + releaseSingletonInstance(body); + // We need to clear the head because this is the only singleton that can have children that + // were part of this boundary but are not inside this boundary. + clearHead(head); + } else { + if (data[1] === PREAMBLE_CONTRIBUTION_INDICATOR) { + releaseSingletonInstance(documentElement); + } + if (data[2] === PREAMBLE_CONTRIBUTION_INDICATOR) { + releaseSingletonInstance(head); + // We need to clear the head because this is the only singleton that can have children that + // were part of this boundary but are not inside this boundary. + clearHead(head); + } + if (data[3] === PREAMBLE_CONTRIBUTION_INDICATOR) { + releaseSingletonInstance(body); + } + } } } // $FlowFixMe[incompatible-type] we bail out when we get a null @@ -1499,7 +1529,7 @@ function clearContainerSparingly(container: Node) { case 'STYLE': { continue; } - // Stylesheet tags are retained because tehy may likely come from 3rd party scripts and extensions + // Stylesheet tags are retained because they may likely come from 3rd party scripts and extensions case 'LINK': { if (((node: any): HTMLLinkElement).rel.toLowerCase() === 'stylesheet') { continue; @@ -1511,6 +1541,27 @@ function clearContainerSparingly(container: Node) { return; } +function clearHead(head: Element): void { + let node = head.firstChild; + while (node) { + const nextNode = node.nextSibling; + const nodeName = node.nodeName; + if ( + isMarkedHoistable(node) || + nodeName === 'SCRIPT' || + nodeName === 'STYLE' || + (nodeName === 'LINK' && + ((node: any): HTMLLinkElement).rel.toLowerCase() === 'stylesheet') + ) { + // retain these nodes + } else { + head.removeChild(node); + } + node = nextNode; + } + return; +} + // Making this so we can eventually move all of the instance caching to the commit phase. // Currently this is only used to associate fiber and props to instances for hydrating // HostSingletons. The reason we need it here is we only want to make this binding on commit @@ -1872,7 +1923,20 @@ export function getFirstHydratableChild( export function getFirstHydratableChildWithinContainer( parentContainer: Container, ): null | HydratableInstance { - return getNextHydratable(parentContainer.firstChild); + let parentElement: Element; + switch (parentContainer.nodeType) { + case DOCUMENT_NODE: + parentElement = (parentContainer: any).body; + break; + default: { + if (parentContainer.nodeName === 'HTML') { + parentElement = (parentContainer: any).ownerDocument.body; + } else { + parentElement = (parentContainer: any); + } + } + } + return getNextHydratable(parentElement.firstChild); } export function getFirstHydratableChildWithinSuspenseInstance( @@ -1881,6 +1945,40 @@ export function getFirstHydratableChildWithinSuspenseInstance( return getNextHydratable(parentInstance.nextSibling); } +// If it were possible to have more than one scope singleton in a DOM tree +// we would need to model this as a stack but since you can only have one +// and head is the only singleton that is a scope in DOM we can get away with +// tracking this as a single value. +let previousHydratableOnEnteringScopedSingleton: null | HydratableInstance = + null; + +export function getFirstHydratableChildWithinSingleton( + type: string, + singletonInstance: Instance, + currentHydratableInstance: null | HydratableInstance, +): null | HydratableInstance { + if (isSingletonScope(type)) { + previousHydratableOnEnteringScopedSingleton = currentHydratableInstance; + return getNextHydratable(singletonInstance.firstChild); + } else { + return currentHydratableInstance; + } +} + +export function getNextHydratableSiblingAfterSingleton( + type: string, + currentHydratableInstance: null | HydratableInstance, +): null | HydratableInstance { + if (isSingletonScope(type)) { + const previousHydratableInstance = + previousHydratableOnEnteringScopedSingleton; + previousHydratableOnEnteringScopedSingleton = null; + return previousHydratableInstance; + } else { + return currentHydratableInstance; + } +} + export function describeHydratableInstanceForDevWarnings( instance: HydratableInstance, ): string | {type: string, props: $ReadOnly} { diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 4b8841a06e67b..2a06560bcb626 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -684,16 +684,25 @@ export function completeResumableState(resumableState: ResumableState): void { resumableState.bootstrapModules = undefined; } +const NoContribution /* */ = 0b000; +const HTMLContribution /* */ = 0b001; +const HeadContribution /* */ = 0b010; +const BodyContribution /* */ = 0b100; +const TotalContribution = + HTMLContribution | HeadContribution | BodyContribution; + export type PreambleState = { htmlChunks: null | Array, headChunks: null | Array, bodyChunks: null | Array, + contribution: number, }; export function createPreambleState(): PreambleState { return { htmlChunks: null, headChunks: null, bodyChunks: null, + contribution: NoContribution, }; } @@ -3227,7 +3236,7 @@ function pushStartHead( throw new Error(`The ${'``'} tag may only be rendered once.`); } preamble.headChunks = []; - return pushStartGenericElement(preamble.headChunks, props, 'head'); + return pushStartSingletonElement(preamble.headChunks, props, 'head'); } else { // This is deep and is likely just an error. we emit it inline though. // Validation should warn that this tag is the the wrong spot. @@ -3251,7 +3260,7 @@ function pushStartBody( } preamble.bodyChunks = []; - return pushStartGenericElement(preamble.bodyChunks, props, 'body'); + return pushStartSingletonElement(preamble.bodyChunks, props, 'body'); } else { // This is deep and is likely just an error. we emit it inline though. // Validation should warn that this tag is the the wrong spot. @@ -3275,7 +3284,7 @@ function pushStartHtml( } preamble.htmlChunks = [DOCTYPE]; - return pushStartGenericElement(preamble.htmlChunks, props, 'html'); + return pushStartSingletonElement(preamble.htmlChunks, props, 'html'); } else { // This is deep and is likely just an error. we emit it inline though. // Validation should warn that this tag is the the wrong spot. @@ -3416,6 +3425,43 @@ function pushScriptImpl( return null; } +// This is a fork of pushStartGenericElement because we don't ever want to do +// the children as strign optimization on that path when rendering singletons. +// When we eliminate that special path we can delete this fork and unify it again +function pushStartSingletonElement( + target: Array, + props: Object, + tag: string, +): ReactNodeList { + target.push(startChunkForTag(tag)); + + let children = null; + let innerHTML = null; + 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: + pushAttribute(target, propKey, propValue); + break; + } + } + } + + target.push(endOfStartTag); + pushInnerHTML(target, innerHTML, children); + return children; +} + function pushStartGenericElement( target: Array, props: Object, @@ -3907,14 +3953,17 @@ export function hoistPreambleState( preambleState: PreambleState, ) { const rootPreamble = renderState.preamble; - if (rootPreamble.htmlChunks === null) { + if (rootPreamble.htmlChunks === null && preambleState.htmlChunks) { rootPreamble.htmlChunks = preambleState.htmlChunks; + preambleState.contribution |= HTMLContribution; } - if (rootPreamble.headChunks === null) { + if (rootPreamble.headChunks === null && preambleState.headChunks) { rootPreamble.headChunks = preambleState.headChunks; + preambleState.contribution |= HeadContribution; } - if (rootPreamble.bodyChunks === null) { + if (rootPreamble.bodyChunks === null && preambleState.bodyChunks) { rootPreamble.bodyChunks = preambleState.bodyChunks; + preambleState.contribution |= BodyContribution; } } @@ -4005,6 +4054,14 @@ const clientRenderedSuspenseBoundaryError1D = const clientRenderedSuspenseBoundaryError2 = stringToPrecomputedChunk('>'); +const boundaryPreambleContributionChunkTotal = + stringToPrecomputedChunk(''); +const boundaryPreambleContributionChunkStart = + stringToPrecomputedChunk(''); + export function writeStartCompletedSuspenseBoundary( destination: Destination, renderState: RenderState, @@ -4091,7 +4148,11 @@ export function writeStartClientRenderedSuspenseBoundary( export function writeEndCompletedSuspenseBoundary( destination: Destination, renderState: RenderState, + preambleState: null | PreambleState, ): boolean { + if (preambleState) { + writePreambleContribution(destination, preambleState); + } return writeChunkAndReturn(destination, endSuspenseBoundary); } export function writeEndPendingSuspenseBoundary( @@ -4103,9 +4164,47 @@ export function writeEndPendingSuspenseBoundary( export function writeEndClientRenderedSuspenseBoundary( destination: Destination, renderState: RenderState, + preambleState: null | PreambleState, ): boolean { + if (preambleState) { + writePreambleContribution(destination, preambleState); + } return writeChunkAndReturn(destination, endSuspenseBoundary); } +function writePreambleContribution( + destination: Destination, + preambleState: PreambleState, +) { + const contribution = preambleState.contribution; + if (contribution !== NoContribution) { + if (contribution === TotalContribution) { + // We shortcut the preamble marker for boundaries that contribute the entire preamble + // since we expect this to be the most common case + writeChunk(destination, boundaryPreambleContributionChunkTotal); + } else { + writeChunk(destination, boundaryPreambleContributionChunkStart); + writeChunk( + destination, + contribution & HTMLContribution + ? ContributionChunk + : NonContributionChunk, + ); + writeChunk( + destination, + contribution & HeadContribution + ? ContributionChunk + : NonContributionChunk, + ); + writeChunk( + destination, + contribution & BodyContribution + ? ContributionChunk + : NonContributionChunk, + ); + writeChunk(destination, boundaryPreambleContributionChunkEnd); + } + } +} const startSegmentHTML = stringToPrecomputedChunk('