@samitouri / QOS-React-2 / commits / 978fae4b4f

[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.

Josh Story committed Mar 6, 2023 at 19:52 UTC 978fae4b4f6d5aa28887b530b5c9bf28b1e7b74b
10 files changed +427 -296
packages/react-dom-bindings/src/client/ReactDOMComponent.js
+75 -65
@@ -55,12 +55,7 @@ import {
55 setValueForStyles,
56 validateShorthandPropertyCollisionInDev,
57 } from './CSSPropertyOperations';
58 -import {
59 - HTML_NAMESPACE,
60 - MATH_NAMESPACE,
61 - SVG_NAMESPACE,
62 - getIntrinsicNamespace,
63 -} from '../shared/DOMNamespaces';
58 +import {HTML_NAMESPACE, getIntrinsicNamespace} from '../shared/DOMNamespaces';
59 import {
60 getPropertyInfo,
61 shouldIgnoreAttribute,
@@ -380,15 +375,83 @@ function updateDOMProperties(
375 }
376 }
377
378 +// creates a script element that won't execute
379 +export function createPotentiallyInlineScriptElement(
380 + ownerDocument: Document,
381 +): Element {
382 + // Create the script via .innerHTML so its "parser-inserted" flag is
383 + // set to true and it does not execute
384 + const div = ownerDocument.createElement('div');
385 + if (__DEV__) {
386 + if (enableTrustedTypesIntegration && !didWarnScriptTags) {
387 + console.error(
388 + 'Encountered a script tag while rendering React component. ' +
389 + 'Scripts inside React components are never executed when rendering ' +
390 + 'on the client. Consider using template tag instead ' +
391 + '(https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template).',
392 + );
393 + didWarnScriptTags = true;
394 + }
395 + }
396 + div.innerHTML = '<script><' + '/script>'; // eslint-disable-line
397 + // This is guaranteed to yield a script element.
398 + const firstChild = ((div.firstChild: any): HTMLScriptElement);
399 + const element = div.removeChild(firstChild);
400 + return element;
401 +}
402 +
403 +export function createSelectElement(
404 + props: Object,
405 + ownerDocument: Document,
406 +): Element {
407 + let element;
408 + if (typeof props.is === 'string') {
409 + element = ownerDocument.createElement('select', {is: props.is});
410 + } else {
411 + // Separate else branch instead of using `props.is || undefined` above because of a Firefox bug.
412 + // See discussion in https://github.com/facebook/react/pull/6896
413 + // and discussion in https://bugzilla.mozilla.org/show_bug.cgi?id=1276240
414 + element = ownerDocument.createElement('select');
415 + }
416 + if (props.multiple) {
417 + element.multiple = true;
418 + } else if (props.size) {
419 + // Setting a size greater than 1 causes a select to behave like `multiple=true`, where
420 + // it is possible that no option is selected.
421 + //
422 + // This is only necessary when a select in "single selection mode".
423 + element.size = props.size;
424 + }
425 + return element;
426 +}
427 +
428 // Creates elements in the HTML namesapce
429 export function createHTMLElement(
430 type: string,
431 props: Object,
432 ownerDocument: Document,
433 ): Element {
434 + if (__DEV__) {
435 + switch (type) {
436 + case 'script':
437 + case 'select':
438 + console.error(
439 + '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.',
440 + type,
441 + );
442 + break;
443 + case 'svg':
444 + case 'math':
445 + console.error(
446 + '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.',
447 + type,
448 + );
449 + }
450 + }
451 +
452 let isCustomComponentTag;
453
391 - let domElement: Element;
454 + let element: Element;
455 if (__DEV__) {
456 isCustomComponentTag = isCustomComponent(type, props);
457 // Should this check be gated by parent namespace? Not sure we want to
@@ -403,59 +466,20 @@ export function createHTMLElement(
466 }
467 }
468
406 - if (type === 'script') {
407 - // Create the script via .innerHTML so its "parser-inserted" flag is
408 - // set to true and it does not execute
409 - const div = ownerDocument.createElement('div');
410 - if (__DEV__) {
411 - if (enableTrustedTypesIntegration && !didWarnScriptTags) {
412 - console.error(
413 - 'Encountered a script tag while rendering React component. ' +
414 - 'Scripts inside React components are never executed when rendering ' +
415 - 'on the client. Consider using template tag instead ' +
416 - '(https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template).',
417 - );
418 - didWarnScriptTags = true;
419 - }
420 - }
421 - div.innerHTML = '<script><' + '/script>'; // eslint-disable-line
422 - // This is guaranteed to yield a script element.
423 - const firstChild = ((div.firstChild: any): HTMLScriptElement);
424 - domElement = div.removeChild(firstChild);
425 - } else if (typeof props.is === 'string') {
426 - domElement = ownerDocument.createElement(type, {is: props.is});
469 + if (typeof props.is === 'string') {
470 + element = ownerDocument.createElement(type, {is: props.is});
471 } else {
472 // Separate else branch instead of using `props.is || undefined` above because of a Firefox bug.
473 // See discussion in https://github.com/facebook/react/pull/6896
474 // and discussion in https://bugzilla.mozilla.org/show_bug.cgi?id=1276240
431 - domElement = ownerDocument.createElement(type);
432 - // Normally attributes are assigned in `setInitialDOMProperties`, however the `multiple` and `size`
433 - // attributes on `select`s needs to be added before `option`s are inserted.
434 - // This prevents:
435 - // - a bug where the `select` does not scroll to the correct option because singular
436 - // `select` elements automatically pick the first item #13222
437 - // - a bug where the `select` set the first item as selected despite the `size` attribute #14239
438 - // See https://github.com/facebook/react/issues/13222
439 - // and https://github.com/facebook/react/issues/14239
440 - if (type === 'select') {
441 - const node = ((domElement: any): HTMLSelectElement);
442 - if (props.multiple) {
443 - node.multiple = true;
444 - } else if (props.size) {
445 - // Setting a size greater than 1 causes a select to behave like `multiple=true`, where
446 - // it is possible that no option is selected.
447 - //
448 - // This is only necessary when a select in "single selection mode".
449 - node.size = props.size;
450 - }
451 - }
475 + element = ownerDocument.createElement(type);
476 }
477
478 if (__DEV__) {
479 if (
480 !isCustomComponentTag &&
481 // $FlowFixMe[method-unbinding]
458 - Object.prototype.toString.call(domElement) ===
482 + Object.prototype.toString.call(element) ===
483 '[object HTMLUnknownElement]' &&
484 !hasOwnProperty.call(warnedUnknownTags, type)
485 ) {
@@ -469,21 +493,7 @@ export function createHTMLElement(
493 }
494 }
495
472 - return domElement;
473 -}
474 -
475 -export function createSVGElement(
476 - type: string,
477 - ownerDocument: Document,
478 -): Element {
479 - return ownerDocument.createElementNS(SVG_NAMESPACE, type);
480 -}
481 -
482 -export function createMathElement(
483 - type: string,
484 - ownerDocument: Document,
485 -): Element {
486 - return ownerDocument.createElementNS(MATH_NAMESPACE, type);
496 + return element;
497 }
498
499 export function createTextNode(
packages/react-dom-bindings/src/client/ReactDOMComponentTree.js
+11 -5
@@ -47,7 +47,7 @@ const internalEventHandlersKey = '__reactEvents$' + randomKey;
47 const internalEventHandlerListenersKey = '__reactListeners$' + randomKey;
48 const internalEventHandlesSetKey = '__reactHandles$' + randomKey;
49 const internalRootNodeResourcesKey = '__reactResources$' + randomKey;
50 -const internalResourceMarker = '__reactMarker$' + randomKey;
50 +const internalHoistableMarker = '__reactMarker$' + randomKey;
51
52 export function detachDeletedInstance(node: Instance): void {
53 // 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 {
288 return resources;
289 }
290
291 -export function isMarkedResource(node: Node): boolean {
292 - return !!(node: any)[internalResourceMarker];
291 +export function isMarkedHoistable(node: Node): boolean {
292 + return !!(node: any)[internalHoistableMarker];
293 }
294
295 -export function markNodeAsResource(node: Node) {
296 - (node: any)[internalResourceMarker] = true;
295 +export function markNodeAsHoistable(node: Node) {
296 + (node: any)[internalHoistableMarker] = true;
297 +}
298 +
299 +export function isOwnedInstance(node: Node): boolean {
300 + return !!(
301 + (node: any)[internalHoistableMarker] || (node: any)[internalInstanceKey]
302 + );
303 }
packages/react-dom-bindings/src/client/ReactDOMFloatClient.js
+182 -191
@@ -11,15 +11,12 @@ import type {Instance, Container} from './ReactDOMHostConfig';
11
12 import {getCurrentRootHostContainer} from 'react-reconciler/src/ReactFiberHostContext';
13
14 +import hasOwnProperty from 'shared/hasOwnProperty';
15 import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals.js';
16 const {Dispatcher} = ReactDOMSharedInternals;
16 -import {
17 - checkAttributeStringCoercion,
18 - checkPropStringCoercion,
19 -} from 'shared/CheckStringCoercion';
17 +import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
18
19 import {DOCUMENT_NODE} from '../shared/HTMLNodeType';
22 -import {isAttributeNameSafe} from '../shared/DOMProperty';
20 import {SVG_NAMESPACE} from '../shared/DOMNamespaces';
21 import {
22 validatePreloadArguments,
@@ -28,19 +25,19 @@ import {
25 getValueDescriptorExpectingEnumForWarning,
26 } from '../shared/ReactDOMResourceValidation';
27
31 -import {precacheFiberNode} from './ReactDOMComponentTree';
32 -import {createHTMLElement, setInitialProperties} from './ReactDOMComponent';
28 +import {setInitialProperties} from './ReactDOMComponent';
29 import {
30 + precacheFiberNode,
31 getResourcesFromRoot,
35 - isMarkedResource,
36 - markNodeAsResource,
32 + isOwnedInstance,
33 + markNodeAsHoistable,
34 } from './ReactDOMComponentTree';
35
36 // The resource types we support. currently they match the form for the as argument.
37 // In the future this may need to change, especially when modules / scripts are supported
38 type ResourceType = 'style' | 'font' | 'script';
39
43 -type HoistableTagType = 'link' | 'meta' | 'title' | 'script' | 'style';
40 +type HoistableTagType = 'link' | 'meta' | 'title';
41 type TResource<T: 'stylesheet' | 'style' | 'script' | 'void'> = {
42 type: T,
43 instance: null | Instance,
@@ -104,6 +101,10 @@ export function cleanupAfterRenderResources() {
101 previousDispatcher = null;
102 }
103
104 +export function prepareToCommitHoistables() {
105 + tagCaches = null;
106 +}
107 +
108 // We want this to be the default dispatcher on ReactDOMSharedInternals but we don't want to mutate
109 // internals in Module scope. Instead we export it and Internals will import it. There is already a cycle
110 // from Internals -> ReactDOM -> FloatClient -> Internals so this doesn't introduce a new one.
@@ -176,13 +177,9 @@ function preconnectAs(
177
178 const preconnectProps = {rel, crossOrigin, href};
179 if (null === ownerDocument.querySelector(key)) {
179 - const preloadInstance = createHTMLElement(
180 - 'link',
181 - preconnectProps,
182 - ownerDocument,
183 - );
180 + const preloadInstance = ownerDocument.createElement('link');
181 setInitialProperties(preloadInstance, 'link', preconnectProps);
185 - markNodeAsResource(preloadInstance);
182 + markNodeAsHoistable(preloadInstance);
183 (ownerDocument.head: any).appendChild(preloadInstance);
184 }
185 }
@@ -202,7 +199,7 @@ function prefetchDNS(href: string, options?: mixed) {
199 } else if (options != null) {
200 if (
201 typeof options === 'object' &&
205 - options.hasOwnProperty('crossOrigin')
202 + hasOwnProperty.call(options, 'crossOrigin')
203 ) {
204 console.error(
205 '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) {
287 preloadPropsMap.set(key, preloadProps);
288
289 if (null === ownerDocument.querySelector(preloadKey)) {
293 - const preloadInstance = createHTMLElement(
294 - 'link',
295 - preloadProps,
296 - ownerDocument,
297 - );
290 + const preloadInstance = ownerDocument.createElement('link');
291 setInitialProperties(preloadInstance, 'link', preloadProps);
299 - markNodeAsResource(preloadInstance);
292 + markNodeAsHoistable(preloadInstance);
293 (ownerDocument.head: any).appendChild(preloadInstance);
294 }
295 }
@@ -371,13 +364,9 @@ function preinit(href: string, options: PreinitOptions) {
364 preloadPropsMap.set(key, preloadProps);
365
366 if (null === preloadDocument.querySelector(preloadKey)) {
374 - const preloadInstance = createHTMLElement(
375 - 'link',
376 - preloadProps,
377 - preloadDocument,
378 - );
367 + const preloadInstance = preloadDocument.createElement('link');
368 setInitialProperties(preloadInstance, 'link', preloadProps);
380 - markNodeAsResource(preloadInstance);
369 + markNodeAsHoistable(preloadInstance);
370 (preloadDocument.head: any).appendChild(preloadInstance);
371 }
372 }
@@ -417,8 +406,8 @@ function preinit(href: string, options: PreinitOptions) {
406 adoptPreloadPropsForStylesheet(stylesheetProps, preloadProps);
407 }
408 const ownerDocument = getDocumentFromRoot(resourceRoot);
420 - instance = createHTMLElement('link', stylesheetProps, ownerDocument);
421 - markNodeAsResource(instance);
409 + instance = ownerDocument.createElement('link');
410 + markNodeAsHoistable(instance);
411 setInitialProperties(instance, 'link', stylesheetProps);
412 insertStylesheet(instance, precedence, resourceRoot);
413 }
@@ -459,10 +448,10 @@ function preinit(href: string, options: PreinitOptions) {
448 adoptPreloadPropsForScript(scriptProps, preloadProps);
449 }
450 const ownerDocument = getDocumentFromRoot(resourceRoot);
462 - instance = createHTMLElement('script', scriptProps, ownerDocument);
463 - markNodeAsResource(instance);
451 + instance = ownerDocument.createElement('script');
452 + markNodeAsHoistable(instance);
453 setInitialProperties(instance, 'link', scriptProps);
465 - (getDocumentFromRoot(resourceRoot).head: any).appendChild(instance);
454 + (ownerDocument.head: any).appendChild(instance);
455 }
456
457 // Construct a Resource and cache it
@@ -694,13 +683,9 @@ function preloadStylesheet(
683 null ===
684 ownerDocument.querySelector(getPreloadStylesheetSelectorFromKey(key))
685 ) {
697 - const preloadInstance = createHTMLElement(
698 - 'link',
699 - preloadProps,
700 - ownerDocument,
701 - );
686 + const preloadInstance = ownerDocument.createElement('link');
687 setInitialProperties(preloadInstance, 'link', preloadProps);
703 - markNodeAsResource(preloadInstance);
688 + markNodeAsHoistable(preloadInstance);
689 (ownerDocument.head: any).appendChild(preloadInstance);
690 }
691 }
@@ -752,15 +737,15 @@ export function acquireResource(
737 );
738 if (instance) {
739 resource.instance = instance;
755 - markNodeAsResource(instance);
740 + markNodeAsHoistable(instance);
741 return instance;
742 }
743
744 const styleProps = styleTagPropsFromRawProps(props);
745 const ownerDocument = getDocumentFromRoot(hoistableRoot);
761 - instance = createHTMLElement('style', styleProps, ownerDocument);
746 + instance = ownerDocument.createElement('style');
747
763 - markNodeAsResource(instance);
748 + markNodeAsHoistable(instance);
749 setInitialProperties(instance, 'style', styleProps);
750 insertStylesheet(instance, qualifiedProps.precedence, hoistableRoot);
751 resource.instance = instance;
@@ -780,7 +765,7 @@ export function acquireResource(
765 );
766 if (instance) {
767 resource.instance = instance;
783 - markNodeAsResource(instance);
768 + markNodeAsHoistable(instance);
769 return instance;
770 }
771
@@ -792,8 +777,8 @@ export function acquireResource(
777
778 // Construct and insert a new instance
779 const ownerDocument = getDocumentFromRoot(hoistableRoot);
795 - instance = createHTMLElement('link', stylesheetProps, ownerDocument);
796 - markNodeAsResource(instance);
780 + instance = ownerDocument.createElement('link');
781 + markNodeAsHoistable(instance);
782 const linkInstance: HTMLLinkElement = (instance: any);
783 (linkInstance: any)._p = new Promise((resolve, reject) => {
784 linkInstance.onload = resolve;
@@ -821,7 +806,7 @@ export function acquireResource(
806 );
807 if (instance) {
808 resource.instance = instance;
824 - markNodeAsResource(instance);
809 + markNodeAsHoistable(instance);
810 return instance;
811 }
812
@@ -834,10 +819,10 @@ export function acquireResource(
819
820 // Construct and insert a new instance
821 const ownerDocument = getDocumentFromRoot(hoistableRoot);
837 - instance = createHTMLElement('script', scriptProps, ownerDocument);
838 - markNodeAsResource(instance);
822 + instance = ownerDocument.createElement('script');
823 + markNodeAsHoistable(instance);
824 setInitialProperties(instance, 'link', scriptProps);
840 - (getDocumentFromRoot(hoistableRoot).head: any).appendChild(instance);
825 + (ownerDocument.head: any).appendChild(instance);
826 resource.instance = instance;
827
828 return instance;
@@ -919,6 +904,10 @@ function adoptPreloadPropsForScript(
904 // Hoistable Element Reconciliation
905 // --------------------------------------
906
907 +type KeyedTagCache = Map<string, Array<Element>>;
908 +type DocumentTagCaches = Map<Document, KeyedTagCache>;
909 +let tagCaches: null | DocumentTagCaches = null;
910 +
911 export function hydrateHoistable(
912 hoistableRoot: HoistableRoot,
913 type: HoistableTagType,
@@ -926,163 +915,165 @@ export function hydrateHoistable(
915 internalInstanceHandle: Object,
916 ): Instance {
917 const ownerDocument = getDocumentFromRoot(hoistableRoot);
929 - const nodes = ownerDocument.getElementsByTagName(type);
918
931 - const children = props.children;
932 - let child, childString;
933 - if (Array.isArray(children)) {
934 - child = children.length === 1 ? children[0] : null;
935 - } else {
936 - child = children;
937 - }
938 - if (
939 - typeof child !== 'function' &&
940 - typeof child !== 'symbol' &&
941 - child !== null &&
942 - child !== undefined
943 - ) {
944 - if (__DEV__) {
945 - checkPropStringCoercion(child, 'children');
946 - }
947 - childString = '' + (child: any);
948 - } else {
949 - childString = '';
950 - }
951 - nodeLoop: for (let i = 0; i < nodes.length; i++) {
952 - const node = nodes[i];
953 - if (
954 - isMarkedResource(node) ||
955 - node.namespaceURI === SVG_NAMESPACE ||
956 - node.textContent !== childString
957 - ) {
958 - continue;
959 - }
960 - let checkedAttributes = 0;
961 - for (const propName in props) {
962 - const propValue = props[propName];
963 - if (!props.hasOwnProperty(propName)) {
964 - continue;
919 + let instance: ?Instance = null;
920 + getInstance: switch (type) {
921 + case 'title': {
922 + instance = ownerDocument.getElementsByTagName('title')[0];
923 + if (
924 + !instance ||
925 + isOwnedInstance(instance) ||
926 + instance.namespaceURI === SVG_NAMESPACE ||
927 + instance.hasAttribute('itemprop')
928 + ) {
929 + instance = ownerDocument.createElement(type);
930 + (ownerDocument.head: any).insertBefore(
931 + instance,
932 + ownerDocument.querySelector('head > title'),
933 + );
934 }
966 - switch (propName) {
967 - // Reserved props will never have an attribute partner
968 - case 'children':
969 - case 'defaultValue':
970 - case 'dangerouslySetInnerHTML':
971 - case 'defaultChecked':
972 - case 'innerHTML':
973 - case 'suppressContentEditableWarning':
974 - case 'suppressHydrationWarning':
975 - case 'style':
976 - // we advance to the next prop
977 - continue;
978 -
979 - // Name remapped props used by hoistable tag types
980 - case 'className': {
981 - if (__DEV__) {
982 - checkAttributeStringCoercion(propValue, propName);
935 + setInitialProperties(instance, type, props);
936 + precacheFiberNode(internalInstanceHandle, instance);
937 + markNodeAsHoistable(instance);
938 + return instance;
939 + }
940 + case 'link': {
941 + const cache = getHydratableHoistableCache('link', 'href', ownerDocument);
942 + const key = type + (props.href || '');
943 + const maybeNodes = cache.get(key);
944 + if (maybeNodes) {
945 + const nodes = maybeNodes;
946 + for (let i = 0; i < nodes.length; i++) {
947 + const node = nodes[i];
948 + if (
949 + node.getAttribute('href') !==
950 + (props.href == null ? null : props.href) ||
951 + node.getAttribute('rel') !==
952 + (props.rel == null ? null : props.rel) ||
953 + node.getAttribute('title') !==
954 + (props.title == null ? null : props.title) ||
955 + node.getAttribute('crossorigin') !==
956 + (props.crossOrigin == null ? null : props.crossOrigin)
957 + ) {
958 + // mismatch, try the next node;
959 + continue;
960 }
984 - if (node.getAttribute('class') !== '' + propValue) continue nodeLoop;
985 - break;
961 + instance = node;
962 + nodes.splice(i, 1);
963 + break getInstance;
964 }
987 - case 'httpEquiv': {
965 + }
966 + instance = ownerDocument.createElement(type);
967 + setInitialProperties(instance, type, props);
968 + (ownerDocument.head: any).appendChild(instance);
969 + break;
970 + }
971 + case 'meta': {
972 + const cache = getHydratableHoistableCache(
973 + 'meta',
974 + 'content',
975 + ownerDocument,
976 + );
977 + const key = type + (props.content || '');
978 + const maybeNodes = cache.get(key);
979 + if (maybeNodes) {
980 + const nodes = maybeNodes;
981 + for (let i = 0; i < nodes.length; i++) {
982 + const node = nodes[i];
983 +
984 + // We coerce content to string because it is the most likely one to
985 + // use a `toString` capable value. For the rest we just do identity match
986 + // passing non-strings here is not really valid anyway.
987 if (__DEV__) {
989 - checkAttributeStringCoercion(propValue, propName);
988 + checkAttributeStringCoercion(props.content, 'content');
989 }
991 - if (node.getAttribute('http-equiv') !== '' + propValue)
992 - continue nodeLoop;
993 - break;
994 - }
995 -
996 - // Booleanish props used by hoistable tag types
997 - case 'contentEditable':
998 - case 'draggable':
999 - case 'spellCheck': {
1000 - if (__DEV__) {
1001 - checkAttributeStringCoercion(propValue, propName);
990 + if (
991 + node.getAttribute('content') !==
992 + (props.content == null ? null : '' + props.content) ||
993 + node.getAttribute('name') !==
994 + (props.name == null ? null : props.name) ||
995 + node.getAttribute('property') !==
996 + (props.property == null ? null : props.property) ||
997 + node.getAttribute('http-equiv') !==
998 + (props.httpEquiv == null ? null : props.httpEquiv) ||
999 + node.getAttribute('charset') !==
1000 + (props.charSet == null ? null : props.charSet)
1001 + ) {
1002 + // mismatch, try the next node;
1003 + continue;
1004 }
1003 - if (node.getAttribute(propName) !== '' + propValue) continue nodeLoop;
1004 - break;
1005 + instance = node;
1006 + nodes.splice(i, 1);
1007 + break getInstance;
1008 }
1006 -
1007 - // Boolean props used by hoistable tag types
1008 - case 'async':
1009 - case 'defer':
1010 - case 'disabled':
1011 - case 'hidden':
1012 - case 'noModule':
1013 - case 'scoped':
1014 - case 'itemScope':
1015 - if (propValue !== node.hasAttribute(propName)) continue nodeLoop;
1016 - break;
1017 -
1018 - // The following properties are left out because they do not apply to
1019 - // the current set of hoistable types. They may have special handling
1020 - // requirements if they end up applying to a hoistable type in the future
1021 - // case 'acceptCharset':
1022 - // case 'value':
1023 - // case 'allowFullScreen':
1024 - // case 'autoFocus':
1025 - // case 'autoPlay':
1026 - // case 'controls':
1027 - // case 'default':
1028 - // case 'disablePictureInPicture':
1029 - // case 'disableRemotePlayback':
1030 - // case 'formNoValidate':
1031 - // case 'loop':
1032 - // case 'noValidate':
1033 - // case 'open':
1034 - // case 'playsInline':
1035 - // case 'readOnly':
1036 - // case 'required':
1037 - // case 'reversed':
1038 - // case 'seamless':
1039 - // case 'multiple':
1040 - // case 'selected':
1041 - // case 'capture':
1042 - // case 'download':
1043 - // case 'cols':
1044 - // case 'rows':
1045 - // case 'size':
1046 - // case 'span':
1047 - // case 'rowSpan':
1048 - // case 'start':
1049 -
1050 - default:
1051 - if (isAttributeNameSafe(propName)) {
1052 - const attributeName = propName;
1053 - if (propValue == null && node.hasAttribute(attributeName))
1054 - continue nodeLoop;
1055 - if (__DEV__) {
1056 - checkAttributeStringCoercion(propValue, attributeName);
1057 - }
1058 - if (node.getAttribute(attributeName) !== '' + (propValue: any))
1059 - continue nodeLoop;
1060 - }
1009 }
1062 - checkedAttributes++;
1010 + instance = ownerDocument.createElement(type);
1011 + setInitialProperties(instance, type, props);
1012 + (ownerDocument.head: any).appendChild(instance);
1013 + break;
1014 }
1015 + default:
1016 + throw new Error(
1017 + `getNodesForType encountered a type it did not expect: "${type}". This is a bug in React.`,
1018 + );
1019 + }
1020
1065 - if (node.attributes.length !== checkedAttributes) {
1066 - // We didn't match ever attribute so we abandon this node
1067 - continue nodeLoop;
1021 + // This node is a match
1022 + precacheFiberNode(internalInstanceHandle, instance);
1023 + markNodeAsHoistable(instance);
1024 + return instance;
1025 +}
1026 +
1027 +function getHydratableHoistableCache(
1028 + type: HoistableTagType,
1029 + keyAttribute: string,
1030 + ownerDocument: Document,
1031 +): KeyedTagCache {
1032 + let cache: KeyedTagCache;
1033 + let caches: DocumentTagCaches;
1034 + if (tagCaches === null) {
1035 + cache = new Map();
1036 + caches = tagCaches = new Map();
1037 + caches.set(ownerDocument, cache);
1038 + } else {
1039 + caches = tagCaches;
1040 + const maybeCache = caches.get(ownerDocument);
1041 + if (!maybeCache) {
1042 + cache = new Map();
1043 + caches.set(ownerDocument, cache);
1044 + } else {
1045 + cache = maybeCache;
1046 }
1047 + }
1048
1070 - // We found a matching instance. We can return early after marking it
1071 - markNodeAsResource(node);
1072 - return node;
1049 + if (cache.has(type)) {
1050 + // We use type as a special key that signals that this cache has been seeded for this type
1051 + return cache;
1052 }
1053
1075 - // There is no matching instance to hydrate, we create it now
1076 - const instance = createHTMLElement(type, props, ownerDocument);
1077 - setInitialProperties(instance, type, props);
1078 - precacheFiberNode(internalInstanceHandle, instance);
1079 - markNodeAsResource(instance);
1054 + // Mark this cache as seeded for this type
1055 + cache.set(type, (null: any));
1056
1081 - (ownerDocument.head: any).insertBefore(
1082 - instance,
1083 - type === 'title' ? ownerDocument.querySelector('head > title') : null,
1084 - );
1085 - return instance;
1057 + const nodes = ownerDocument.getElementsByTagName(type);
1058 + for (let i = 0; i < nodes.length; i++) {
1059 + const node = nodes[i];
1060 + if (
1061 + !isOwnedInstance(node) &&
1062 + (type !== 'link' || node.getAttribute('rel') !== 'stylesheet') &&
1063 + node.namespaceURI !== SVG_NAMESPACE
1064 + ) {
1065 + const nodeKey = node.getAttribute(keyAttribute) || '';
1066 + const key = type + nodeKey;
1067 + const existing = cache.get(key);
1068 + if (existing) {
1069 + existing.push(node);
1070 + } else {
1071 + cache.set(key, [node]);
1072 + }
1073 + }
1074 + }
1075 +
1076 + return cache;
1077 }
1078
1079 export function mountHoistable(
packages/react-dom-bindings/src/client/ReactDOMHostConfig.js
+23 -20
@@ -26,15 +26,15 @@ import {
26 getInstanceFromNode as getInstanceFromNodeDOMTree,
27 isContainerMarkedAsRoot,
28 detachDeletedInstance,
29 - isMarkedResource,
30 - markNodeAsResource,
29 + isMarkedHoistable,
30 + markNodeAsHoistable,
31 } from './ReactDOMComponentTree';
32 export {detachDeletedInstance};
33 import {hasRole} from './DOMAccessibilityRoles';
34 import {
35 createHTMLElement,
36 - createSVGElement,
37 - createMathElement,
36 + createPotentiallyInlineScriptElement,
37 + createSelectElement,
38 createTextNode,
39 setInitialProperties,
40 diffProperties,
@@ -61,7 +61,6 @@ import {
61 getChildNamespace,
62 SVG_NAMESPACE,
63 MATH_NAMESPACE,
64 - HTML_NAMESPACE,
64 } from '../shared/DOMNamespaces';
65 import {
66 ELEMENT_NODE,
@@ -284,7 +283,7 @@ export function createHoistableInstance(
283 precacheFiberNode(internalInstanceHandle, domElement);
284 updateFiberProps(domElement, props);
285 setInitialProperties(domElement, type, props);
287 - markNodeAsResource(domElement);
286 + markNodeAsHoistable(domElement);
287 return domElement;
288 }
289
@@ -322,25 +321,28 @@ export function createInstance(
321 );
322
323 let domElement: Instance;
325 - create: switch (namespace) {
324 + switch (namespace) {
325 case SVG_NAMESPACE:
327 - domElement = createSVGElement(type, ownerDocument);
328 - break;
326 case MATH_NAMESPACE:
330 - domElement = createMathElement(type, ownerDocument);
327 + domElement = ownerDocument.createElementNS(namespace, type);
328 break;
332 - case HTML_NAMESPACE:
329 + default:
330 switch (type) {
331 case 'svg':
335 - domElement = createSVGElement(type, ownerDocument);
336 - break create;
332 + domElement = ownerDocument.createElementNS(SVG_NAMESPACE, type);
333 + break;
334 case 'math':
338 - domElement = createMathElement(type, ownerDocument);
339 - break create;
335 + domElement = ownerDocument.createElementNS(MATH_NAMESPACE, type);
336 + break;
337 + case 'script':
338 + domElement = createPotentiallyInlineScriptElement(ownerDocument);
339 + break;
340 + case 'select':
341 + domElement = createSelectElement(props, ownerDocument);
342 + break;
343 + default:
344 + domElement = createHTMLElement(type, props, ownerDocument);
345 }
341 - // eslint-disable-next-line no-fallthrough
342 - default:
343 - domElement = createHTMLElement(type, props, ownerDocument);
346 }
347 precacheFiberNode(internalInstanceHandle, domElement);
348 updateFiberProps(domElement, props);
@@ -876,7 +878,7 @@ export function shouldSkipHydratableForInstance(
878 return false;
879 } else if (
880 instance.nodeName.toLowerCase() !== type.toLowerCase() ||
879 - isMarkedResource(instance)
881 + isMarkedHoistable(instance)
882 ) {
883 // We are either about to
884 return true;
@@ -1807,6 +1809,7 @@ export {
1809 hydrateHoistable,
1810 mountHoistable,
1811 unmountHoistable,
1812 + prepareToCommitHoistables,
1813 } from './ReactDOMFloatClient';
1814
1815 // -------------------
@@ -1936,7 +1939,7 @@ export function clearSingleton(instance: Instance): void {
1939 const nextNode = node.nextSibling;
1940 const nodeName = node.nodeName;
1941 if (
1939 - isMarkedResource(node) ||
1942 + isMarkedHoistable(node) ||
1943 nodeName === 'HEAD' ||
1944 nodeName === 'BODY' ||
1945 nodeName === 'STYLE' ||
packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+118 -4
@@ -2388,7 +2388,7 @@ body {
2388 );
2389
2390 ReactDOMClient.hydrateRoot(document, <App />);
2391 - expect(Scheduler).toFlushWithoutYielding();
2391 + await waitForAll([]);
2392
2393 expect(getMeaningfulChildren(document)).toEqual(
2394 <html>
@@ -2441,8 +2441,8 @@ body {
2441 <script itemProp="foo" />
2442 </html>,
2443 );
2444 - expect(() => {
2445 - expect(Scheduler).toFlushWithoutYielding();
2444 + await expect(async () => {
2445 + await waitForAll([]);
2446 }).toErrorDev([
2447 '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.',
2448 '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 {
2567 // script insertion that happens because we do not SSR async scripts with load handlers.
2568 // All the extra inject nodes are preset
2569 const root = ReactDOMClient.hydrateRoot(document, <App />);
2570 - expect(Scheduler).toFlushWithoutYielding();
2570 + await waitForAll([]);
2571 expect(getMeaningfulChildren(document)).toEqual(
2572 <html itemscope="">
2573 <head>
@@ -5405,6 +5405,120 @@ background-color: green;
5405 );
5406 });
5407
5408 + it('can hydrate hoistable tags inside late suspense boundaries', async () => {
5409 + function App() {
5410 + return (
5411 + <html>
5412 + <body>
5413 + <link rel="rel1" href="linkhref" />
5414 + <link rel="rel2" href="linkhref" />
5415 + <meta name="name1" content="metacontent" />
5416 + <meta name="name2" content="metacontent" />
5417 + <Suspense fallback="loading...">
5418 + <link rel="rel3" href="linkhref" />
5419 + <link rel="rel4" href="linkhref" />
5420 + <meta name="name3" content="metacontent" />
5421 + <meta name="name4" content="metacontent" />
5422 + <BlockedOn value="release">
5423 + <link rel="rel5" href="linkhref" />
5424 + <link rel="rel6" href="linkhref" />
5425 + <meta name="name5" content="metacontent" />
5426 + <meta name="name6" content="metacontent" />
5427 + <div>hello world</div>
5428 + </BlockedOn>
5429 + </Suspense>
5430 + </body>
5431 + </html>
5432 + );
5433 + }
5434 + await actIntoEmptyDocument(() => {
5435 + renderToPipeableStream(<App />).pipe(writable);
5436 + });
5437 +
5438 + expect(getMeaningfulChildren(document)).toEqual(
5439 + <html>
5440 + <head>
5441 + <link rel="rel1" href="linkhref" />
5442 + <link rel="rel2" href="linkhref" />
5443 + <meta name="name1" content="metacontent" />
5444 + <meta name="name2" content="metacontent" />
5445 + <link rel="rel3" href="linkhref" />
5446 + <link rel="rel4" href="linkhref" />
5447 + <meta name="name3" content="metacontent" />
5448 + <meta name="name4" content="metacontent" />
5449 + </head>
5450 + <body>loading...</body>
5451 + </html>,
5452 + );
5453 +
5454 + const root = ReactDOMClient.hydrateRoot(document, <App />);
5455 + await waitForAll([]);
5456 + expect(getMeaningfulChildren(document)).toEqual(
5457 + <html>
5458 + <head>
5459 + <link rel="rel1" href="linkhref" />
5460 + <link rel="rel2" href="linkhref" />
5461 + <meta name="name1" content="metacontent" />
5462 + <meta name="name2" content="metacontent" />
5463 + <link rel="rel3" href="linkhref" />
5464 + <link rel="rel4" href="linkhref" />
5465 + <meta name="name3" content="metacontent" />
5466 + <meta name="name4" content="metacontent" />
5467 + </head>
5468 + <body>loading...</body>
5469 + </html>,
5470 + );
5471 +
5472 + const thirdPartyLink = document.createElement('link');
5473 + thirdPartyLink.setAttribute('href', 'linkhref');
5474 + thirdPartyLink.setAttribute('rel', '3rdparty');
5475 + document.body.prepend(thirdPartyLink);
5476 +
5477 + const thirdPartyMeta = document.createElement('meta');
5478 + thirdPartyMeta.setAttribute('content', 'metacontent');
5479 + thirdPartyMeta.setAttribute('name', '3rdparty');
5480 + document.body.prepend(thirdPartyMeta);
5481 +
5482 + await act(() => {
5483 + resolveText('release');
5484 + });
5485 + await waitForAll([]);
5486 + expect(getMeaningfulChildren(document)).toEqual(
5487 + <html>
5488 + <head>
5489 + <link rel="rel1" href="linkhref" />
5490 + <link rel="rel2" href="linkhref" />
5491 + <meta name="name1" content="metacontent" />
5492 + <meta name="name2" content="metacontent" />
5493 + <link rel="rel3" href="linkhref" />
5494 + <link rel="rel4" href="linkhref" />
5495 + <meta name="name3" content="metacontent" />
5496 + <meta name="name4" content="metacontent" />
5497 + </head>
5498 + <body>
5499 + <meta name="3rdparty" content="metacontent" />
5500 + <link rel="3rdparty" href="linkhref" />
5501 + <div>hello world</div>
5502 + <link rel="rel5" href="linkhref" />
5503 + <link rel="rel6" href="linkhref" />
5504 + <meta name="name5" content="metacontent" />
5505 + <meta name="name6" content="metacontent" />
5506 + </body>
5507 + </html>,
5508 + );
5509 +
5510 + root.unmount();
5511 + expect(getMeaningfulChildren(document)).toEqual(
5512 + <html>
5513 + <head />
5514 + <body>
5515 + <meta name="3rdparty" content="metacontent" />
5516 + <link rel="3rdparty" href="linkhref" />
5517 + </body>
5518 + </html>,
5519 + );
5520 + });
5521 +
5522 // @gate enableFloat
5523 it('does not hoist inside an <svg> context', async () => {
5524 await actIntoEmptyDocument(() => {
packages/react-reconciler/src/ReactFiberCommitWork.js
+3
@@ -157,6 +157,7 @@ import {
157 hydrateHoistable,
158 mountHoistable,
159 unmountHoistable,
160 + prepareToCommitHoistables,
161 } from './ReactFiberHostConfig';
162 import {
163 captureCommitPhaseError,
@@ -2822,6 +2823,8 @@ function commitMutationEffectsOnFiber(
2823 }
2824 case HostRoot: {
2825 if (enableFloat && supportsResources) {
2826 + prepareToCommitHoistables();
2827 +
2828 const previousHoistableRoot = currentHoistableRoot;
2829 currentHoistableRoot = getHoistableRoot(root.containerInfo);
2830
packages/react-reconciler/src/ReactFiberHostConfigWithNoResources.js
+1
@@ -31,3 +31,4 @@ export const hydrateHoistable = shim;
31 export const mountHoistable = shim;
32 export const unmountHoistable = shim;
33 export const createHoistableInstance = shim;
34 +export const prepareToCommitHoistables = shim;
packages/react-reconciler/src/ReactFiberHydrationContext.js
+9 -9
@@ -436,7 +436,7 @@ function claimHydratableSingleton(fiber: Fiber): void {
436 }
437 }
438
439 -function advanceToFirstAttempableInstance(fiber: Fiber) {
439 +function advanceToFirstAttemptableInstance(fiber: Fiber) {
440 // fiber is HostComponent Fiber
441 while (
442 nextHydratableInstance &&
@@ -452,7 +452,7 @@ function advanceToFirstAttempableInstance(fiber: Fiber) {
452 }
453 }
454
455 -function advanceToFirstAttempableTextInstance() {
455 +function advanceToFirstAttemptableTextInstance() {
456 while (
457 nextHydratableInstance &&
458 shouldSkipHydratableForTextInstance(nextHydratableInstance)
@@ -463,7 +463,7 @@ function advanceToFirstAttempableTextInstance() {
463 }
464 }
465
466 -function advanceToFirstAttempableSuspenseInstance() {
466 +function advanceToFirstAttemptableSuspenseInstance() {
467 while (
468 nextHydratableInstance &&
469 shouldSkipHydratableForSuspenseInstance(nextHydratableInstance)
@@ -490,7 +490,7 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
490 const initialInstance = nextHydratableInstance;
491 if (rootOrSingletonContext) {
492 // We may need to skip past certain nodes in these contexts
493 - advanceToFirstAttempableInstance(fiber);
493 + advanceToFirstAttemptableInstance(fiber);
494 }
495 const nextInstance = nextHydratableInstance;
496 if (!nextInstance) {
@@ -518,7 +518,7 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void {
518 const prevHydrationParentFiber: Fiber = (hydrationParentFiber: any);
519 if (rootOrSingletonContext) {
520 // We may need to skip past certain nodes in these contexts
521 - advanceToFirstAttempableInstance(fiber);
521 + advanceToFirstAttemptableInstance(fiber);
522 }
523 if (
524 !nextHydratableInstance ||
@@ -551,7 +551,7 @@ function tryToClaimNextHydratableTextInstance(fiber: Fiber): void {
551 // We may need to skip past certain nodes in these contexts.
552 // We don't skip if the text is not hydratable because we know no hydratables
553 // exist which could match this Fiber
554 - advanceToFirstAttempableTextInstance();
554 + advanceToFirstAttemptableTextInstance();
555 }
556 const nextInstance = nextHydratableInstance;
557 if (!nextInstance || !isHydratable) {
@@ -582,7 +582,7 @@ function tryToClaimNextHydratableTextInstance(fiber: Fiber): void {
582
583 if (rootOrSingletonContext && isHydratable) {
584 // We may need to skip past certain nodes in these contexts
585 - advanceToFirstAttempableTextInstance();
585 + advanceToFirstAttemptableTextInstance();
586 }
587
588 if (
@@ -611,7 +611,7 @@ function tryToClaimNextHydratableSuspenseInstance(fiber: Fiber): void {
611 const initialInstance = nextHydratableInstance;
612 if (rootOrSingletonContext) {
613 // We may need to skip past certain nodes in these contexts
614 - advanceToFirstAttempableSuspenseInstance();
614 + advanceToFirstAttemptableSuspenseInstance();
615 }
616 const nextInstance = nextHydratableInstance;
617 if (!nextInstance) {
@@ -640,7 +640,7 @@ function tryToClaimNextHydratableSuspenseInstance(fiber: Fiber): void {
640
641 if (rootOrSingletonContext) {
642 // We may need to skip past certain nodes in these contexts
643 - advanceToFirstAttempableSuspenseInstance();
643 + advanceToFirstAttemptableSuspenseInstance();
644 }
645
646 if (
packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js
+3 -1
@@ -202,6 +202,7 @@ export const errorHydratingContainer = $$$hostConfig.errorHydratingContainer;
202 // Resources
203 // (optional)
204 // -------------------
205 +export type HoistableRoot = mixed;
206 export const supportsResources = $$$hostConfig.supportsResources;
207 export const isHostHoistableType = $$$hostConfig.isHostHoistableType;
208 export const getHoistableRoot = $$$hostConfig.getHoistableRoot;
@@ -212,7 +213,8 @@ export const hydrateHoistable = $$$hostConfig.hydrateHoistable;
213 export const mountHoistable = $$$hostConfig.mountHoistable;
214 export const unmountHoistable = $$$hostConfig.unmountHoistable;
215 export const createHoistableInstance = $$$hostConfig.createHoistableInstance;
215 -export type HoistableRoot = mixed;
216 +export const prepareToCommitHoistables =
217 + $$$hostConfig.prepareToCommitHoistables;
218
219 // -------------------
220 // Singletons
scripts/error-codes/codes.json
+2 -1
@@ -452,5 +452,6 @@
452 "464": "ReactDOMServer.renderToStaticNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead.",
453 "465": "enableFizzExternalRuntime without enableFloat is not supported. This should never appear in production, since it means you are using a misconfigured React bundle.",
454 "466": "Trying to call a function from \"use server\" but the callServer option was not implemented in your router runtime.",
455 - "467": "Update hook called on initial render. This is likely a bug in React. Please file an issue."
455 + "467": "Update hook called on initial render. This is likely a bug in React. Please file an issue.",
456 + "468": "getNodesForType encountered a type it did not expect: \"%s\". This is a bug in React."
457 }
\ No newline at end of file