[Float][Fiber] implement a faster hydration match for hoistable elements (#26154)

This PR is now based on #26256 

The original matching function for `hydrateHoistable` some challenging
time complexity since we built up the list of matchable nodes for each
link of that type and then had to check to exclusion. This new
implementation aims to improve the complexity

For hoisted title tags we match the first title if it is valid (not in
SVG context and does not have `itemprop`, the two ways you opt out of
hoisting when rendering titles). This path is much faster than others
and we use it because valid Documents only have 1 title anyway and if we
did have a mismatch the rendered title still ends up as the
Document.title so there is no functional degradation for misses.

For hoisted link and meta tags we track all potentially hydratable
Elements of this type in a cache per Document. The cache is refreshed
once each commit if and only if there is a title or meta hoistable
hydrating. The caches are partitioned by a natural key for each type
(href for link and content for meta). Then secondary attributes are
checked to see if the potential match is matchable.

For link we check `rel`, `title`, and `crossorigin`. These should
provide enough entropy that we never have collisions except is contrived
cases and even then it should not affect functionality of the page. This
should also be tolerant of links being injected in arbitrary places in
the Document by 3rd party scripts and browser extensions

For meta we check `name`, `property`, `http-equiv`, and `charset`. These
should provide enough entropy that we don't have meaningful collisions.
It is concievable with og tags that there may be true duplciates `<meta
property="og:image:size:height" content="100" />` but even if we did
bind to the wrong instance meta tags are typically only read from SSR by
bots and rarely inserted by 3rd parties so an adverse functional outcome
is not expected.
This commit is contained in:
Josh Story 2023-03-06 19:52:35 -08:00 committed by GitHub
parent 8a9f82ed58
commit 978fae4b4f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 433 additions and 302 deletions

View File

@ -55,12 +55,7 @@ import {
setValueForStyles,
validateShorthandPropertyCollisionInDev,
} from './CSSPropertyOperations';
import {
HTML_NAMESPACE,
MATH_NAMESPACE,
SVG_NAMESPACE,
getIntrinsicNamespace,
} from '../shared/DOMNamespaces';
import {HTML_NAMESPACE, getIntrinsicNamespace} from '../shared/DOMNamespaces';
import {
getPropertyInfo,
shouldIgnoreAttribute,
@ -380,15 +375,83 @@ function updateDOMProperties(
}
}
// creates a script element that won't execute
export function createPotentiallyInlineScriptElement(
ownerDocument: Document,
): Element {
// Create the script via .innerHTML so its "parser-inserted" flag is
// set to true and it does not execute
const div = ownerDocument.createElement('div');
if (__DEV__) {
if (enableTrustedTypesIntegration && !didWarnScriptTags) {
console.error(
'Encountered a script tag while rendering React component. ' +
'Scripts inside React components are never executed when rendering ' +
'on the client. Consider using template tag instead ' +
'(https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template).',
);
didWarnScriptTags = true;
}
}
div.innerHTML = '<script><' + '/script>'; // eslint-disable-line
// This is guaranteed to yield a script element.
const firstChild = ((div.firstChild: any): HTMLScriptElement);
const element = div.removeChild(firstChild);
return element;
}
export function createSelectElement(
props: Object,
ownerDocument: Document,
): Element {
let element;
if (typeof props.is === 'string') {
element = ownerDocument.createElement('select', {is: props.is});
} else {
// Separate else branch instead of using `props.is || undefined` above because of a Firefox bug.
// See discussion in https://github.com/facebook/react/pull/6896
// and discussion in https://bugzilla.mozilla.org/show_bug.cgi?id=1276240
element = ownerDocument.createElement('select');
}
if (props.multiple) {
element.multiple = true;
} else if (props.size) {
// Setting a size greater than 1 causes a select to behave like `multiple=true`, where
// it is possible that no option is selected.
//
// This is only necessary when a select in "single selection mode".
element.size = props.size;
}
return element;
}
// Creates elements in the HTML namesapce
export function createHTMLElement(
type: string,
props: Object,
ownerDocument: Document,
): Element {
if (__DEV__) {
switch (type) {
case 'script':
case 'select':
console.error(
'createHTMLElement was called with a "%s" type. This type has special creation logic in React and should use the create function implemented specifically for it. This is a bug in React.',
type,
);
break;
case 'svg':
case 'math':
console.error(
'createHTMLElement was called with a "%s" type. This type must be created with Document.createElementNS which this method does not implement. This is a bug in React.',
type,
);
}
}
let isCustomComponentTag;
let domElement: Element;
let element: Element;
if (__DEV__) {
isCustomComponentTag = isCustomComponent(type, props);
// Should this check be gated by parent namespace? Not sure we want to
@ -403,59 +466,20 @@ export function createHTMLElement(
}
}
if (type === 'script') {
// Create the script via .innerHTML so its "parser-inserted" flag is
// set to true and it does not execute
const div = ownerDocument.createElement('div');
if (__DEV__) {
if (enableTrustedTypesIntegration && !didWarnScriptTags) {
console.error(
'Encountered a script tag while rendering React component. ' +
'Scripts inside React components are never executed when rendering ' +
'on the client. Consider using template tag instead ' +
'(https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template).',
);
didWarnScriptTags = true;
}
}
div.innerHTML = '<script><' + '/script>'; // eslint-disable-line
// This is guaranteed to yield a script element.
const firstChild = ((div.firstChild: any): HTMLScriptElement);
domElement = div.removeChild(firstChild);
} else if (typeof props.is === 'string') {
domElement = ownerDocument.createElement(type, {is: props.is});
if (typeof props.is === 'string') {
element = ownerDocument.createElement(type, {is: props.is});
} else {
// Separate else branch instead of using `props.is || undefined` above because of a Firefox bug.
// See discussion in https://github.com/facebook/react/pull/6896
// and discussion in https://bugzilla.mozilla.org/show_bug.cgi?id=1276240
domElement = ownerDocument.createElement(type);
// Normally attributes are assigned in `setInitialDOMProperties`, however the `multiple` and `size`
// attributes on `select`s needs to be added before `option`s are inserted.
// This prevents:
// - a bug where the `select` does not scroll to the correct option because singular
// `select` elements automatically pick the first item #13222
// - a bug where the `select` set the first item as selected despite the `size` attribute #14239
// See https://github.com/facebook/react/issues/13222
// and https://github.com/facebook/react/issues/14239
if (type === 'select') {
const node = ((domElement: any): HTMLSelectElement);
if (props.multiple) {
node.multiple = true;
} else if (props.size) {
// Setting a size greater than 1 causes a select to behave like `multiple=true`, where
// it is possible that no option is selected.
//
// This is only necessary when a select in "single selection mode".
node.size = props.size;
}
}
element = ownerDocument.createElement(type);
}
if (__DEV__) {
if (
!isCustomComponentTag &&
// $FlowFixMe[method-unbinding]
Object.prototype.toString.call(domElement) ===
Object.prototype.toString.call(element) ===
'[object HTMLUnknownElement]' &&
!hasOwnProperty.call(warnedUnknownTags, type)
) {
@ -469,21 +493,7 @@ export function createHTMLElement(
}
}
return domElement;
}
export function createSVGElement(
type: string,
ownerDocument: Document,
): Element {
return ownerDocument.createElementNS(SVG_NAMESPACE, type);
}
export function createMathElement(
type: string,
ownerDocument: Document,
): Element {
return ownerDocument.createElementNS(MATH_NAMESPACE, type);
return element;
}
export function createTextNode(

View File

@ -47,7 +47,7 @@ const internalEventHandlersKey = '__reactEvents$' + randomKey;
const internalEventHandlerListenersKey = '__reactListeners$' + randomKey;
const internalEventHandlesSetKey = '__reactHandles$' + randomKey;
const internalRootNodeResourcesKey = '__reactResources$' + randomKey;
const internalResourceMarker = '__reactMarker$' + randomKey;
const internalHoistableMarker = '__reactMarker$' + randomKey;
export function detachDeletedInstance(node: Instance): void {
// TODO: This function is only called on host components. I don't think all of
@ -288,10 +288,16 @@ export function getResourcesFromRoot(root: HoistableRoot): RootResources {
return resources;
}
export function isMarkedResource(node: Node): boolean {
return !!(node: any)[internalResourceMarker];
export function isMarkedHoistable(node: Node): boolean {
return !!(node: any)[internalHoistableMarker];
}
export function markNodeAsResource(node: Node) {
(node: any)[internalResourceMarker] = true;
export function markNodeAsHoistable(node: Node) {
(node: any)[internalHoistableMarker] = true;
}
export function isOwnedInstance(node: Node): boolean {
return !!(
(node: any)[internalHoistableMarker] || (node: any)[internalInstanceKey]
);
}

View File

@ -11,15 +11,12 @@ import type {Instance, Container} from './ReactDOMHostConfig';
import {getCurrentRootHostContainer} from 'react-reconciler/src/ReactFiberHostContext';
import hasOwnProperty from 'shared/hasOwnProperty';
import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals.js';
const {Dispatcher} = ReactDOMSharedInternals;
import {
checkAttributeStringCoercion,
checkPropStringCoercion,
} from 'shared/CheckStringCoercion';
import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
import {DOCUMENT_NODE} from '../shared/HTMLNodeType';
import {isAttributeNameSafe} from '../shared/DOMProperty';
import {SVG_NAMESPACE} from '../shared/DOMNamespaces';
import {
validatePreloadArguments,
@ -28,19 +25,19 @@ import {
getValueDescriptorExpectingEnumForWarning,
} from '../shared/ReactDOMResourceValidation';
import {precacheFiberNode} from './ReactDOMComponentTree';
import {createHTMLElement, setInitialProperties} from './ReactDOMComponent';
import {setInitialProperties} from './ReactDOMComponent';
import {
precacheFiberNode,
getResourcesFromRoot,
isMarkedResource,
markNodeAsResource,
isOwnedInstance,
markNodeAsHoistable,
} from './ReactDOMComponentTree';
// 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
type ResourceType = 'style' | 'font' | 'script';
type HoistableTagType = 'link' | 'meta' | 'title' | 'script' | 'style';
type HoistableTagType = 'link' | 'meta' | 'title';
type TResource<T: 'stylesheet' | 'style' | 'script' | 'void'> = {
type: T,
instance: null | Instance,
@ -104,6 +101,10 @@ export function cleanupAfterRenderResources() {
previousDispatcher = null;
}
export function prepareToCommitHoistables() {
tagCaches = null;
}
// We want this to be the default dispatcher on ReactDOMSharedInternals but we don't want to mutate
// internals in Module scope. Instead we export it and Internals will import it. There is already a cycle
// from Internals -> ReactDOM -> FloatClient -> Internals so this doesn't introduce a new one.
@ -176,13 +177,9 @@ function preconnectAs(
const preconnectProps = {rel, crossOrigin, href};
if (null === ownerDocument.querySelector(key)) {
const preloadInstance = createHTMLElement(
'link',
preconnectProps,
ownerDocument,
);
const preloadInstance = ownerDocument.createElement('link');
setInitialProperties(preloadInstance, 'link', preconnectProps);
markNodeAsResource(preloadInstance);
markNodeAsHoistable(preloadInstance);
(ownerDocument.head: any).appendChild(preloadInstance);
}
}
@ -202,7 +199,7 @@ function prefetchDNS(href: string, options?: mixed) {
} else if (options != null) {
if (
typeof options === 'object' &&
options.hasOwnProperty('crossOrigin')
hasOwnProperty.call(options, 'crossOrigin')
) {
console.error(
'ReactDOM.prefetchDNS(): Expected only one argument, `href`, but encountered %s as a second argument instead. This argument is reserved for future options and is currently disallowed. It looks like the you are attempting to set a crossOrigin property for this DNS lookup hint. Browsers do not perform DNS queries using CORS and setting this attribute on the resource hint has no effect. Try calling ReactDOM.prefetchDNS() with just a single string argument, `href`.',
@ -290,13 +287,9 @@ function preload(href: string, options: PreloadOptions) {
preloadPropsMap.set(key, preloadProps);
if (null === ownerDocument.querySelector(preloadKey)) {
const preloadInstance = createHTMLElement(
'link',
preloadProps,
ownerDocument,
);
const preloadInstance = ownerDocument.createElement('link');
setInitialProperties(preloadInstance, 'link', preloadProps);
markNodeAsResource(preloadInstance);
markNodeAsHoistable(preloadInstance);
(ownerDocument.head: any).appendChild(preloadInstance);
}
}
@ -371,13 +364,9 @@ function preinit(href: string, options: PreinitOptions) {
preloadPropsMap.set(key, preloadProps);
if (null === preloadDocument.querySelector(preloadKey)) {
const preloadInstance = createHTMLElement(
'link',
preloadProps,
preloadDocument,
);
const preloadInstance = preloadDocument.createElement('link');
setInitialProperties(preloadInstance, 'link', preloadProps);
markNodeAsResource(preloadInstance);
markNodeAsHoistable(preloadInstance);
(preloadDocument.head: any).appendChild(preloadInstance);
}
}
@ -417,8 +406,8 @@ function preinit(href: string, options: PreinitOptions) {
adoptPreloadPropsForStylesheet(stylesheetProps, preloadProps);
}
const ownerDocument = getDocumentFromRoot(resourceRoot);
instance = createHTMLElement('link', stylesheetProps, ownerDocument);
markNodeAsResource(instance);
instance = ownerDocument.createElement('link');
markNodeAsHoistable(instance);
setInitialProperties(instance, 'link', stylesheetProps);
insertStylesheet(instance, precedence, resourceRoot);
}
@ -459,10 +448,10 @@ function preinit(href: string, options: PreinitOptions) {
adoptPreloadPropsForScript(scriptProps, preloadProps);
}
const ownerDocument = getDocumentFromRoot(resourceRoot);
instance = createHTMLElement('script', scriptProps, ownerDocument);
markNodeAsResource(instance);
instance = ownerDocument.createElement('script');
markNodeAsHoistable(instance);
setInitialProperties(instance, 'link', scriptProps);
(getDocumentFromRoot(resourceRoot).head: any).appendChild(instance);
(ownerDocument.head: any).appendChild(instance);
}
// Construct a Resource and cache it
@ -694,13 +683,9 @@ function preloadStylesheet(
null ===
ownerDocument.querySelector(getPreloadStylesheetSelectorFromKey(key))
) {
const preloadInstance = createHTMLElement(
'link',
preloadProps,
ownerDocument,
);
const preloadInstance = ownerDocument.createElement('link');
setInitialProperties(preloadInstance, 'link', preloadProps);
markNodeAsResource(preloadInstance);
markNodeAsHoistable(preloadInstance);
(ownerDocument.head: any).appendChild(preloadInstance);
}
}
@ -752,15 +737,15 @@ export function acquireResource(
);
if (instance) {
resource.instance = instance;
markNodeAsResource(instance);
markNodeAsHoistable(instance);
return instance;
}
const styleProps = styleTagPropsFromRawProps(props);
const ownerDocument = getDocumentFromRoot(hoistableRoot);
instance = createHTMLElement('style', styleProps, ownerDocument);
instance = ownerDocument.createElement('style');
markNodeAsResource(instance);
markNodeAsHoistable(instance);
setInitialProperties(instance, 'style', styleProps);
insertStylesheet(instance, qualifiedProps.precedence, hoistableRoot);
resource.instance = instance;
@ -780,7 +765,7 @@ export function acquireResource(
);
if (instance) {
resource.instance = instance;
markNodeAsResource(instance);
markNodeAsHoistable(instance);
return instance;
}
@ -792,8 +777,8 @@ export function acquireResource(
// Construct and insert a new instance
const ownerDocument = getDocumentFromRoot(hoistableRoot);
instance = createHTMLElement('link', stylesheetProps, ownerDocument);
markNodeAsResource(instance);
instance = ownerDocument.createElement('link');
markNodeAsHoistable(instance);
const linkInstance: HTMLLinkElement = (instance: any);
(linkInstance: any)._p = new Promise((resolve, reject) => {
linkInstance.onload = resolve;
@ -821,7 +806,7 @@ export function acquireResource(
);
if (instance) {
resource.instance = instance;
markNodeAsResource(instance);
markNodeAsHoistable(instance);
return instance;
}
@ -834,10 +819,10 @@ export function acquireResource(
// Construct and insert a new instance
const ownerDocument = getDocumentFromRoot(hoistableRoot);
instance = createHTMLElement('script', scriptProps, ownerDocument);
markNodeAsResource(instance);
instance = ownerDocument.createElement('script');
markNodeAsHoistable(instance);
setInitialProperties(instance, 'link', scriptProps);
(getDocumentFromRoot(hoistableRoot).head: any).appendChild(instance);
(ownerDocument.head: any).appendChild(instance);
resource.instance = instance;
return instance;
@ -919,6 +904,10 @@ function adoptPreloadPropsForScript(
// Hoistable Element Reconciliation
// --------------------------------------
type KeyedTagCache = Map<string, Array<Element>>;
type DocumentTagCaches = Map<Document, KeyedTagCache>;
let tagCaches: null | DocumentTagCaches = null;
export function hydrateHoistable(
hoistableRoot: HoistableRoot,
type: HoistableTagType,
@ -926,163 +915,165 @@ export function hydrateHoistable(
internalInstanceHandle: Object,
): Instance {
const ownerDocument = getDocumentFromRoot(hoistableRoot);
const nodes = ownerDocument.getElementsByTagName(type);
const children = props.children;
let child, childString;
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
) {
if (__DEV__) {
checkPropStringCoercion(child, 'children');
let instance: ?Instance = null;
getInstance: switch (type) {
case 'title': {
instance = ownerDocument.getElementsByTagName('title')[0];
if (
!instance ||
isOwnedInstance(instance) ||
instance.namespaceURI === SVG_NAMESPACE ||
instance.hasAttribute('itemprop')
) {
instance = ownerDocument.createElement(type);
(ownerDocument.head: any).insertBefore(
instance,
ownerDocument.querySelector('head > title'),
);
}
setInitialProperties(instance, type, props);
precacheFiberNode(internalInstanceHandle, instance);
markNodeAsHoistable(instance);
return instance;
}
childString = '' + (child: any);
} else {
childString = '';
case 'link': {
const cache = getHydratableHoistableCache('link', 'href', ownerDocument);
const key = type + (props.href || '');
const maybeNodes = cache.get(key);
if (maybeNodes) {
const nodes = maybeNodes;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (
node.getAttribute('href') !==
(props.href == null ? null : props.href) ||
node.getAttribute('rel') !==
(props.rel == null ? null : props.rel) ||
node.getAttribute('title') !==
(props.title == null ? null : props.title) ||
node.getAttribute('crossorigin') !==
(props.crossOrigin == null ? null : props.crossOrigin)
) {
// mismatch, try the next node;
continue;
}
instance = node;
nodes.splice(i, 1);
break getInstance;
}
}
instance = ownerDocument.createElement(type);
setInitialProperties(instance, type, props);
(ownerDocument.head: any).appendChild(instance);
break;
}
case 'meta': {
const cache = getHydratableHoistableCache(
'meta',
'content',
ownerDocument,
);
const key = type + (props.content || '');
const maybeNodes = cache.get(key);
if (maybeNodes) {
const nodes = maybeNodes;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
// We coerce content to string because it is the most likely one to
// use a `toString` capable value. For the rest we just do identity match
// passing non-strings here is not really valid anyway.
if (__DEV__) {
checkAttributeStringCoercion(props.content, 'content');
}
if (
node.getAttribute('content') !==
(props.content == null ? null : '' + props.content) ||
node.getAttribute('name') !==
(props.name == null ? null : props.name) ||
node.getAttribute('property') !==
(props.property == null ? null : props.property) ||
node.getAttribute('http-equiv') !==
(props.httpEquiv == null ? null : props.httpEquiv) ||
node.getAttribute('charset') !==
(props.charSet == null ? null : props.charSet)
) {
// mismatch, try the next node;
continue;
}
instance = node;
nodes.splice(i, 1);
break getInstance;
}
}
instance = ownerDocument.createElement(type);
setInitialProperties(instance, type, props);
(ownerDocument.head: any).appendChild(instance);
break;
}
default:
throw new Error(
`getNodesForType encountered a type it did not expect: "${type}". This is a bug in React.`,
);
}
nodeLoop: for (let i = 0; i < nodes.length; i++) {
// This node is a match
precacheFiberNode(internalInstanceHandle, instance);
markNodeAsHoistable(instance);
return instance;
}
function getHydratableHoistableCache(
type: HoistableTagType,
keyAttribute: string,
ownerDocument: Document,
): KeyedTagCache {
let cache: KeyedTagCache;
let caches: DocumentTagCaches;
if (tagCaches === null) {
cache = new Map();
caches = tagCaches = new Map();
caches.set(ownerDocument, cache);
} else {
caches = tagCaches;
const maybeCache = caches.get(ownerDocument);
if (!maybeCache) {
cache = new Map();
caches.set(ownerDocument, cache);
} else {
cache = maybeCache;
}
}
if (cache.has(type)) {
// We use type as a special key that signals that this cache has been seeded for this type
return cache;
}
// Mark this cache as seeded for this type
cache.set(type, (null: any));
const nodes = ownerDocument.getElementsByTagName(type);
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (
isMarkedResource(node) ||
node.namespaceURI === SVG_NAMESPACE ||
node.textContent !== childString
!isOwnedInstance(node) &&
(type !== 'link' || node.getAttribute('rel') !== 'stylesheet') &&
node.namespaceURI !== SVG_NAMESPACE
) {
continue;
}
let checkedAttributes = 0;
for (const propName in props) {
const propValue = props[propName];
if (!props.hasOwnProperty(propName)) {
continue;
const nodeKey = node.getAttribute(keyAttribute) || '';
const key = type + nodeKey;
const existing = cache.get(key);
if (existing) {
existing.push(node);
} else {
cache.set(key, [node]);
}
switch (propName) {
// Reserved props will never have an attribute partner
case 'children':
case 'defaultValue':
case 'dangerouslySetInnerHTML':
case 'defaultChecked':
case 'innerHTML':
case 'suppressContentEditableWarning':
case 'suppressHydrationWarning':
case 'style':
// we advance to the next prop
continue;
// Name remapped props used by hoistable tag types
case 'className': {
if (__DEV__) {
checkAttributeStringCoercion(propValue, propName);
}
if (node.getAttribute('class') !== '' + propValue) continue nodeLoop;
break;
}
case 'httpEquiv': {
if (__DEV__) {
checkAttributeStringCoercion(propValue, propName);
}
if (node.getAttribute('http-equiv') !== '' + propValue)
continue nodeLoop;
break;
}
// Booleanish props used by hoistable tag types
case 'contentEditable':
case 'draggable':
case 'spellCheck': {
if (__DEV__) {
checkAttributeStringCoercion(propValue, propName);
}
if (node.getAttribute(propName) !== '' + propValue) continue nodeLoop;
break;
}
// Boolean props used by hoistable tag types
case 'async':
case 'defer':
case 'disabled':
case 'hidden':
case 'noModule':
case 'scoped':
case 'itemScope':
if (propValue !== node.hasAttribute(propName)) continue nodeLoop;
break;
// The following properties are left out because they do not apply to
// the current set of hoistable types. They may have special handling
// requirements if they end up applying to a hoistable type in the future
// case 'acceptCharset':
// case 'value':
// case 'allowFullScreen':
// case 'autoFocus':
// case 'autoPlay':
// case 'controls':
// case 'default':
// case 'disablePictureInPicture':
// case 'disableRemotePlayback':
// case 'formNoValidate':
// case 'loop':
// case 'noValidate':
// case 'open':
// case 'playsInline':
// case 'readOnly':
// case 'required':
// case 'reversed':
// case 'seamless':
// case 'multiple':
// case 'selected':
// case 'capture':
// case 'download':
// case 'cols':
// case 'rows':
// case 'size':
// case 'span':
// case 'rowSpan':
// case 'start':
default:
if (isAttributeNameSafe(propName)) {
const attributeName = propName;
if (propValue == null && node.hasAttribute(attributeName))
continue nodeLoop;
if (__DEV__) {
checkAttributeStringCoercion(propValue, attributeName);
}
if (node.getAttribute(attributeName) !== '' + (propValue: any))
continue nodeLoop;
}
}
checkedAttributes++;
}
if (node.attributes.length !== checkedAttributes) {
// We didn't match ever attribute so we abandon this node
continue nodeLoop;
}
// We found a matching instance. We can return early after marking it
markNodeAsResource(node);
return node;
}
// There is no matching instance to hydrate, we create it now
const instance = createHTMLElement(type, props, ownerDocument);
setInitialProperties(instance, type, props);
precacheFiberNode(internalInstanceHandle, instance);
markNodeAsResource(instance);
(ownerDocument.head: any).insertBefore(
instance,
type === 'title' ? ownerDocument.querySelector('head > title') : null,
);
return instance;
return cache;
}
export function mountHoistable(

View File

@ -26,15 +26,15 @@ import {
getInstanceFromNode as getInstanceFromNodeDOMTree,
isContainerMarkedAsRoot,
detachDeletedInstance,
isMarkedResource,
markNodeAsResource,
isMarkedHoistable,
markNodeAsHoistable,
} from './ReactDOMComponentTree';
export {detachDeletedInstance};
import {hasRole} from './DOMAccessibilityRoles';
import {
createHTMLElement,
createSVGElement,
createMathElement,
createPotentiallyInlineScriptElement,
createSelectElement,
createTextNode,
setInitialProperties,
diffProperties,
@ -61,7 +61,6 @@ import {
getChildNamespace,
SVG_NAMESPACE,
MATH_NAMESPACE,
HTML_NAMESPACE,
} from '../shared/DOMNamespaces';
import {
ELEMENT_NODE,
@ -284,7 +283,7 @@ export function createHoistableInstance(
precacheFiberNode(internalInstanceHandle, domElement);
updateFiberProps(domElement, props);
setInitialProperties(domElement, type, props);
markNodeAsResource(domElement);
markNodeAsHoistable(domElement);
return domElement;
}
@ -322,25 +321,28 @@ export function createInstance(
);
let domElement: Instance;
create: switch (namespace) {
switch (namespace) {
case SVG_NAMESPACE:
domElement = createSVGElement(type, ownerDocument);
break;
case MATH_NAMESPACE:
domElement = createMathElement(type, ownerDocument);
domElement = ownerDocument.createElementNS(namespace, type);
break;
case HTML_NAMESPACE:
default:
switch (type) {
case 'svg':
domElement = createSVGElement(type, ownerDocument);
break create;
domElement = ownerDocument.createElementNS(SVG_NAMESPACE, type);
break;
case 'math':
domElement = createMathElement(type, ownerDocument);
break create;
domElement = ownerDocument.createElementNS(MATH_NAMESPACE, type);
break;
case 'script':
domElement = createPotentiallyInlineScriptElement(ownerDocument);
break;
case 'select':
domElement = createSelectElement(props, ownerDocument);
break;
default:
domElement = createHTMLElement(type, props, ownerDocument);
}
// eslint-disable-next-line no-fallthrough
default:
domElement = createHTMLElement(type, props, ownerDocument);
}
precacheFiberNode(internalInstanceHandle, domElement);
updateFiberProps(domElement, props);
@ -876,7 +878,7 @@ export function shouldSkipHydratableForInstance(
return false;
} else if (
instance.nodeName.toLowerCase() !== type.toLowerCase() ||
isMarkedResource(instance)
isMarkedHoistable(instance)
) {
// We are either about to
return true;
@ -1807,6 +1809,7 @@ export {
hydrateHoistable,
mountHoistable,
unmountHoistable,
prepareToCommitHoistables,
} from './ReactDOMFloatClient';
// -------------------
@ -1936,7 +1939,7 @@ export function clearSingleton(instance: Instance): void {
const nextNode = node.nextSibling;
const nodeName = node.nodeName;
if (
isMarkedResource(node) ||
isMarkedHoistable(node) ||
nodeName === 'HEAD' ||
nodeName === 'BODY' ||
nodeName === 'STYLE' ||

View File

@ -2388,7 +2388,7 @@ body {
);
ReactDOMClient.hydrateRoot(document, <App />);
expect(Scheduler).toFlushWithoutYielding();
await waitForAll([]);
expect(getMeaningfulChildren(document)).toEqual(
<html>
@ -2441,8 +2441,8 @@ body {
<script itemProp="foo" />
</html>,
);
expect(() => {
expect(Scheduler).toFlushWithoutYielding();
await expect(async () => {
await waitForAll([]);
}).toErrorDev([
'Cannot render a <meta> outside the main document if it has an `itemProp` prop. `itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. If you were intending for React to hoist this <meta> remove the `itemProp` prop. Otherwise, try moving this tag into the <head> or <body> of the Document.',
'Cannot render a <title> outside the main document if it has an `itemProp` prop. `itemProp` suggests the tag belongs to an `itemScope` which can appear anywhere in the DOM. If you were intending for React to hoist this <title> remove the `itemProp` prop. Otherwise, try moving this tag into the <head> or <body> of the Document.',
@ -2567,7 +2567,7 @@ body {
// script insertion that happens because we do not SSR async scripts with load handlers.
// All the extra inject nodes are preset
const root = ReactDOMClient.hydrateRoot(document, <App />);
expect(Scheduler).toFlushWithoutYielding();
await waitForAll([]);
expect(getMeaningfulChildren(document)).toEqual(
<html itemscope="">
<head>
@ -5405,6 +5405,120 @@ background-color: green;
);
});
it('can hydrate hoistable tags inside late suspense boundaries', async () => {
function App() {
return (
<html>
<body>
<link rel="rel1" href="linkhref" />
<link rel="rel2" href="linkhref" />
<meta name="name1" content="metacontent" />
<meta name="name2" content="metacontent" />
<Suspense fallback="loading...">
<link rel="rel3" href="linkhref" />
<link rel="rel4" href="linkhref" />
<meta name="name3" content="metacontent" />
<meta name="name4" content="metacontent" />
<BlockedOn value="release">
<link rel="rel5" href="linkhref" />
<link rel="rel6" href="linkhref" />
<meta name="name5" content="metacontent" />
<meta name="name6" content="metacontent" />
<div>hello world</div>
</BlockedOn>
</Suspense>
</body>
</html>
);
}
await actIntoEmptyDocument(() => {
renderToPipeableStream(<App />).pipe(writable);
});
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<link rel="rel1" href="linkhref" />
<link rel="rel2" href="linkhref" />
<meta name="name1" content="metacontent" />
<meta name="name2" content="metacontent" />
<link rel="rel3" href="linkhref" />
<link rel="rel4" href="linkhref" />
<meta name="name3" content="metacontent" />
<meta name="name4" content="metacontent" />
</head>
<body>loading...</body>
</html>,
);
const root = ReactDOMClient.hydrateRoot(document, <App />);
await waitForAll([]);
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<link rel="rel1" href="linkhref" />
<link rel="rel2" href="linkhref" />
<meta name="name1" content="metacontent" />
<meta name="name2" content="metacontent" />
<link rel="rel3" href="linkhref" />
<link rel="rel4" href="linkhref" />
<meta name="name3" content="metacontent" />
<meta name="name4" content="metacontent" />
</head>
<body>loading...</body>
</html>,
);
const thirdPartyLink = document.createElement('link');
thirdPartyLink.setAttribute('href', 'linkhref');
thirdPartyLink.setAttribute('rel', '3rdparty');
document.body.prepend(thirdPartyLink);
const thirdPartyMeta = document.createElement('meta');
thirdPartyMeta.setAttribute('content', 'metacontent');
thirdPartyMeta.setAttribute('name', '3rdparty');
document.body.prepend(thirdPartyMeta);
await act(() => {
resolveText('release');
});
await waitForAll([]);
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<link rel="rel1" href="linkhref" />
<link rel="rel2" href="linkhref" />
<meta name="name1" content="metacontent" />
<meta name="name2" content="metacontent" />
<link rel="rel3" href="linkhref" />
<link rel="rel4" href="linkhref" />
<meta name="name3" content="metacontent" />
<meta name="name4" content="metacontent" />
</head>
<body>
<meta name="3rdparty" content="metacontent" />
<link rel="3rdparty" href="linkhref" />
<div>hello world</div>
<link rel="rel5" href="linkhref" />
<link rel="rel6" href="linkhref" />
<meta name="name5" content="metacontent" />
<meta name="name6" content="metacontent" />
</body>
</html>,
);
root.unmount();
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head />
<body>
<meta name="3rdparty" content="metacontent" />
<link rel="3rdparty" href="linkhref" />
</body>
</html>,
);
});
// @gate enableFloat
it('does not hoist inside an <svg> context', async () => {
await actIntoEmptyDocument(() => {

View File

@ -157,6 +157,7 @@ import {
hydrateHoistable,
mountHoistable,
unmountHoistable,
prepareToCommitHoistables,
} from './ReactFiberHostConfig';
import {
captureCommitPhaseError,
@ -2822,6 +2823,8 @@ function commitMutationEffectsOnFiber(
}
case HostRoot: {
if (enableFloat && supportsResources) {
prepareToCommitHoistables();
const previousHoistableRoot = currentHoistableRoot;
currentHoistableRoot = getHoistableRoot(root.containerInfo);

View File

@ -31,3 +31,4 @@ export const hydrateHoistable = shim;
export const mountHoistable = shim;
export const unmountHoistable = shim;
export const createHoistableInstance = shim;
export const prepareToCommitHoistables = shim;

View File

@ -436,7 +436,7 @@ function claimHydratableSingleton(fiber: Fiber): void {
}
}
function advanceToFirstAttempableInstance(fiber: Fiber) {
function advanceToFirstAttemptableInstance(fiber: Fiber) {
// fiber is HostComponent Fiber
while (
nextHydratableInstance &&
@ -452,7 +452,7 @@ function advanceToFirstAttempableInstance(fiber: Fiber) {
}
}
function advanceToFirstAttempableTextInstance() {
function advanceToFirstAttemptableTextInstance() {
while (
nextHydratableInstance &&
shouldSkipHydratableForTextInstance(nextHydratableInstance)
@ -463,7 +463,7 @@ function advanceToFirstAttempableTextInstance() {
}
}
function advanceToFirstAttempableSuspenseInstance() {
function advanceToFirstAttemptableSuspenseInstance() {
while (
nextHydratableInstance &&
shouldSkipHydratableForSuspenseInstance(nextHydratableInstance)
@ -490,7 +490,7 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
const initialInstance = nextHydratableInstance;
if (rootOrSingletonContext) {
// We may need to skip past certain nodes in these contexts
advanceToFirstAttempableInstance(fiber);
advanceToFirstAttemptableInstance(fiber);
}
const nextInstance = nextHydratableInstance;
if (!nextInstance) {
@ -518,7 +518,7 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
const prevHydrationParentFiber: Fiber = (hydrationParentFiber: any);
if (rootOrSingletonContext) {
// We may need to skip past certain nodes in these contexts
advanceToFirstAttempableInstance(fiber);
advanceToFirstAttemptableInstance(fiber);
}
if (
!nextHydratableInstance ||
@ -551,7 +551,7 @@ function tryToClaimNextHydratableTextInstance(fiber: Fiber): void {
// We may need to skip past certain nodes in these contexts.
// We don't skip if the text is not hydratable because we know no hydratables
// exist which could match this Fiber
advanceToFirstAttempableTextInstance();
advanceToFirstAttemptableTextInstance();
}
const nextInstance = nextHydratableInstance;
if (!nextInstance || !isHydratable) {
@ -582,7 +582,7 @@ function tryToClaimNextHydratableTextInstance(fiber: Fiber): void {
if (rootOrSingletonContext && isHydratable) {
// We may need to skip past certain nodes in these contexts
advanceToFirstAttempableTextInstance();
advanceToFirstAttemptableTextInstance();
}
if (
@ -611,7 +611,7 @@ function tryToClaimNextHydratableSuspenseInstance(fiber: Fiber): void {
const initialInstance = nextHydratableInstance;
if (rootOrSingletonContext) {
// We may need to skip past certain nodes in these contexts
advanceToFirstAttempableSuspenseInstance();
advanceToFirstAttemptableSuspenseInstance();
}
const nextInstance = nextHydratableInstance;
if (!nextInstance) {
@ -640,7 +640,7 @@ function tryToClaimNextHydratableSuspenseInstance(fiber: Fiber): void {
if (rootOrSingletonContext) {
// We may need to skip past certain nodes in these contexts
advanceToFirstAttempableSuspenseInstance();
advanceToFirstAttemptableSuspenseInstance();
}
if (

View File

@ -202,6 +202,7 @@ export const errorHydratingContainer = $$$hostConfig.errorHydratingContainer;
// Resources
// (optional)
// -------------------
export type HoistableRoot = mixed;
export const supportsResources = $$$hostConfig.supportsResources;
export const isHostHoistableType = $$$hostConfig.isHostHoistableType;
export const getHoistableRoot = $$$hostConfig.getHoistableRoot;
@ -212,7 +213,8 @@ export const hydrateHoistable = $$$hostConfig.hydrateHoistable;
export const mountHoistable = $$$hostConfig.mountHoistable;
export const unmountHoistable = $$$hostConfig.unmountHoistable;
export const createHoistableInstance = $$$hostConfig.createHoistableInstance;
export type HoistableRoot = mixed;
export const prepareToCommitHoistables =
$$$hostConfig.prepareToCommitHoistables;
// -------------------
// Singletons

View File

@ -452,5 +452,6 @@
"464": "ReactDOMServer.renderToStaticNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead.",
"465": "enableFizzExternalRuntime without enableFloat is not supported. This should never appear in production, since it means you are using a misconfigured React bundle.",
"466": "Trying to call a function from \"use server\" but the callServer option was not implemented in your router runtime.",
"467": "Update hook called on initial render. This is likely a bug in React. Please file an issue."
"467": "Update hook called on initial render. This is likely a bug in React. Please file an issue.",
"468": "getNodesForType encountered a type it did not expect: \"%s\". This is a bug in React."
}