diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index 47047c98cecea..ebcad81b81436 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -47,6 +47,7 @@ import { updateWrapper as ReactDOMTextareaUpdateWrapper, restoreControlledState as ReactDOMTextareaRestoreControlledState, } from './ReactDOMTextarea'; +import {getProps as ReactDOMTitleGetProps} from './ReactDOMTitle'; import {track} from './inputValueTracking'; import setInnerHTML from './setInnerHTML'; import setTextContent from './setTextContent'; @@ -79,6 +80,7 @@ import { mediaEventTypes, listenToNonDelegatedEvent, } from '../events/DOMPluginEventSystem'; +import {isMarkedResource} from './ReactDOMComponentTree'; let didWarnInvalidHydration = false; let didWarnScriptTags = false; @@ -574,6 +576,9 @@ export function setInitialProperties( // listeners still fire for the invalid event. listenToNonDelegatedEvent('invalid', domElement); break; + case 'title': + props = ReactDOMTitleGetProps(isMarkedResource(domElement), rawProps); + break; default: props = rawProps; } @@ -641,6 +646,11 @@ export function diffProperties( nextProps = ReactDOMTextareaGetHostProps(domElement, nextRawProps); updatePayload = []; break; + case 'title': + const isResource = isMarkedResource(domElement); + lastProps = ReactDOMTitleGetProps(isResource, lastRawProps); + nextProps = ReactDOMTitleGetProps(isResource, nextRawProps); + break; default: lastProps = lastRawProps; nextProps = nextRawProps; diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js b/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js index 6496e7a3990f3..c9ae419824365 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponentTree.js @@ -285,8 +285,6 @@ export function getResourcesFromRoot(root: FloatRoot): RootResources { resources = (root: any)[internalRootNodeResourcesKey] = { styles: new Map(), scripts: new Map(), - head: new Map(), - lastStructuredMeta: new Map(), }; } return resources; diff --git a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js index cfcdabdb28abe..c0fef8064a882 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js +++ b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js @@ -13,7 +13,6 @@ import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals.js'; const {Dispatcher} = ReactDOMSharedInternals; import {DOCUMENT_NODE} from '../shared/HTMLNodeType'; import { - warnOnMissingHrefAndRel, validatePreloadResourceDifference, validateURLKeyedUpdatedProps, validateStyleResourceDifference, @@ -27,9 +26,11 @@ import {createElement, setInitialProperties} from './ReactDOMComponent'; import { getResourcesFromRoot, markNodeAsResource, + isMarkedResource, } from './ReactDOMComponentTree'; import {HTML_NAMESPACE, SVG_NAMESPACE} from '../shared/DOMNamespaces'; import {getCurrentRootHostContainer} from 'react-reconciler/src/ReactFiberHostContext'; +import {getPropertyInfo} from '../shared/DOMProperty'; // The resource types we support. currently they match the form for the as argument. // In the future this may need to change, especially when modules / scripts are supported @@ -88,67 +89,24 @@ type ScriptResource = { root: FloatRoot, }; -type TitleProps = { - [string]: mixed, -}; -type TitleResource = { - type: 'title', - props: TitleProps, - - count: number, - instance: ?Element, - root: Document, -}; - -type MetaProps = { - [string]: mixed, -}; -type MetaResource = { - type: 'meta', - matcher: string, - property: ?string, - parentResource: ?MetaResource, - props: MetaProps, - - count: number, - instance: ?Element, - root: Document, -}; - -type LinkProps = { - href: string, - rel: string, - [string]: mixed, -}; -type LinkResource = { - type: 'link', - props: LinkProps, - - count: number, - instance: ?Element, - root: Document, -}; - -type BaseResource = { - type: 'base', - matcher: string, - props: Props, - - count: number, - instance: ?Element, - root: Document, +type HoistableTagType = 'link' | 'meta' | 'title'; +type HoistableResource = { + type: HoistableTagType, + instance: Element, + hydrate: boolean, }; type Props = {[string]: mixed}; -type HeadResource = TitleResource | MetaResource | LinkResource | BaseResource; -type Resource = StyleResource | ScriptResource | PreloadResource | HeadResource; +export type Resource = + | StyleResource + | ScriptResource + | PreloadResource + | HoistableResource; export type RootResources = { styles: Map, scripts: Map, - head: Map, - lastStructuredMeta: Map, }; // Brief on purpose due to insertion by script when streaming late boundaries @@ -206,7 +164,7 @@ function getCurrentResourceRoot(): null | FloatRoot { // This resource type constraint can be loosened. It really is everything except PreloadResource // because that is the only one that does not have an optional instance type. Expand as needed. -function resetInstance(resource: ScriptResource | HeadResource) { +function resetInstance(resource: ScriptResource) { resource.instance = undefined; } @@ -221,9 +179,6 @@ export function clearRootResources(rootContainer: Container): void { // Styles stay put // Scripts get reset resources.scripts.forEach(resetInstance); - // Head Resources get reset - resources.head.forEach(resetInstance); - // lastStructuredMeta stays put } // Preloads are somewhat special. Even if we don't have the Document @@ -461,15 +416,13 @@ 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, + current: null | Resource, pendingProps: Props, currentProps: null | Props, + isHydrating: boolean, ): null | Resource { const resourceRoot = getCurrentResourceRoot(); if (!resourceRoot) { @@ -478,148 +431,6 @@ export function getResource( ); } switch (type) { - case 'base': { - const headRoot: Document = getDocumentFromRoot(resourceRoot); - const headResources = getResourcesFromRoot(headRoot).head; - const {target, href} = pendingProps; - let matcher = 'base'; - matcher += - typeof href === 'string' - ? `[href="${escapeSelectorAttributeValueInsideDoubleQuotes(href)}"]` - : ':not([href])'; - matcher += - typeof target === 'string' - ? `[target="${escapeSelectorAttributeValueInsideDoubleQuotes( - target, - )}"]` - : ':not([target])'; - let resource = headResources.get(matcher); - if (!resource) { - resource = { - type: 'base', - matcher, - props: Object.assign({}, pendingProps), - count: 0, - instance: null, - root: headRoot, - }; - headResources.set(matcher, resource); - } - return resource; - } - case 'meta': { - let matcher, propertyString, parentResource; - const { - charSet, - content, - httpEquiv, - name, - itemProp, - property, - } = pendingProps; - const headRoot: Document = getDocumentFromRoot(resourceRoot); - const {head: headResources, lastStructuredMeta} = getResourcesFromRoot( - headRoot, - ); - if (typeof charSet === 'string') { - matcher = 'meta[charset]'; - } else if (typeof content === 'string') { - if (typeof httpEquiv === 'string') { - matcher = `meta[http-equiv="${escapeSelectorAttributeValueInsideDoubleQuotes( - httpEquiv, - )}"][content="${escapeSelectorAttributeValueInsideDoubleQuotes( - content, - )}"]`; - } else if (typeof property === 'string') { - propertyString = property; - matcher = `meta[property="${escapeSelectorAttributeValueInsideDoubleQuotes( - property, - )}"][content="${escapeSelectorAttributeValueInsideDoubleQuotes( - content, - )}"]`; - - const parentPropertyPath = property - .split(':') - .slice(0, -1) - .join(':'); - parentResource = lastStructuredMeta.get(parentPropertyPath); - if (parentResource) { - // When using parentResource the matcher is not functional for locating - // the instance in the DOM but it still serves as a unique key. - matcher = parentResource.matcher + matcher; - } - } else if (typeof name === 'string') { - matcher = `meta[name="${escapeSelectorAttributeValueInsideDoubleQuotes( - name, - )}"][content="${escapeSelectorAttributeValueInsideDoubleQuotes( - content, - )}"]`; - } else if (typeof itemProp === 'string') { - matcher = `meta[itemprop="${escapeSelectorAttributeValueInsideDoubleQuotes( - itemProp, - )}"][content="${escapeSelectorAttributeValueInsideDoubleQuotes( - content, - )}"]`; - } - } - if (matcher) { - let resource = headResources.get(matcher); - if (!resource) { - resource = { - type: 'meta', - matcher, - property: propertyString, - parentResource, - props: Object.assign({}, pendingProps), - count: 0, - instance: null, - root: headRoot, - }; - headResources.set(matcher, resource); - } - if (typeof resource.property === 'string') { - // We cast because flow doesn't know that this resource must be a Meta resource - lastStructuredMeta.set(resource.property, (resource: any)); - } - return resource; - } - return null; - } - case 'title': { - const children = pendingProps.children; - let child; - if (Array.isArray(children)) { - child = children.length === 1 ? children[0] : null; - } else { - child = children; - } - if ( - typeof child !== 'function' && - typeof child !== 'symbol' && - child !== null && - child !== undefined - ) { - // eslint-disable-next-line react-internal/safe-string-coercion - const childString = '' + (child: any); - const headRoot: Document = getDocumentFromRoot(resourceRoot); - const headResources = getResourcesFromRoot(headRoot).head; - const key = getTitleKey(childString); - let resource = headResources.get(key); - if (!resource) { - const titleProps = titlePropsFromRawProps(childString, pendingProps); - resource = { - type: 'title', - props: titleProps, - count: 0, - instance: null, - root: headRoot, - }; - headResources.set(key, resource); - } - return resource; - } - return null; - } case 'link': { const {rel} = pendingProps; switch (rel) { @@ -707,34 +518,45 @@ export function getResource( } return null; } - default: { - const {href, sizes, media} = pendingProps; - if (typeof rel === 'string' && typeof href === 'string') { - const sizeKey = - '::sizes:' + (typeof sizes === 'string' ? sizes : ''); - const mediaKey = - '::media:' + (typeof media === 'string' ? media : ''); - const key = 'rel:' + rel + '::href:' + href + sizeKey + mediaKey; - const headRoot = getDocumentFromRoot(resourceRoot); - const headResources = getResourcesFromRoot(headRoot).head; - let resource = headResources.get(key); - if (!resource) { - resource = { - type: 'link', - props: Object.assign({}, pendingProps), - count: 0, - instance: null, - root: headRoot, - }; - headResources.set(key, resource); - } - return resource; - } - if (__DEV__) { - warnOnMissingHrefAndRel(pendingProps, currentProps); + // We intentionally fall through to the title and meta cases because links that do not + // match a more specialized resource type like stylesheet or preload are treated like + // general Hoistables + } + } + case 'title': + case 'meta': { + if (current === null) { + let props = pendingProps; + if (type === 'title') { + props = Object.assign({}, pendingProps); + const children = pendingProps.children; + const child = Array.isArray(children) + ? children.length < 2 + ? children[0] + : null + : children; + if ( + typeof child !== 'function' && + typeof child !== 'symbol' && + child !== null && + child !== undefined + ) { + // eslint-disable-next-line react-internal/safe-string-coercion + props.children = '' + (child: any); } - return null; } + const instance = createResourceInstance( + type, + props, + getDocumentFromRoot(resourceRoot), + ); + return { + type, + instance, + hydrate: isHydrating, + }; + } else { + return current; } } case 'script': { @@ -795,15 +617,6 @@ function preloadPropsFromRawProps( return Object.assign({}, rawBorrowedProps); } -function titlePropsFromRawProps( - child: string | number, - rawProps: Props, -): TitleProps { - const props: TitleProps = Object.assign({}, rawProps); - props.children = child; - return props; -} - function stylePropsFromRawProps(rawProps: StyleQualifyingProps): StyleProps { // $FlowFixMe[prop-missing] - recommended fix is to use object spread operator const props: StyleProps = Object.assign({}, rawProps); @@ -823,13 +636,28 @@ function scriptPropsFromRawProps(rawProps: ScriptQualifyingProps): ScriptProps { // Resource Reconciliation // -------------------------------------- +export function getResourceProps( + resource: Resource, + pendingProps: Object, +): Object { + switch (resource.type) { + case 'title': + case 'link': + case 'meta': { + return pendingProps; + } + default: { + return resource.props; + } + } +} + export function acquireResource(resource: Resource): Instance { switch (resource.type) { - case 'base': case 'title': case 'link': case 'meta': { - return acquireHeadResource(resource); + return acquireHoistable(resource); } case 'style': { return acquireStyleResource(resource); @@ -853,7 +681,7 @@ export function releaseResource(resource: Resource): void { case 'link': case 'title': case 'meta': { - return releaseHeadResource(resource); + return removeHoistable(resource); } case 'style': { resource.count--; @@ -862,15 +690,13 @@ export function releaseResource(resource: Resource): void { } } -function releaseHeadResource(resource: HeadResource): void { - if (--resource.count === 0) { - // the instance will have existed since we acquired it - const instance: Instance = (resource.instance: any); +function removeHoistable(resource: HoistableResource): void { + if (resource.instance) { + const instance: Instance = resource.instance; const parent = instance.parentNode; if (parent) { parent.removeChild(instance); } - resource.instance = null; } } @@ -880,8 +706,8 @@ function createResourceInstance( ownerDocument: Document, ): Instance { const element = createElement(type, props, ownerDocument, HTML_NAMESPACE); - setInitialProperties(element, type, props); markNodeAsResource(element); + setInitialProperties(element, type, props); return element; } @@ -1095,150 +921,50 @@ function createPreloadResource( }; } -function acquireHeadResource(resource: HeadResource): Instance { - resource.count++; - let instance = resource.instance; - if (!instance) { - const {props, root, type} = resource; - switch (type) { - 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( - type, - props, - root, - ); - const firstTitle = titles[0]; - insertResourceInstanceBefore( - root, - instance, - firstTitle && firstTitle.namespaceURI !== SVG_NAMESPACE - ? firstTitle - : null, - ); - break; - } - case 'meta': { - let insertBefore = null; - - const metaResource: MetaResource = (resource: any); - const {matcher, property, parentResource} = metaResource; - - if (parentResource && typeof property === 'string') { - // This resoruce is a structured meta type with a parent. - // Instead of using the matcher we just traverse forward - // siblings of the parent instance until we find a match - // or exhaust. - const parent = parentResource.instance; - if (parent) { - let node = null; - let nextNode = (insertBefore = parent.nextSibling); - while ((node = nextNode)) { - nextNode = node.nextSibling; - if (node.nodeName === 'META') { - const meta: Element = (node: any); - const propertyAttr = meta.getAttribute('property'); - if (typeof propertyAttr !== 'string') { - continue; - } else if ( - propertyAttr === property && - meta.getAttribute('content') === props.content - ) { - resource.instance = meta; - markNodeAsResource(meta); - return meta; - } else if (property.startsWith(propertyAttr + ':')) { - // This meta starts a new instance of a parent structure for this meta type - // We need to halt our search here because even if we find a later match it - // is for a different parent element - break; - } - } - } - } - } else if ((instance = root.querySelector(matcher))) { - resource.instance = instance; - markNodeAsResource(instance); - return instance; - } - instance = resource.instance = createResourceInstance( - type, - props, - root, - ); - insertResourceInstanceBefore(root, instance, insertBefore); - break; - } - case 'link': { - const linkProps: LinkProps = (props: any); - const limitedEscapedRel = escapeSelectorAttributeValueInsideDoubleQuotes( - linkProps.rel, - ); - const limitedEscapedHref = escapeSelectorAttributeValueInsideDoubleQuotes( - linkProps.href, - ); - let selector = `link[rel="${limitedEscapedRel}"][href="${limitedEscapedHref}"]`; - if (typeof linkProps.sizes === 'string') { - const limitedEscapedSizes = escapeSelectorAttributeValueInsideDoubleQuotes( - linkProps.sizes, - ); - selector += `[sizes="${limitedEscapedSizes}"]`; - } - if (typeof linkProps.media === 'string') { - const limitedEscapedMedia = escapeSelectorAttributeValueInsideDoubleQuotes( - linkProps.media, - ); - selector += `[media="${limitedEscapedMedia}"]`; - } - const existingEl = root.querySelector(selector); - if (existingEl) { - instance = resource.instance = existingEl; - markNodeAsResource(instance); - return instance; - } - instance = resource.instance = createResourceInstance( - type, - props, - root, - ); - insertResourceInstanceBefore(root, instance, null); - return instance; +function acquireHoistable(resource: HoistableResource): Instance { + if (resource.hydrate) { + resource.hydrate = false; + const {type, instance} = resource; + const root = instance.ownerDocument; + let selector: string = type; + const attributeNames = instance.getAttributeNames(); + if (attributeNames.length) { + selector += `[${attributeNames.join('][')}]`; + } + const nodes = root.querySelectorAll(selector); + nodeLoop: for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if ( + isMarkedResource(node) || + node.namespaceURI === SVG_NAMESPACE || + node.textContent !== instance.textContent + ) { + continue; } - case 'base': { - const baseResource: BaseResource = (resource: any); - const {matcher} = baseResource; - const base = root.querySelector(matcher); - if (base) { - instance = resource.instance = base; - markNodeAsResource(instance); - } else { - instance = resource.instance = createResourceInstance( - type, - props, - root, - ); - insertResourceInstanceBefore( - root, - instance, - root.querySelector('base'), - ); + const attributes = node.attributes; + for (let j = 0; j < attributes.length; j++) { + const attr = attributes[j]; + if (instance.getAttribute(attr.name) !== attr.value) { + continue nodeLoop; } - return instance; - } - default: { - throw new Error( - `acquireHeadResource encountered a resource type it did not expect: "${type}". This is a bug in React.`, - ); } + resource.instance = node; + markNodeAsResource(node); + return node; } } + const instance = resource.instance; + if (resource.type === 'title') { + const root = instance.ownerDocument; + const firstHeadTitle = root.querySelector('head > title'); + insertResourceInstanceBefore( + instance.ownerDocument, + instance, + firstHeadTitle, + ); + } else { + insertResourceInstanceBefore(instance.ownerDocument, instance, null); + } return instance; } @@ -1418,6 +1144,11 @@ function insertStyleInstance( } } +function insertHoistedTitle(ownerDocument: Document, instance: Instance): void { + const firstHeadTitle = document.querySelector('head > title'); + insertResourceInstanceBefore(ownerDocument, instance, firstHeadTitle); +} + function insertResourceInstanceBefore( ownerDocument: Document, instance: Instance, diff --git a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js index db3c6f1c5df22..55db943458dbd 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom-bindings/src/client/ReactDOMHostConfig.js @@ -17,6 +17,7 @@ import type { } from 'react-reconciler/src/ReactTestSelectors'; import type {ReactScopeInstance} from 'shared/ReactTypes'; import type {AncestorInfoDev} from './validateDOMNesting'; +import type {Resource} from './ReactDOMFloatClient'; import { precacheFiberNode, @@ -620,6 +621,12 @@ export function removeChildFromContainer( ): void { if (container.nodeType === COMMENT_NODE) { (container.parentNode: any).removeChild(child); + } else if ( + (container.nodeType === DOCUMENT_NODE || container.nodeName === 'HTML') && + child.parentNode + ) { + const parentNode: Node = (child.parentNode: any); + parentNode.removeChild(child); } else { container.removeChild(child); } @@ -936,7 +943,6 @@ function getNextHydratable(node: ?Node) { // developer on how to fix. case 'TITLE': case 'META': - case 'BASE': case 'HTML': case 'HEAD': case 'BODY': { @@ -978,8 +984,7 @@ function getNextHydratable(node: ?Node) { const element: Element = (node: any); switch (element.tagName) { case 'TITLE': - case 'META': - case 'BASE': { + case 'META': { continue; } case 'LINK': { @@ -1060,7 +1065,29 @@ export function getFirstHydratableChild( export function getFirstHydratableChildWithinContainer( parentContainer: Container, ): null | HydratableInstance { - return getNextHydratable(parentContainer.firstChild); + if (enableHostSingletons) { + if ( + parentContainer.nodeType === DOCUMENT_NODE || + parentContainer.nodeName === 'HTML' + ) { + // when singletons are in place, we can start hydration within the context of the body + // if the parentContainer is body, html, or document. If we encounter any of these nodes + // they will hydrate using the singleton pathway and if we encounter something else it should + // be found in the body regardless + const documentContainer: Document = + parentContainer.ownerDocument || parentContainer; + // we unsafely cast here because we expect to be in the context of a full document. + // If you initiate a render synchronously from a script in the head or otherwise remove + // the body before this runs then it will error but React does not support either of these + // use cases anyway and we can avoid the extra conditional by making this assumption + const body: HTMLBodyElement = (documentContainer.body: any); + return getNextHydratable(body.firstChild); + } else { + return getNextHydratable(parentContainer.firstChild); + } + } else { + return getNextHydratable(parentContainer.firstChild); + } } export function getFirstHydratableChildWithinSuspenseInstance( @@ -1584,7 +1611,6 @@ export function isHostResourceType( namespace = hostContextProd; } switch (type) { - case 'base': case 'meta': { return true; } @@ -1708,6 +1734,7 @@ export { getResource, acquireResource, releaseResource, + getResourceProps, } from './ReactDOMFloatClient'; // ------------------- diff --git a/packages/react-dom-bindings/src/client/ReactDOMTitle.js b/packages/react-dom-bindings/src/client/ReactDOMTitle.js new file mode 100644 index 0000000000000..2496fe392f3c2 --- /dev/null +++ b/packages/react-dom-bindings/src/client/ReactDOMTitle.js @@ -0,0 +1,44 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import isArray from 'shared/isArray'; + +import {enableFloat} from 'shared/ReactFeatureFlags'; + +function getTitleChildren(children: mixed): void | null | string { + if (children == null) { + return children; + } else if (typeof children === 'string') { + return children; + } else if (typeof children === 'number') { + return '' + children; + } else if (isArray(children) && children.length === 1) { + return getTitleChildren(children[0]); + } else { + return null; + } +} + +// For titles that are Hoistables the only valid values for children are +// void, null, or single alphanumeric values. We could in theory make this +// restriction apply to all titles however to avoid breaking changes this +// is for now only applied to Hoistable titles +export function getProps(isResource: boolean, props: Object): Object { + if (enableFloat && isResource) { + const titleChildren = getTitleChildren(props.children); + if (titleChildren !== props.children) { + // We only bother constructing a new object if the children value differs + // from what was passed in + const resourceProps = Object.assign({}, props); + resourceProps.children = titleChildren; + return resourceProps; + } + } + return props; +} diff --git a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js index 320bb1ef7c551..63a742e4a5199 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js +++ b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js @@ -7,6 +7,7 @@ * @flow */ +import {current} from '../../../react-reconciler/src/ReactCurrentFiber'; import { validatePreloadResourceDifference, validateStyleResourceDifference, @@ -108,6 +109,12 @@ type BaseResource = { flushed: boolean, }; +type HoistableTag = 'link' | 'meta' | 'title'; +type Hoistable = { + type: HoistableTag, + props: Props, +}; + export type LinkTagResource = PreloadResource | StyleResource | LinkResource; export type Resource = PreloadResource | StyleResource | ScriptResource; export type HeadResource = @@ -129,6 +136,8 @@ export type Resources = { preconnects: Set, fontPreloads: Set, // usedImagePreloads: Set, + firstPrecedence: string, + firstPrecedenceFlushed: boolean, precedences: Map>, usedStylePreloads: Set, scripts: Set, @@ -161,6 +170,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(), @@ -371,7 +382,7 @@ function preloadPropsFromPreloadOptions( }; } -function preloadPropsFromRawProps( +export function preloadPropsFromRawProps( href: string, as: ResourceType, rawProps: Props, @@ -389,7 +400,7 @@ function preloadPropsFromRawProps( return props; } -function preloadAsStylePropsFromProps( +export function preloadAsStylePropsFromProps( href: string, props: Props | StyleProps, ): PreloadProps { @@ -405,7 +416,7 @@ function preloadAsStylePropsFromProps( }; } -function preloadAsScriptPropsFromProps( +export function preloadAsScriptPropsFromProps( href: string, props: Props | ScriptProps, ): PreloadProps { @@ -419,7 +430,7 @@ function preloadAsScriptPropsFromProps( }; } -function createPreloadResource( +export function createPreloadResource( resources: Resources, href: string, as: ResourceType, @@ -445,7 +456,7 @@ function createPreloadResource( return resource; } -function stylePropsFromRawProps( +export function stylePropsFromRawProps( href: string, precedence: string, rawProps: Props, @@ -472,7 +483,7 @@ function stylePropsFromPreinitOptions( }; } -function createStyleResource( +export function createStyleResource( resources: Resources, href: string, precedence: string, @@ -485,7 +496,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 +504,9 @@ function createStyleResource( if (!precedenceSet) { precedenceSet = new Set(); precedences.set(precedence, precedenceSet); + if (!firstPrecedence) { + resources.firstPrecedence = precedence; + } } let hint = preloadsMap.get(href); @@ -539,7 +553,7 @@ function createStyleResource( return resource; } -function adoptPreloadPropsForStyleProps( +export function adoptPreloadPropsForStyleProps( resourceProps: StyleProps, preloadProps: PreloadProps, ): void { @@ -562,13 +576,16 @@ function scriptPropsFromPreinitOptions( }; } -function scriptPropsFromRawProps(src: string, rawProps: Props): ScriptProps { +export function scriptPropsFromRawProps( + src: string, + rawProps: Props, +): ScriptProps { const props = Object.assign({}, rawProps); props.src = src; return props; } -function createScriptResource( +export function createScriptResource( resources: Resources, src: string, props: ScriptProps, @@ -623,7 +640,7 @@ function createScriptResource( return resource; } -function adoptPreloadPropsForScriptProps( +export function adoptPreloadPropsForScriptProps( resourceProps: ScriptProps, preloadProps: PreloadProps, ): void { @@ -644,275 +661,13 @@ function titlePropsFromRawProps( return props; } -export function resourcesFromElement(type: string, props: Props): boolean { +export function expectCurrentResources(): Resources { if (!currentResources) { throw new Error( '"currentResources" was expected to exist. This is a bug in React.', ); } - const resources = currentResources; - switch (type) { - case 'title': { - const children = props.children; - let child; - if (Array.isArray(children)) { - child = children.length === 1 ? children[0] : null; - } else { - child = children; - } - if ( - typeof child !== 'function' && - typeof child !== 'symbol' && - child !== null && - child !== undefined - ) { - // eslint-disable-next-line react-internal/safe-string-coercion - const childString = '' + (child: any); - const key = 'title::' + childString; - let resource = resources.headsMap.get(key); - if (!resource) { - resource = { - type: 'title', - props: titlePropsFromRawProps(childString, props), - flushed: false, - }; - resources.headsMap.set(key, resource); - resources.headResources.add(resource); - } - } - return true; - } - case 'meta': { - let key, propertyPath; - if (typeof props.charSet === 'string') { - key = 'charSet'; - } else if (typeof props.content === 'string') { - const contentKey = '::' + props.content; - if (typeof props.httpEquiv === 'string') { - key = 'httpEquiv::' + props.httpEquiv + contentKey; - } else if (typeof props.name === 'string') { - key = 'name::' + props.name + contentKey; - } else if (typeof props.itemProp === 'string') { - key = 'itemProp::' + props.itemProp + contentKey; - } else if (typeof props.property === 'string') { - const {property} = props; - key = 'property::' + property + contentKey; - propertyPath = property; - const parentPath = property - .split(':') - .slice(0, -1) - .join(':'); - const parentResource = resources.structuredMetaKeys.get(parentPath); - if (parentResource) { - key = parentResource.key + '::child::' + key; - } - } - } - if (key) { - if (!resources.headsMap.has(key)) { - const resource = { - type: 'meta', - key, - props: Object.assign({}, props), - flushed: false, - }; - resources.headsMap.set(key, resource); - if (key === 'charSet') { - resources.charset = resource; - } else { - if (propertyPath) { - resources.structuredMetaKeys.set(propertyPath, resource); - } - resources.headResources.add(resource); - } - } - } - return true; - } - case 'base': { - const {target, href} = props; - // We mirror the key construction on the client since we will likely unify - // this code in the future to better guarantee key semantics are identical - // in both environments - let key = 'base'; - key += typeof href === 'string' ? `[href="${href}"]` : ':not([href])'; - key += - typeof target === 'string' ? `[target="${target}"]` : ':not([target])'; - if (!resources.headsMap.has(key)) { - const resource = { - type: 'base', - props: Object.assign({}, props), - flushed: false, - }; - resources.headsMap.set(key, resource); - resources.bases.add(resource); - } - return true; - } - } - return false; -} - -// Construct a resource from link props. -export function resourcesFromLink(props: Props): boolean { - if (!currentResources) { - throw new Error( - '"currentResources" was expected to exist. This is a bug in React.', - ); - } - const resources = currentResources; - - const {rel, href} = props; - if (!href || typeof href !== 'string' || !rel || typeof rel !== 'string') { - return false; - } - - let key = ''; - switch (rel) { - case 'stylesheet': { - const {onLoad, onError, precedence, disabled} = props; - if ( - typeof precedence !== 'string' || - onLoad || - onError || - disabled != null - ) { - // This stylesheet is either not opted into Resource semantics or has conflicting properties which - // disqualify it for such. We can still create a preload resource to help it load faster on the - // client - if (__DEV__) { - validateLinkPropsForStyleResource(props); - } - let preloadResource = resources.preloadsMap.get(href); - if (!preloadResource) { - preloadResource = createPreloadResource( - resources, - href, - 'style', - preloadAsStylePropsFromProps(href, props), - ); - if (__DEV__) { - (preloadResource: any)._dev_implicit_construction = true; - } - resources.usedStylePreloads.add(preloadResource); - } - return false; - } else { - // We are able to convert this link element to a resource exclusively. We construct the relevant Resource - // and return true indicating that this link was fully consumed. - let resource = resources.stylesMap.get(href); - - if (resource) { - if (__DEV__) { - const resourceProps = stylePropsFromRawProps( - href, - precedence, - props, - ); - adoptPreloadPropsForStyleProps(resourceProps, resource.hint.props); - validateStyleResourceDifference(resource.props, resourceProps); - } - } else { - const resourceProps = stylePropsFromRawProps(href, precedence, props); - resource = createStyleResource( - // $FlowFixMe[incompatible-call] found when upgrading Flow - currentResources, - href, - precedence, - resourceProps, - ); - resources.usedStylePreloads.add(resource.hint); - } - if (resources.boundaryResources) { - resources.boundaryResources.add(resource); - } else { - resource.set.add(resource); - } - return true; - } - } - case 'preload': { - const {as} = props; - switch (as) { - case 'script': - case 'style': - case 'font': { - if (__DEV__) { - validateLinkPropsForPreloadResource(props); - } - let resource = resources.preloadsMap.get(href); - if (resource) { - if (__DEV__) { - const originallyImplicit = - (resource: any)._dev_implicit_construction === true; - const latestProps = preloadPropsFromRawProps(href, as, props); - validatePreloadResourceDifference( - resource.props, - originallyImplicit, - latestProps, - false, - ); - } - } else { - resource = createPreloadResource( - resources, - href, - as, - preloadPropsFromRawProps(href, as, props), - ); - switch (as) { - case 'script': { - resources.explicitScriptPreloads.add(resource); - break; - } - case 'style': { - resources.explicitStylePreloads.add(resource); - break; - } - case 'font': { - resources.fontPreloads.add(resource); - break; - } - } - } - return true; - } - } - break; - } - } - if (props.onLoad || props.onError) { - // When a link has these props we can't treat it is a Resource but if we rendered it on the - // server it would look like a Resource in the rendered html (the onLoad/onError aren't emitted) - // Instead we expect the client to insert them rather than hydrate them which also guarantees - // that the onLoad and onError won't fire before the event handlers are attached - return true; - } - - const sizes = typeof props.sizes === 'string' ? props.sizes : ''; - const media = typeof props.media === 'string' ? props.media : ''; - key = - 'rel:' + rel + '::href:' + href + '::sizes:' + sizes + '::media:' + media; - let resource = resources.headsMap.get(key); - if (!resource) { - resource = { - type: 'link', - props: Object.assign({}, props), - flushed: false, - }; - resources.headsMap.set(key, resource); - switch (rel) { - case 'preconnect': - case 'dns-prefetch': { - resources.preconnects.add(resource); - break; - } - default: { - resources.headResources.add(resource); - } - } - } - return true; + return currentResources; } // Construct a resource from link props. diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index eb5366a954f52..2d9fa20d9be59 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -70,10 +70,19 @@ import { preinitImpl, prepareToRenderResources, finishRenderingResources, - resourcesFromElement, - resourcesFromLink, resourcesFromScript, ReactDOMServerFloatDispatcher, + expectCurrentResources, + createStyleResource, + createPreloadResource, + createScriptResource, + preloadAsStylePropsFromProps, + stylePropsFromRawProps, + adoptPreloadPropsForStyleProps, + preloadPropsFromRawProps, + preloadAsScriptPropsFromProps, + scriptPropsFromRawProps, + adoptPreloadPropsForScriptProps, } from './ReactDOMFloatServer'; export { createResources, @@ -82,6 +91,13 @@ export { hoistResources, hoistResourcesToRoot, } from './ReactDOMFloatServer'; +import { + validateLinkPropsForStyleResource, + validateStyleResourceDifference, + validateLinkPropsForPreloadResource, + validatePreloadResourceDifference, + validateScriptResourceDifference, +} from '../shared/ReactDOMResourceValidation'; import { clientRenderBoundary as clientRenderFunctionString, @@ -119,10 +135,25 @@ 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, + charsetChunks: Array, + hoistableChunks: Array, placeholderPrefix: PrecomputedChunk, segmentPrefix: PrecomputedChunk, boundaryPrefix: string, @@ -199,6 +230,7 @@ export function createResponseState( > | void, externalRuntimeConfig: string | BootstrapScriptDescriptor | void, containerID: string | void, + documentEmbedding: boolean | void, ): ResponseState { const idPrefix = identifierPrefix === undefined ? '' : identifierPrefix; const inlineScriptWithNonce = @@ -335,6 +367,13 @@ export function createResponseState( fallbackBootstrapChunks: fallbackBootstrapChunks.length ? fallbackBootstrapChunks : undefined, + htmlChunks: [], + headChunks: [], + requiresEmbedding: documentEmbedding === true, + rendered: NONE, + flushed: NONE, + charsetChunks: [], + hoistableChunks: [], placeholderPrefix: stringToPrecomputedChunk(idPrefix + 'P:'), segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'), boundaryPrefix: idPrefix + 'B:', @@ -358,17 +397,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 = { @@ -470,12 +512,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; } @@ -1298,75 +1342,205 @@ function pushStartTextArea( return null; } -function pushBase( +function pushMeta( target: Array, props: Object, responseState: ResponseState, textEmbedded: boolean, noscriptTagInScope: boolean, ): ReactNodeList { - if ( - enableFloat && - !noscriptTagInScope && - resourcesFromElement('base', props) - ) { + if (enableFloat) { + if (noscriptTagInScope) { + return pushSelfClosing(target, props, 'meta', responseState); + } if (textEmbedded) { - // This link follows text but we aren't writing a tag. while not as efficient as possible we need - // to be safe and assume text will follow by inserting a textSeparator + // This meta tag is not going to emit in place and we are adjacent to text. + // We defensively emit a textSeparator in case the next chunk is text. target.push(textSeparator); } - // We have converted this link exclusively to a resource and no longer - // need to emit it + if (props.charSet != null) { + pushSelfClosing( + responseState.charsetChunks, + props, + 'meta', + responseState, + ); + } else { + pushSelfClosing( + responseState.hoistableChunks, + props, + 'meta', + responseState, + ); + } return null; + } else { + return pushSelfClosing(target, props, 'meta', responseState); } - - return pushSelfClosing(target, props, 'base', responseState); } -function pushMeta( +function pushLink( target: Array, props: Object, responseState: ResponseState, textEmbedded: boolean, noscriptTagInScope: boolean, ): ReactNodeList { - if ( - enableFloat && - !noscriptTagInScope && - resourcesFromElement('meta', props) - ) { + if (enableFloat) { + if (noscriptTagInScope) { + return pushLinkImpl(target, props, responseState); + } if (textEmbedded) { // This link follows text but we aren't writing a tag. while not as efficient as possible we need // to be safe and assume text will follow by inserting a textSeparator target.push(textSeparator); } - // We have converted this link exclusively to a resource and no longer - // need to emit it - return null; - } - return pushSelfClosing(target, props, 'meta', responseState); -} + const resources = expectCurrentResources(); -function pushLink( - target: Array, - props: Object, - responseState: ResponseState, - textEmbedded: boolean, - noscriptTagInScope: boolean, -): ReactNodeList { - if (enableFloat && !noscriptTagInScope && resourcesFromLink(props)) { - if (textEmbedded) { - // This link follows text but we aren't writing a tag. while not as efficient as possible we need - // to be safe and assume text will follow by inserting a textSeparator - target.push(textSeparator); + const {rel, href} = props; + if (!href || typeof href !== 'string' || !rel || typeof rel !== 'string') { + return false; + } + + let key = ''; + switch (rel) { + case 'stylesheet': { + const {onLoad, onError, precedence, disabled} = props; + if ( + typeof precedence !== 'string' || + onLoad || + onError || + disabled != null + ) { + // This stylesheet is either not opted into Resource semantics or has conflicting properties which + // disqualify it for such. We can still create a preload resource to help it load faster on the + // client + if (__DEV__) { + validateLinkPropsForStyleResource(props); + } + let preloadResource = resources.preloadsMap.get(href); + if (!preloadResource) { + preloadResource = createPreloadResource( + resources, + href, + 'style', + preloadAsStylePropsFromProps(href, props), + ); + if (__DEV__) { + (preloadResource: any)._dev_implicit_construction = true; + } + resources.usedStylePreloads.add(preloadResource); + } + // This link is neither a Resource nor Hoistable. we write it as normal chunks + return pushLinkImpl(target, props, responseState); + } else { + // We are able to convert this link element to a resource exclusively. We construct the relevant Resource + // and return true indicating that this link was fully consumed. + let resource = resources.stylesMap.get(href); + + if (resource) { + if (__DEV__) { + const resourceProps = stylePropsFromRawProps( + href, + precedence, + props, + ); + adoptPreloadPropsForStyleProps( + resourceProps, + resource.hint.props, + ); + validateStyleResourceDifference(resource.props, resourceProps); + } + } else { + const resourceProps = stylePropsFromRawProps( + href, + precedence, + props, + ); + resource = createStyleResource( + // $FlowFixMe[incompatible-call] found when upgrading Flow + resources, + href, + precedence, + resourceProps, + ); + resources.usedStylePreloads.add(resource.hint); + } + if (resources.boundaryResources) { + resources.boundaryResources.add(resource); + } else { + resource.set.add(resource); + } + // This was turned into a Resource + return null; + } + } + case 'preload': { + const {as} = props; + switch (as) { + case 'script': + case 'style': + case 'font': { + if (__DEV__) { + validateLinkPropsForPreloadResource(props); + } + let resource = resources.preloadsMap.get(href); + if (resource) { + if (__DEV__) { + const originallyImplicit = + (resource: any)._dev_implicit_construction === true; + const latestProps = preloadPropsFromRawProps(href, as, props); + validatePreloadResourceDifference( + resource.props, + originallyImplicit, + latestProps, + false, + ); + } + } else { + resource = createPreloadResource( + resources, + href, + as, + preloadPropsFromRawProps(href, as, props), + ); + switch (as) { + case 'script': { + resources.explicitScriptPreloads.add(resource); + break; + } + case 'style': { + resources.explicitStylePreloads.add(resource); + break; + } + case 'font': { + resources.fontPreloads.add(resource); + break; + } + } + } + // This was turned into a resource + return null; + } + } + break; + } + } + if (props.onLoad || props.onError) { + // When a link has these props we can't treat it is a Resource but if we rendered it on the + // server it would look like a Resource in the rendered html (the onLoad/onError aren't emitted) + // Instead we expect the client to insert them rather than hydrate them which also guarantees + // that the onLoad and onError won't fire before the event handlers are attached + return null; } - // We have converted this link exclusively to a resource and no longer - // need to emit it + + // This link is Hoistable + pushLinkImpl(responseState.hoistableChunks, props, responseState); return null; + } else { + return pushLinkImpl(target, props, responseState); } - - return pushLinkImpl(target, props, responseState); } function pushLinkImpl( @@ -1520,18 +1694,16 @@ function pushTitle( } } - if ( - enableFloat && - // title is valid in SVG so we avoid resour - insertionMode !== SVG_MODE && - !noscriptTagInScope && - resourcesFromElement('title', props) - ) { - // We have converted this link exclusively to a resource and no longer - // need to emit it - return null; + if (enableFloat) { + if (insertionMode === SVG_MODE || noscriptTagInScope) { + return pushTitleImpl(target, props, responseState); + } else { + pushTitleImpl(responseState.hoistableChunks, props, responseState); + return null; + } + } else { + return pushTitleImpl(target, props, responseState); } - return pushTitleImpl(target, props, responseState); } function pushTitleImpl( @@ -1656,37 +1828,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( @@ -1696,18 +2068,63 @@ function pushScript( textEmbedded: boolean, noscriptTagInScope: boolean, ): null { - if (enableFloat && !noscriptTagInScope && resourcesFromScript(props)) { - if (textEmbedded) { - // This link follows text but we aren't writing a tag. while not as efficient as possible we need - // to be safe and assume text will follow by inserting a textSeparator - target.push(textSeparator); + if (enableFloat) { + if (!noscriptTagInScope) { + const resources = expectCurrentResources(); + const {src, async, onLoad, onError} = props; + + if (!src || typeof src !== 'string') { + // Inline script emits in place + return pushScriptImpl(target, props, responseState); + } + + if (async) { + if (onLoad || onError) { + if (__DEV__) { + // validate + } + let preloadResource = resources.preloadsMap.get(src); + if (!preloadResource) { + preloadResource = createPreloadResource( + resources, + src, + 'script', + preloadAsScriptPropsFromProps(src, props), + ); + if (__DEV__) { + (preloadResource: any)._dev_implicit_construction = true; + } + resources.usedScriptPreloads.add(preloadResource); + } + } else { + let resource = resources.scriptsMap.get(src); + if (resource) { + if (__DEV__) { + const latestProps = scriptPropsFromRawProps(src, props); + adoptPreloadPropsForScriptProps(latestProps, resource.hint.props); + validateScriptResourceDifference(resource.props, latestProps); + } + } else { + const resourceProps = scriptPropsFromRawProps(src, props); + resource = createScriptResource(resources, src, resourceProps); + resources.scripts.add(resource); + } + } + // If the async script had an onLoad or onError we do not emit the script + // on the server and expect the client to insert it on hydration + if (textEmbedded) { + // This link follows text but we aren't writing a tag. while not as efficient as possible we need + // to be safe and assume text will follow by inserting a textSeparator + target.push(textSeparator); + } + return null; + } } - // We have converted this link exclusively to a resource and no longer - // need to emit it - return null; + // The script was not a resource or client insertion script so we write it as a component + return pushScriptImpl(target, props, responseState); + } else { + return pushScriptImpl(target, props, responseState); } - - return pushScriptImpl(target, props, responseState); } function pushScriptImpl( @@ -1980,7 +2397,6 @@ const DOCTYPE: PrecomputedChunk = stringToPrecomputedChunk(''); export function pushStartInstance( target: Array, - preamble: Array, type: string, props: Object, responseState: ResponseState, @@ -2024,6 +2440,10 @@ export function pushStartInstance( } } + if (formatContext.insertionMode === ROOT_HTML_MODE) { + responseState.rendered |= FLOW; + } + switch (type) { // Special tags case 'select': @@ -2072,20 +2492,13 @@ export function pushStartInstance( textEmbedded, formatContext.noscriptTagInScope, ); - case 'base': - return pushBase( - target, - props, - responseState, - textEmbedded, - formatContext.noscriptTagInScope, - ); // Newline eating tags case 'listing': case 'pre': { return pushStartPreformattedElement(target, props, type, responseState); } // Omitted close tags + case 'base': case 'area': case 'br': case 'col': @@ -2111,19 +2524,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 @@ -2141,9 +2548,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 @@ -2177,24 +2584,245 @@ 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; + } + + let i = 0; + let r = true; + + const {charsetChunks, hoistableChunks} = responseState; + for (; i < charsetChunks.length; i++) { + writeChunk(destination, charsetChunks[i]); + } + charsetChunks.length = 0; + + r = writeEarlyResources( + destination, + resources, + responseState, + willEmitInstructions, + ); + + for (i = 0; i < hoistableChunks.length - 1; i++) { + writeChunk(destination, hoistableChunks[i]); + } + if (i < hoistableChunks.length) { + r = writeChunkAndReturn(destination, hoistableChunks[i]); + } + hoistableChunks.length = 0; + + return r; + } + } + } + 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); + } + } + + let i = 0; + let r = true; + + const {charsetChunks, hoistableChunks} = responseState; + for (; i < charsetChunks.length; i++) { + writeChunk(destination, charsetChunks[i]); + } + charsetChunks.length = 0; + + // Write all remaining resources that should flush with the Shell + r = writeInitialResources( + destination, + resources, + responseState, + willEmitInstructions, + ); + + for (i = 0; i < hoistableChunks.length - 1; i++) { + writeChunk(destination, hoistableChunks[i]); + } + if (i < hoistableChunks.length) { + r = writeChunkAndReturn(destination, hoistableChunks[i]); + } + hoistableChunks.length = 0; + + // 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, @@ -2407,6 +3035,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); @@ -2464,6 +3093,7 @@ export function writeEndSegment( ): boolean { switch (formatContext.insertionMode) { case ROOT_HTML_MODE: + case HTML_HTML_MODE: case HTML_MODE: { return writeChunkAndReturn(destination, endSegmentHTML); } @@ -3004,7 +3634,7 @@ const precedencePlaceholderStart = stringToPrecomputedChunk( ); const precedencePlaceholderEnd = stringToPrecomputedChunk('">'); -export function writeInitialResources( +export function writeEarlyResources( destination: Destination, resources: Resources, responseState: ResponseState, @@ -3031,13 +3661,13 @@ export function writeInitialResources( } } - const target: Array = []; + const target = []; const { - charset, bases, preconnects, fontPreloads, + firstPrecedence, precedences, usedStylePreloads, scripts, @@ -3047,12 +3677,137 @@ export function writeInitialResources( 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, + 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: Array = []; + + const { + bases, + preconnects, + fontPreloads, + firstPrecedence, + firstPrecedenceFlushed, + precedences, + usedStylePreloads, + scripts, + usedScriptPreloads, + explicitStylePreloads, + explicitScriptPreloads, + headResources, + } = resources; + bases.forEach(r => { pushSelfClosing(target, r.props, 'base', responseState); r.flushed = true; @@ -3075,13 +3830,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 { @@ -3143,7 +3909,7 @@ export function writeInitialResources( return r; } -export function writeImmediateResources( +export function writeResources( destination: Destination, resources: Resources, responseState: ResponseState, @@ -3169,7 +3935,6 @@ export function writeImmediateResources( const target: Array = []; const { - charset, preconnects, fontPreloads, usedStylePreloads, @@ -3180,12 +3945,6 @@ export function writeImmediateResources( headResources, } = resources; - if (charset) { - pushSelfClosing(target, charset.props, 'meta', responseState); - charset.flushed = true; - resources.charset = null; - } - preconnects.forEach(r => { // font preload Resources should not already be flushed so we elide this check pushLinkImpl(target, r.props, responseState); diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js index cf864fafb16cc..cfc0d3140c435 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerLegacyFormatConfig.js @@ -12,6 +12,7 @@ import type { FormatContext, StreamingFormat, SuspenseBoundaryID, + DocumentStructureTag, } from './ReactDOMServerFormatConfig'; import { @@ -23,6 +24,7 @@ import { writeEndCompletedSuspenseBoundary as writeEndCompletedSuspenseBoundaryImpl, writeEndClientRenderedSuspenseBoundary as writeEndClientRenderedSuspenseBoundaryImpl, HTML_MODE, + NONE, } from './ReactDOMServerFormatConfig'; import type { @@ -37,11 +39,18 @@ 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, - idPrefix: string, containerBoundaryID: SuspenseBoundaryID, + idPrefix: string, nextSuspenseID: number, streamingFormat: StreamingFormat, startInlineScript: PrecomputedChunk, @@ -75,6 +84,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, @@ -129,14 +145,17 @@ export { writeCompletedRoot, createResources, createBoundaryResources, - writeInitialResources, - writeImmediateResources, + writeResources, hoistResources, hoistResourcesToRoot, setCurrentlyRenderingBoundaryResourcesTarget, prepareToRender, cleanupAfterRender, getRootBoundaryID, + writeEarlyPreamble, + writePreamble, + writePostamble, + prepareForFallback, } from './ReactDOMServerFormatConfig'; import {stringToChunk} from 'react-server/src/ReactServerStreamConfig'; diff --git a/packages/react-dom-bindings/src/shared/ReactDOMResourceValidation.js b/packages/react-dom-bindings/src/shared/ReactDOMResourceValidation.js index d52656e5a3966..08571b9d869de 100644 --- a/packages/react-dom-bindings/src/shared/ReactDOMResourceValidation.js +++ b/packages/react-dom-bindings/src/shared/ReactDOMResourceValidation.js @@ -11,69 +11,6 @@ import hasOwnProperty from 'shared/hasOwnProperty'; type Props = {[string]: mixed}; -export function warnOnMissingHrefAndRel( - pendingProps: Props, - currentProps: ?Props, -) { - if (__DEV__) { - if (currentProps != null) { - const originalResourceName = - typeof currentProps.href === 'string' - ? `Resource with href "${currentProps.href}"` - : 'Resource'; - const originalRelStatement = getValueDescriptorExpectingEnumForWarning( - currentProps.rel, - ); - const pendingRel = getValueDescriptorExpectingEnumForWarning( - pendingProps.rel, - ); - const pendingHref = getValueDescriptorExpectingEnumForWarning( - pendingProps.href, - ); - if (typeof pendingProps.rel !== 'string') { - console.error( - 'A previously rendered as a %s with rel "%s" but was updated with an invalid rel: %s. When a link' + - ' does not have a valid rel prop it is not represented in the DOM. If this is intentional, instead' + - ' do not render the anymore.', - originalResourceName, - originalRelStatement, - pendingRel, - ); - } else if (typeof pendingProps.href !== 'string') { - console.error( - 'A previously rendered as a %s but was updated with an invalid href prop: %s. When a link' + - ' does not have a valid href prop it is not represented in the DOM. If this is intentional, instead' + - ' do not render the anymore.', - originalResourceName, - pendingHref, - ); - } - } else { - const pendingRel = getValueDescriptorExpectingEnumForWarning( - pendingProps.rel, - ); - const pendingHref = getValueDescriptorExpectingEnumForWarning( - pendingProps.href, - ); - if (typeof pendingProps.rel !== 'string') { - console.error( - 'A is rendering with an invalid rel: %s. When a link' + - ' does not have a valid rel prop it is not represented in the DOM. If this is intentional, instead' + - ' do not render the anymore.', - pendingRel, - ); - } else if (typeof pendingProps.href !== 'string') { - console.error( - 'A is rendering with an invalid href: %s. When a link' + - ' does not have a valid href prop it is not represented in the DOM. If this is intentional, instead' + - ' do not render the anymore.', - pendingHref, - ); - } - } - } -} - export function validatePreloadResourceDifference( originalProps: any, originalImplicit: boolean, 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..168be5467f903 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; @@ -5106,158 +5148,136 @@ describe('ReactDOMFizzServer', () => { }); it('should warn in dev when given an array of length 2 or more', async () => { - const originalConsoleError = console.error; - const mockError = jest.fn(); - console.error = (...args) => { - if (args.length > 1) { - if (typeof args[1] === 'object') { - mockError(args[0].split('\n')[0]); - return; - } - } - mockError(...args.map(normalizeCodeLocInfo)); - }; - // a Single string child function App() { - return {['hello1', 'hello2']}; + return ( + + + {['hello1', 'hello2']} + + + + ); } - try { - prepareJSDOMForTitle(); - - await actIntoContainer(async () => { + await expect(async () => { + await act(async () => { const {pipe} = renderToPipeableStream(); pipe(writable); }); - if (__DEV__) { - expect(mockError).toHaveBeenCalledWith( - 'Warning: A title element received an array with more than 1 element as children. ' + - 'In browsers title Elements can only have Text Nodes as children. If ' + - 'the children being rendered output more than a single text node in aggregate the browser ' + - 'will display markup and comments as text in the title and hydration will likely fail and ' + - 'fall back to client rendering%s', - '\n' + ' in title (at **)\n' + ' in App (at **)', - ); - } else { - expect(mockError).not.toHaveBeenCalled(); - } + }).toErrorDev( + 'Warning: A title element received an array with more than 1 element as children. ' + + 'In browsers title Elements can only have Text Nodes as children. If ' + + 'the children being rendered output more than a single text node in aggregate the browser ' + + 'will display markup and comments as text in the title and hydration will likely fail and ' + + 'fall back to client rendering', + ); - if (gate(flags => flags.enableFloat)) { - // This title was invalid so it is not emitted - expect(getMeaningfulChildren(container)).toEqual(undefined); - } else { - expect(getMeaningfulChildren(container)).toEqual( - {'hello1<!-- -->hello2'}, - ); - } + if (gate(flags => flags.enableFloat)) { + // This title was invalid so it is not emitted + expect(getMeaningfulChildren(document.head)).toEqual(); + } else { + expect(getMeaningfulChildren(document.head)).toEqual( + <title>{'hello1<!-- -->hello2'}, + ); + } - const errors = []; - ReactDOMClient.hydrateRoot(container, , { - onRecoverableError(error) { - errors.push(error.message); - }, - }); + const errors = []; + ReactDOMClient.hydrateRoot(document, , { + onRecoverableError(error) { + errors.push(error.message); + }, + }); + + if (gate(flags => flags.enableFloat)) { expect(Scheduler).toFlushAndYield([]); - if (gate(flags => flags.enableFloat)) { - expect(errors).toEqual([]); - // with float, the title doesn't render on the client or on the server - expect(getMeaningfulChildren(container)).toEqual(undefined); - } 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(getMeaningfulChildren(container)).toEqual( - {['hello1', 'hello2']}, - ); - } - } finally { - console.error = originalConsoleError; + expect(errors).toEqual([]); + // with float, the title doesn't render on the client or on the server + expect(getMeaningfulChildren(document.head)).toEqual(); + } else { + expect(() => { + expect(Scheduler).toFlushAndYield([]); + }).toErrorDev( + [ + 'Text content did not match. Server: "hello1<!-- -->hello2" Client: "hello1"', + 'An error occurred during hydration. The server HTML was replaced with client content in <#document>', + ], + {withoutStack: 1}, + ); + 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(getMeaningfulChildren(document.head)).toEqual( + <title>{['hello1', 'hello2']}, + ); } }); it('should warn in dev if you pass a React Component as a child to ', async () => { - const originalConsoleError = console.error; - const mockError = jest.fn(); - console.error = (...args) => { - if (args.length > 1) { - if (typeof args[1] === 'object') { - mockError(args[0].split('\n')[0]); - return; - } - } - mockError(...args.map(normalizeCodeLocInfo)); - }; - function IndirectTitle() { return 'hello'; } function App() { return ( - <title> - <IndirectTitle /> - + + + + <IndirectTitle /> + + + + ); } - try { - prepareJSDOMForTitle(); - - await actIntoContainer(async () => { + await expect(async () => { + await act(async () => { const {pipe} = renderToPipeableStream(); pipe(writable); }); - if (__DEV__) { - expect(mockError).toHaveBeenCalledWith( - 'Warning: A title element received a React element for children. ' + - 'In the browser title Elements can only have Text Nodes as children. If ' + - 'the children being rendered output more than a single text node in aggregate the browser ' + - 'will display markup and comments as text in the title and hydration will likely fail and ' + - 'fall back to client rendering%s', - '\n' + ' in title (at **)\n' + ' in App (at **)', - ); - } else { - expect(mockError).not.toHaveBeenCalled(); - } + }).toErrorDev( + 'Warning: A title element received a React element for children. ' + + 'In the browser title Elements can only have Text Nodes as children. If ' + + 'the children being rendered output more than a single text node in aggregate the browser ' + + 'will display markup and comments as text in the title and hydration will likely fail and ' + + 'fall back to client rendering', + ); - if (gate(flags => flags.enableFloat)) { - // object titles are toStringed when float is on - expect(getMeaningfulChildren(container)).toEqual( - {'[object Object]'}, - ); - } else { - expect(getMeaningfulChildren(container)).toEqual( - hello, - ); - } + if (gate(flags => flags.enableFloat)) { + // object titles are toStringed when float is on + expect(getMeaningfulChildren(document.head)).toEqual( + {'[object Object]'}, + ); + } else { + expect(getMeaningfulChildren(document.head)).toEqual( + hello, + ); + } - const errors = []; - ReactDOMClient.hydrateRoot(container, , { - onRecoverableError(error) { - errors.push(error.message); - }, - }); - expect(Scheduler).toFlushAndYield([]); - expect(errors).toEqual([]); - if (gate(flags => flags.enableFloat)) { - // object titles are toStringed when float is on - expect(getMeaningfulChildren(container)).toEqual( - {'[object Object]'}, - ); - } else { - expect(getMeaningfulChildren(container)).toEqual( - hello, - ); - } - } finally { - console.error = originalConsoleError; + const errors = []; + ReactDOMClient.hydrateRoot(document, , { + onRecoverableError(error) { + errors.push(error.message); + }, + }); + expect(Scheduler).toFlushAndYield([]); + expect(errors).toEqual([]); + if (gate(flags => flags.enableFloat)) { + // object titles are toStringed when float is on + expect(getMeaningfulChildren(document.head)).toEqual( + {'[object Object]'}, + ); + } else { + expect(getMeaningfulChildren(document.head)).toEqual( + hello, + ); } }); @@ -6122,4 +6142,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', '', ]); }); @@ -642,47 +835,6 @@ describe('ReactDOMFloat', () => { ).toEqual(['']); }); - describe('HostResource', () => { - // @gate enableFloat - it('warns when you update props to an invalid type', async () => { - const root = ReactDOMClient.createRoot(container); - root.render( -
- - -
, - ); - expect(Scheduler).toFlushWithoutYielding(); - root.render( -
- {}} href="bar" /> - {}} /> -
, - ); - expect(() => { - expect(Scheduler).toFlushWithoutYielding(); - }).toErrorDev([ - 'Warning: A previously rendered as a Resource with href "foo" with rel ""stylesheet"" but was updated with an invalid rel: something with type "function". When a link does not have a valid rel prop it is not represented in the DOM. If this is intentional, instead do not render the anymore.', - 'Warning: A previously rendered as a Resource with href "bar" but was updated with an invalid href prop: something with type "function". When a link does not have a valid href prop it is not represented in the DOM. If this is intentional, instead do not render the anymore.', - ]); - expect(getMeaningfulChildren(document)).toEqual( - - - - - - - - -
-
-
- - , - ); - }); - }); - describe('ReactDOM.preload', () => { // @gate enableFloat it('inserts a preload resource into the stream when called during server rendering', async () => { @@ -1232,11 +1384,11 @@ describe('ReactDOMFloat', () => { expect(getMeaningfulChildren(document)).toEqual( - - + + @@ -1273,11 +1425,11 @@ describe('ReactDOMFloat', () => { expect(getMeaningfulChildren(document)).toEqual( - - + + @@ -1312,64 +1464,6 @@ describe('ReactDOMFloat', () => { ); }); - // @gate enableFloat - it('can render as a Resource', async () => { - await actIntoEmptyDocument(() => { - const {pipe} = renderToPipeableStream( - - - - - - -
hello world
- - , - ); - pipe(writable); - }); - expect(getMeaningfulChildren(document)).toEqual( - - - - - - - -
hello world
- - , - ); - - ReactDOMClient.hydrateRoot( - document, - - - - - - - -
hello world
- - , - ); - expect(Scheduler).toFlushWithoutYielding(); - expect(getMeaningfulChildren(document)).toEqual( - - - - - - - - -
hello world
- - , - ); - }); - // @gate enableFloat it('can render icons and apple-touch-icons as Resources', async () => { await actIntoEmptyDocument(() => { @@ -1646,11 +1740,6 @@ describe('ReactDOMFloat', () => { property="og:description" content="my site" /> - @@ -1660,169 +1749,12 @@ describe('ReactDOMFloat', () => { - - -
hello world
- - , - ); - }); - - // @gate enableFloat - it('can render meta tags with og properties with structured data', async () => { - await actIntoEmptyDocument(() => { - const {pipe} = renderToPipeableStream( - <> - - - -
hello world
- - - - - - - - - , - ); - pipe(writable); - }); - expect(getMeaningfulChildren(document)).toEqual( - - - - - - - - - - -
hello world
- - , - ); - - const root = ReactDOMClient.hydrateRoot( - document, - - - - - - - - - -
hello world
- - , - ); - expect(Scheduler).toFlushWithoutYielding(); - expect(getMeaningfulChildren(document)).toEqual( - - - - - - - - - - -
hello world
- - , - ); - - root.render( - - - - - - - - -
hello world
- - , - ); - expect(Scheduler).toFlushWithoutYielding(); - expect(getMeaningfulChildren(document)).toEqual( - - - - - - - - - -
hello world
- - , - ); - - root.render( - - - - - - - - - -
hello world
- - , - ); - expect(Scheduler).toFlushWithoutYielding(); - expect(getMeaningfulChildren(document)).toEqual( - - - - - - - - - - -
hello world
- - , - ); - - root.render( - - - - - - - - - - -
hello world
- - , - ); - expect(Scheduler).toFlushWithoutYielding(); - expect(getMeaningfulChildren(document)).toEqual( - - - - - - - - - + +
hello world
@@ -2027,64 +1959,6 @@ describe('ReactDOMFloat', () => { ); }); - // @gate enableFloat - it('keys titles on text children and only removes them when no more instances refer to that title', async () => { - const root = ReactDOMClient.createRoot(container); - root.render( -
- {[2]}hello world2 -
, - ); - expect(Scheduler).toFlushWithoutYielding(); - expect(getMeaningfulChildren(document)).toEqual( - - - 2 - - -
-
hello world
-
- - , - ); - - root.render( -
- {null}hello world2 -
, - ); - expect(Scheduler).toFlushWithoutYielding(); - expect(getMeaningfulChildren(document)).toEqual( - - - 2 - - -
-
hello world
-
- - , - ); - root.render( -
- {null}hello world{null} -
, - ); - expect(Scheduler).toFlushWithoutYielding(); - expect(getMeaningfulChildren(document)).toEqual( - - - -
-
hello world
-
- - , - ); - }); - // @gate enableFloat && enableHostSingletons && (enableClientRenderFallbackOnTextMismatch || !__DEV__) it('can render a title before a singleton even if that singleton clears its contents', async () => { await actIntoEmptyDocument(() => { @@ -5564,7 +5438,6 @@ describe('ReactDOMFloat', () => { bar