diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js b/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js index 88caebd8c69c5..84ed3bf2da1cf 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js @@ -7,11 +7,7 @@ * @flow */ -import type { - FloatRoot, - StyleResource, - ScriptResource, -} from './ReactDOMFloatClient'; +import type {FloatRoot, RootResources} from './ReactDOMFloatClient'; import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; import type {ReactScopeInstance} from 'shared/ReactTypes'; import type { @@ -53,6 +49,7 @@ const internalEventHandlersKey = '__reactEvents$' + randomKey; const internalEventHandlerListenersKey = '__reactListeners$' + randomKey; const internalEventHandlesSetKey = '__reactHandles$' + randomKey; const internalRootNodeResourcesKey = '__reactResources$' + randomKey; +const internalResourceMarker = '__reactMarker$' + randomKey; export function detachDeletedInstance(node: Instance): void { // TODO: This function is only called on host components. I don't think all of @@ -282,15 +279,22 @@ export function doesTargetHaveEventHandle( return eventHandles.has(eventHandle); } -export function getResourcesFromRoot( - root: FloatRoot, -): {styles: Map, scripts: Map} { +export function getResourcesFromRoot(root: FloatRoot): RootResources { let resources = (root: any)[internalRootNodeResourcesKey]; if (!resources) { resources = (root: any)[internalRootNodeResourcesKey] = { styles: new Map(), scripts: new Map(), + head: new Map(), }; } return resources; } + +export function isMarkedResource(node: Node): boolean { + return !!(node: any)[internalResourceMarker]; +} + +export function markNodeAsResource(node: Node) { + (node: any)[internalResourceMarker] = true; +} diff --git a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js index ffb0e21b30e83..1461feaf0eb66 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js +++ b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js @@ -24,7 +24,10 @@ import { validatePreinitArguments, } from '../shared/ReactDOMResourceValidation'; import {createElement, setInitialProperties} from './ReactDOMComponent'; -import {getResourcesFromRoot} from './ReactDOMComponentTree'; +import { + getResourcesFromRoot, + markNodeAsResource, +} from './ReactDOMComponentTree'; import {HTML_NAMESPACE} from '../shared/DOMNamespaces'; import {getCurrentRootHostContainer} from 'react-reconciler/src/ReactFiberHostContext'; @@ -85,9 +88,28 @@ export type ScriptResource = { root: FloatRoot, }; +type HeadProps = { + [string]: mixed, +}; +export type HeadResource = { + type: 'head', + instanceType: string, + props: HeadProps, + + count: number, + instance: ?Element, + root: Document, +}; + type Props = {[string]: mixed}; -type Resource = StyleResource | ScriptResource | PreloadResource; +type Resource = StyleResource | ScriptResource | PreloadResource | HeadResource; + +export type RootResources = { + styles: Map, + scripts: Map, + head: Map, +}; // Brief on purpose due to insertion by script when streaming late boundaries // s = Status @@ -370,6 +392,10 @@ type ScriptQualifyingProps = { [string]: mixed, }; +function getTitleKey(child: string | number): string { + return 'title:' + child; +} + // This function is called in begin work and we should always have a currentDocument set export function getResource( type: string, @@ -383,6 +409,30 @@ export function getResource( ); } switch (type) { + case 'title': { + let child = pendingProps.children; + if (Array.isArray(child) && child.length === 1) { + child = child[0]; + } + if (typeof child === 'string' || typeof child === 'number') { + const headRoot: Document = getDocumentFromRoot(resourceRoot); + const headResources = getResourcesFromRoot(headRoot).head; + const key = getTitleKey(child); + let resource = headResources.get(key); + if (!resource) { + const titleProps = titlePropsFromRawProps(child, pendingProps); + resource = createHeadResource( + headResources, + headRoot, + 'title', + key, + titleProps, + ); + } + return resource; + } + return null; + } case 'link': { const {rel} = pendingProps; switch (rel) { @@ -535,6 +585,15 @@ function preloadPropsFromRawProps( return Object.assign({}, rawBorrowedProps); } +function titlePropsFromRawProps( + child: string | number, + rawProps: Props, +): HeadProps { + const props: HeadProps = Object.assign({}, rawProps); + props.children = child; + return props; +} + function stylePropsFromRawProps(rawProps: StyleQualifyingProps): StyleProps { const props: StyleProps = Object.assign({}, rawProps); props['data-precedence'] = rawProps.precedence; @@ -554,6 +613,9 @@ function scriptPropsFromRawProps(rawProps: ScriptQualifyingProps): ScriptProps { export function acquireResource(resource: Resource): Instance { switch (resource.type) { + case 'head': { + return acquireHeadResource(resource); + } case 'style': { return acquireStyleResource(resource); } @@ -571,11 +633,27 @@ export function acquireResource(resource: Resource): Instance { } } -export function releaseResource(resource: Resource) { +export function releaseResource(resource: Resource): void { switch (resource.type) { + case 'head': { + return releaseHeadResource(resource); + } case 'style': { resource.count--; + return; + } + } +} + +function releaseHeadResource(resource: HeadResource): void { + if (--resource.count === 0) { + // the instance will have existed since we acquired it + const instance: Instance = (resource.instance: any); + const parent = instance.parentNode; + if (parent) { + parent.removeChild(instance); } + resource.instance = null; } } @@ -586,9 +664,39 @@ function createResourceInstance( ): Instance { const element = createElement(type, props, ownerDocument, HTML_NAMESPACE); setInitialProperties(element, type, props); + markNodeAsResource(element); return element; } +function createHeadResource( + headResources: Map, + root: Document, + instanceType: string, + key: string, + props: HeadProps, +): HeadResource { + if (__DEV__) { + if (headResources.has(key)) { + console.error( + 'createHeadResource was called when a head Resource matching the same key already exists. This is a bug in React.', + ); + } + } + + const resource: HeadResource = { + type: 'head', + instanceType, + props, + + count: 0, + instance: null, + root, + }; + + headResources.set(key, resource); + return resource; +} + function createStyleResource( styleResources: Map, root: FloatRoot, @@ -754,6 +862,8 @@ function createScriptResource( (resource: any)._dev_preload_props = preloadProps; } } + } else { + markNodeAsResource(existingEl); } return resource; @@ -784,7 +894,9 @@ function createPreloadResource( ); if (!element) { element = createResourceInstance('link', props, ownerDocument); - insertResourceInstance(element, ownerDocument); + appendResourceInstance(element, ownerDocument); + } else { + markNodeAsResource(element); } return { type: 'preload', @@ -795,8 +907,41 @@ function createPreloadResource( }; } +function acquireHeadResource(resource: HeadResource): Instance { + resource.count++; + let instance = resource.instance; + if (!instance) { + const {props, root, instanceType} = resource; + switch (instanceType) { + case 'title': { + const titles = root.querySelectorAll('title'); + for (let i = 0; i < titles.length; i++) { + if (titles[i].textContent === props.children) { + instance = resource.instance = titles[i]; + markNodeAsResource(instance); + return instance; + } + } + } + } + instance = resource.instance = createResourceInstance( + instanceType, + props, + root, + ); + + if (instanceType === 'title') { + prependResourceInstance(instance, root); + } else { + appendResourceInstance(instance, root); + } + } + return instance; +} + function acquireStyleResource(resource: StyleResource): Instance { - if (!resource.instance) { + let instance = resource.instance; + if (!instance) { const {props, root, precedence} = resource; const limitedEscapedHref = escapeSelectorAttributeValueInsideDoubleQuotes( props.href, @@ -805,7 +950,8 @@ function acquireStyleResource(resource: StyleResource): Instance { `link[rel="stylesheet"][data-precedence][href="${limitedEscapedHref}"]`, ); if (existingEl) { - resource.instance = existingEl; + instance = resource.instance = existingEl; + markNodeAsResource(instance); resource.preloaded = true; const loadingState: ?StyleResourceLoadingState = (existingEl: any)._p; if (loadingState) { @@ -830,7 +976,7 @@ function acquireStyleResource(resource: StyleResource): Instance { resource.loaded = true; } } else { - const instance = createResourceInstance( + instance = resource.instance = createResourceInstance( 'link', resource.props, getDocumentFromRoot(root), @@ -838,16 +984,15 @@ function acquireStyleResource(resource: StyleResource): Instance { attachLoadListeners(instance, resource); insertStyleInstance(instance, precedence, root); - resource.instance = instance; } } resource.count++; - // $FlowFixMe[incompatible-return] found when upgrading Flow - return resource.instance; + return instance; } function acquireScriptResource(resource: ScriptResource): Instance { - if (!resource.instance) { + let instance = resource.instance; + if (!instance) { const {props, root} = resource; const limitedEscapedSrc = escapeSelectorAttributeValueInsideDoubleQuotes( props.src, @@ -856,19 +1001,19 @@ function acquireScriptResource(resource: ScriptResource): Instance { `script[async][src="${limitedEscapedSrc}"]`, ); if (existingEl) { - resource.instance = existingEl; + instance = resource.instance = existingEl; + markNodeAsResource(instance); } else { - const instance = createResourceInstance( + instance = resource.instance = createResourceInstance( 'script', resource.props, getDocumentFromRoot(root), ); - insertResourceInstance(instance, getDocumentFromRoot(root)); - resource.instance = instance; + appendResourceInstance(instance, getDocumentFromRoot(root)); } } - return resource.instance; + return instance; } function attachLoadListeners(instance: Instance, resource: StyleResource) { @@ -968,14 +1113,38 @@ function insertStyleInstance( } } -function insertResourceInstance( +function prependResourceInstance( instance: Instance, ownerDocument: Document, ): void { if (__DEV__) { if (instance.tagName === 'LINK' && (instance: any).rel === 'stylesheet') { console.error( - 'insertResourceInstance was called with a stylesheet. Stylesheets must be' + + 'prependResourceInstance was called with a stylesheet. Stylesheets must be' + + ' inserted with insertStyleInstance instead. This is a bug in React.', + ); + } + } + + const parent = ownerDocument.head; + if (parent) { + parent.insertBefore(instance, parent.firstChild); + } else { + throw new Error( + 'While attempting to insert a Resource, React expected the Document to contain' + + ' a head element but it was not found.', + ); + } +} + +function appendResourceInstance( + instance: Instance, + ownerDocument: Document, +): void { + if (__DEV__) { + if (instance.tagName === 'LINK' && (instance: any).rel === 'stylesheet') { + console.error( + 'appendResourceInstance was called with a stylesheet. Stylesheets must be' + ' inserted with insertStyleInstance instead. This is a bug in React.', ); } @@ -993,6 +1162,9 @@ function insertResourceInstance( export function isHostResourceType(type: string, props: Props): boolean { switch (type) { + case 'title': { + return true; + } case 'link': { switch (props.rel) { case 'stylesheet': { diff --git a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js index 3a6c83681bf83..9add4a8714074 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js @@ -25,6 +25,7 @@ import { getInstanceFromNode as getInstanceFromNodeDOMTree, isContainerMarkedAsRoot, detachDeletedInstance, + isMarkedResource, } from './ReactDOMComponentTree'; export {detachDeletedInstance}; import {hasRole} from './DOMAccessibilityRoles'; @@ -58,6 +59,7 @@ import { TEXT_NODE, COMMENT_NODE, DOCUMENT_NODE, + DOCUMENT_TYPE_NODE, DOCUMENT_FRAGMENT_NODE, } from '../shared/HTMLNodeType'; import dangerousStyleValue from '../shared/dangerousStyleValue'; @@ -711,50 +713,15 @@ export function unhideTextInstance( export function clearContainer(container: Container): void { if (enableHostSingletons) { - // We have refined the container to Element type const nodeType = container.nodeType; if (nodeType === DOCUMENT_NODE || nodeType === ELEMENT_NODE) { switch (container.nodeName) { case '#document': case 'HTML': case 'HEAD': - case 'BODY': { - let node = container.firstChild; - while (node) { - const nextNode = node.nextSibling; - const nodeName = node.nodeName; - switch (nodeName) { - case 'HTML': - case 'HEAD': - case 'BODY': { - clearContainer((node: any)); - // If these singleton instances had previously been rendered with React they - // may still hold on to references to the previous fiber tree. We detatch them - // prospectiveyl to reset them to a baseline starting state since we cannot create - // new instances. - detachDeletedInstance((node: any)); - break; - } - case 'STYLE': { - break; - } - case 'LINK': { - if ( - ((node: any): HTMLLinkElement).rel.toLowerCase() === - 'stylesheet' - ) { - break; - } - } - // eslint-disable-next-line no-fallthrough - default: { - container.removeChild(node); - } - } - node = nextNode; - } + case 'BODY': + clearContainerChildren(container); return; - } default: { container.textContent = ''; } @@ -775,6 +742,42 @@ export function clearContainer(container: Container): void { } } +function clearContainerChildren(container: Node) { + let node; + let nextNode: ?Node = container.firstChild; + if (nextNode && nextNode.nodeType === DOCUMENT_TYPE_NODE) { + nextNode = nextNode.nextSibling; + } + while (nextNode) { + node = nextNode; + nextNode = nextNode.nextSibling; + switch (node.nodeName) { + case 'HTML': + case 'HEAD': + case 'BODY': { + const element: Element = (node: any); + clearContainerChildren(element); + // If these singleton instances had previously been rendered with React they + // may still hold on to references to the previous fiber tree. We detatch them + // prospectively to reset them to a baseline starting state since we cannot create + // new instances. + detachDeletedInstance(element); + continue; + } + case 'STYLE': { + continue; + } + case 'LINK': { + if (((node: any): HTMLLinkElement).rel.toLowerCase() === 'stylesheet') { + continue; + } + } + } + container.removeChild(node); + } + 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 @@ -923,6 +926,7 @@ function getNextHydratable(node) { } break; } + case 'TITLE': case 'HTML': case 'HEAD': case 'BODY': { @@ -948,6 +952,9 @@ function getNextHydratable(node) { } break; } + case 'TITLE': { + continue; + } case 'STYLE': { const styleEl: HTMLStyleElement = (element: any); if (styleEl.hasAttribute('data-precedence')) { @@ -1666,9 +1673,8 @@ export function clearSingleton(instance: Instance): void { while (node) { const nextNode = node.nextSibling; const nodeName = node.nodeName; - if (getInstanceFromNodeDOMTree(node)) { - // retain nodes owned by React - } else if ( + if ( + isMarkedResource(node) || nodeName === 'HEAD' || nodeName === 'BODY' || nodeName === 'STYLE' || diff --git a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js index 52e566673d08b..f21b5a00b3ed2 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js +++ b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js @@ -68,6 +68,18 @@ type ScriptResource = { hint: PreloadResource, }; +type HeadProps = { + [string]: mixed, +}; +type HeadResource = { + type: 'head', + instanceType: string, + props: HeadProps, + + flushed: boolean, + allowLate: boolean, +}; + export type Resource = PreloadResource | StyleResource | ScriptResource; export type Resources = { @@ -75,8 +87,10 @@ export type Resources = { preloadsMap: Map, stylesMap: Map, scriptsMap: Map, + headsMap: Map, // Flushing queues for Resource dependencies + charset: null | HeadResource, fontPreloads: Set, // usedImagePreloads: Set, precedences: Map>, @@ -86,6 +100,7 @@ export type Resources = { explicitStylePreloads: Set, // explicitImagePreloads: Set, explicitScriptPreloads: Set, + headResources: Set, // Module-global-like reference for current boundary resources boundaryResources: ?BoundaryResources, @@ -99,8 +114,10 @@ export function createResources(): Resources { preloadsMap: new Map(), stylesMap: new Map(), scriptsMap: new Map(), + headsMap: new Map(), // cleared on flush + charset: null, fontPreloads: new Set(), // usedImagePreloads: new Set(), precedences: new Map(), @@ -110,6 +127,7 @@ export function createResources(): Resources { explicitStylePreloads: new Set(), // explicitImagePreloads: new Set(), explicitScriptPreloads: new Set(), + headResources: new Set(), // like a module global for currently rendering boundary boundaryResources: null, @@ -563,6 +581,74 @@ function adoptPreloadPropsForScriptProps( resourceProps.integrity = preloadProps.integrity; } +function createHeadResource( + resources: Resources, + key: string, + instanceType: string, + props: HeadProps, +): HeadResource { + if (__DEV__) { + if (resources.headsMap.has(key)) { + console.error( + 'createScriptResource was called when a script Resource matching the same src already exists. This is a bug in React.', + ); + } + } + + const resource: HeadResource = { + type: 'head', + instanceType, + props, + + flushed: false, + allowLate: true, + }; + resources.headsMap.set(key, resource); + return resource; +} + +function getTitleKey(child: string | number): string { + return 'title' + child; +} + +function titlePropsFromRawProps( + child: string | number, + rawProps: Props, +): HeadProps { + const props = Object.assign({}, rawProps); + props.children = child; + return props; +} + +export function resourcesFromElement(type: string, props: Props): boolean { + if (!currentResources) { + throw new Error( + '"currentResources" was expected to exist. This is a bug in React.', + ); + } + const resources = currentResources; + switch (type) { + case 'title': { + let child = props.children; + if (Array.isArray(child) && child.length === 1) { + child = child[0]; + } + if (typeof child === 'string' || typeof child === 'number') { + const key = getTitleKey(child); + let resource = resources.headsMap.get(key); + if (!resource) { + const titleProps = titlePropsFromRawProps(child, props); + resource = createHeadResource(resources, key, 'title', titleProps); + resources.headResources.add(resource); + } + return true; + } + return false; + } + } + return false; +} + // Construct a resource from link props. export function resourcesFromLink(props: Props): boolean { if (!currentResources) { diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index cc376b3c5c707..5bef5fe3f6c25 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -64,6 +64,7 @@ import isArray from 'shared/isArray'; import { prepareToRenderResources, finishRenderingResources, + resourcesFromElement, resourcesFromLink, resourcesFromScript, ReactDOMServerDispatcher, @@ -1277,6 +1278,20 @@ function pushStartTitle( target: Array, props: Object, responseState: ResponseState, +): ReactNodeList { + if (enableFloat && resourcesFromElement('title', props)) { + // We have converted this link exclusively to a resource and no longer + // need to emit it + return null; + } + + return pushStartTitleImpl(target, props, responseState); +} + +function pushStartTitleImpl( + target: Array, + props: Object, + responseState: ResponseState, ): ReactNodeList { target.push(startChunkForTag('title')); @@ -2310,6 +2325,7 @@ export function writeInitialResources( usedScriptPreloads, explicitStylePreloads, explicitScriptPreloads, + headResources, } = resources; fontPreloads.forEach(r => { @@ -2360,6 +2376,18 @@ export function writeInitialResources( explicitScriptPreloads.forEach(flushLinkResource); explicitScriptPreloads.clear(); + headResources.forEach(r => { + if (r.instanceType === 'title') { + pushStartTitleImpl(target, r.props, responseState); + if (typeof r.props.children === 'string') { + target.push(escapeTextForBrowser(stringToChunk(r.props.children))); + } + pushEndInstance(target, target, 'title', r.props); + } + r.flushed = true; + }); + headResources.clear(); + let i; let r = true; for (i = 0; i < target.length - 1; i++) { @@ -2392,6 +2420,7 @@ export function writeImmediateResources( usedScriptPreloads, explicitStylePreloads, explicitScriptPreloads, + headResources, } = resources; fontPreloads.forEach(r => { @@ -2422,6 +2451,18 @@ export function writeImmediateResources( explicitScriptPreloads.forEach(flushLinkResource); explicitScriptPreloads.clear(); + headResources.forEach(r => { + if (r.instanceType === 'title') { + pushStartTitle(target, r.props, responseState); + if (typeof r.props.children === 'string') { + target.push(escapeTextForBrowser(stringToChunk(r.props.children))); + } + pushEndInstance(target, target, 'title', r.props); + } + r.flushed = true; + }); + headResources.clear(); + let i; let r = true; for (i = 0; i < target.length - 1; i++) { diff --git a/packages/react-dom-bindings/src/shared/HTMLNodeType.js b/packages/react-dom-bindings/src/shared/HTMLNodeType.js index 5494e4d6beb9f..34cd894944c12 100644 --- a/packages/react-dom-bindings/src/shared/HTMLNodeType.js +++ b/packages/react-dom-bindings/src/shared/HTMLNodeType.js @@ -15,4 +15,5 @@ export const ELEMENT_NODE = 1; export const TEXT_NODE = 3; export const COMMENT_NODE = 8; export const DOCUMENT_NODE = 9; +export const DOCUMENT_TYPE_NODE = 10; export const DOCUMENT_FRAGMENT_NODE = 11; diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 0a598aecd00ab..1153ac42498dc 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -4968,18 +4968,27 @@ describe('ReactDOMFizzServer', () => { }, }); expect(Scheduler).toFlushAndYield([]); - expect(errors).toEqual( - [ - gate(flags => flags.enableClientRenderFallbackOnTextMismatch) - ? 'Text content does not match server-rendered HTML.' - : null, - 'Hydration failed because the initial UI does not match what was rendered on the server.', - 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', - ].filter(Boolean), - ); - expect(getVisibleChildren(container)).toEqual( - {['hello1', 'hello2']}, - ); + if (gate(flags => flags.enableFloat)) { + expect(errors).toEqual([]); + // with float, the title doesn't render on the client because it is not a simple child + // we end up seeing the server rendered title + expect(getVisibleChildren(container)).toEqual( + {'hello1<!-- -->hello2'}, + ); + } else { + expect(errors).toEqual( + [ + gate(flags => flags.enableClientRenderFallbackOnTextMismatch) + ? 'Text content does not match server-rendered HTML.' + : null, + 'Hydration failed because the initial UI does not match what was rendered on the server.', + 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', + ].filter(Boolean), + ); + expect(getVisibleChildren(container)).toEqual( + {['hello1', 'hello2']}, + ); + } } finally { console.error = originalConsoleError; } diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 8f124c5dcbd9f..7c022571fff12 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -235,6 +235,80 @@ describe('ReactDOMFloat', () => { return readText(text); } + // @gate enableFloat + it('can render resources before singletons', async () => { + const root = ReactDOMClient.createRoot(document); + root.render( + <> + foo + + + + + hello world + + , + ); + try { + expect(Scheduler).toFlushWithoutYielding(); + } catch (e) { + // for DOMExceptions that happen when expecting this test to fail we need + // to clear the scheduler first otherwise the expected failure will fail + expect(Scheduler).toFlushWithoutYielding(); + throw e; + } + expect(getMeaningfulChildren(document)).toEqual( + + + foo + + + hello world + , + ); + }); + + // @gate enableFloat + it('can acquire a resource after releasing it in the same commit', async () => { + const root = ReactDOMClient.createRoot(container); + root.render( + <> + foo + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + foo + + +
+ + , + ); + + // title is keyed off children so this second resource should match the first one + root.render( + <> + {null} + foo + , + ); + expect(Scheduler).toFlushWithoutYielding(); + // we don't see the attribute because the resource is the same and was not reconstructed + expect(getMeaningfulChildren(document)).toEqual( + + + foo + + +
+ + , + ); + }); + // @gate enableFloat it('errors if the document does not contain a head when inserting a resource', async () => { document.head.parentNode.removeChild(document.head); @@ -866,6 +940,261 @@ describe('ReactDOMFloat', () => { }); }); + describe('head resources', () => { + // @gate enableFloat + it('can rendering title tags anywhere in the tree', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + <> + before + <> + + + in head + + +
+ during + hello world +
+ + + + after + , + ); + pipe(writable); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + before + in head + during + after + + +
hello world
+ + , + ); + + ReactDOMClient.hydrateRoot( + document, + <> + before + <> + + + in head + + +
+ during + hello world +
+ + + + after + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + before + in head + during + after + + +
hello world
+ + , + ); + }); + + // @gate enableFloat + it('prepends new titles on the client so newer ones override older ones, including orphaned server rendered titles', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + server + + +
hello world
+ + , + ); + pipe(writable); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + server + + +
hello world
+ + , + ); + + ReactDOMClient.hydrateRoot( + document, + + html + + head + + + body +
hello world
+ + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + body + head + html + server + + +
hello world
+ + , + ); + }); + + // @gate enableFloat + it('keys titles on text children and only removes them when no more instances refer to that title', async () => { + const root = ReactDOMClient.createRoot(container); + root.render( +
+ {[2]}hello world2 +
, + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + 2 + + +
+
hello world
+
+ + , + ); + + root.render( +
+ {null}hello world2 +
, + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + 2 + + +
+
hello world
+
+ + , + ); + root.render( +
+ {null}hello world{null} +
, + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + +
+
hello world
+
+ + , + ); + }); + + // @gate enableFloat + it('can render a title before a singleton even if that singleton clears its contents', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + <> + foo + + + +
server
+ + + , + , + ); + pipe(writable); + }); + + const errors = []; + ReactDOMClient.hydrateRoot( + document, + <> + foo + + + +
client
+ + + , + { + onRecoverableError(err) { + errors.push(err.message); + }, + }, + ); + try { + expect(() => { + expect(Scheduler).toFlushWithoutYielding(); + }).toErrorDev( + [ + 'Warning: Text content did not match. Server: "server" Client: "client"', + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.', + ], + {withoutStack: 1}, + ); + } catch (e) { + // When gates are false this test fails on a DOMException if you don't clear the scheduler after catching. + // When gates are true this branch should not be hit + expect(Scheduler).toFlushWithoutYielding(); + throw e; + } + expect(getMeaningfulChildren(document)).toEqual( + + + foo + + +
client
+ + , + ); + }); + }); + describe('style resources', () => { // @gate enableFloat it('treats link rel stylesheet elements as a style resource when it includes a precedence when server rendering', async () => { @@ -1229,6 +1558,7 @@ describe('ReactDOMFloat', () => { + hello , diff --git a/packages/react-dom/src/__tests__/ReactDOMSingletonComponents-test.js b/packages/react-dom/src/__tests__/ReactDOMSingletonComponents-test.js index 74b3228254698..5c1cf629ef4e1 100644 --- a/packages/react-dom/src/__tests__/ReactDOMSingletonComponents-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMSingletonComponents-test.js @@ -155,8 +155,8 @@ describe('ReactDOM HostSingleton', () => { expect(getVisibleChildren(document)).toEqual( - Hello Hola + Hello , @@ -241,8 +241,8 @@ describe('ReactDOM HostSingleton', () => { - a server title + @@ -287,10 +287,10 @@ describe('ReactDOM HostSingleton', () => { expect(getVisibleChildren(document)).toEqual( + a client title - a client title