@samitouri / QOS-React-2 / commits / 973b90bdf6

[Float] support meta tags as Resources (#25514)

Stacked on #25508 This PR adds meta tags as a resource type. metas are classified in the following priority 1. charset 2. http-equiv 3. property 4. name 5. itemprop when using property, there is special logic for og type properties where a `property="og:image:height"` following a `property="og:image"` will inherit the key of the previous tag. this relies on timing effects to stay consistent so when mounting new metas it is important that if structured properties are being used all members of a structure mount together. This is similarly true for arrays where the implicit sequential order defines the array structure. if you need an array you need to mount all array members in the same pass.

Josh Story committed Oct 21, 2022 at 15:21 UTC 973b90bdf6f4a1d9b3864d93985d4a204f233855
11 files changed +823 -159
packages/react-dom-bindings/src/client/ReactDOMComponentTree.js
+1
@@ -286,6 +286,7 @@ export function getResourcesFromRoot(root: FloatRoot): RootResources {
286 styles: new Map(),
287 scripts: new Map(),
288 head: new Map(),
289 + lastStructuredMeta: new Map(),
290 };
291 }
292 return resources;
packages/react-dom-bindings/src/client/ReactDOMFloatClient.js
+187 -88
@@ -88,13 +88,29 @@ export type ScriptResource = {
88 root: FloatRoot,
89 };
90
91 -type HeadProps = {
91 +export type HeadResource = TitleResource | MetaResource;
92 +
93 +type TitleProps = {
94 + [string]: mixed,
95 +};
96 +export type TitleResource = {
97 + type: 'title',
98 + props: TitleProps,
99 +
100 + count: number,
101 + instance: ?Element,
102 + root: Document,
103 +};
104 +
105 +type MetaProps = {
106 [string]: mixed,
107 };
94 -export type HeadResource = {
95 - type: 'head',
96 - instanceType: string,
97 - props: HeadProps,
108 +export type MetaResource = {
109 + type: 'meta',
110 + matcher: string,
111 + property: ?string,
112 + parentResource: ?MetaResource,
113 + props: MetaProps,
114
115 count: number,
116 instance: ?Element,
@@ -109,6 +125,7 @@ export type RootResources = {
125 styles: Map<string, StyleResource>,
126 scripts: Map<string, ScriptResource>,
127 head: Map<string, HeadResource>,
128 + lastStructuredMeta: Map<string, MetaResource>,
129 };
130
131 // Brief on purpose due to insertion by script when streaming late boundaries
@@ -409,6 +426,84 @@ export function getResource(
426 );
427 }
428 switch (type) {
429 + case 'meta': {
430 + let matcher, propertyString, parentResource;
431 + const {
432 + charSet,
433 + content,
434 + httpEquiv,
435 + name,
436 + itemProp,
437 + property,
438 + } = pendingProps;
439 + const headRoot: Document = getDocumentFromRoot(resourceRoot);
440 + const {head: headResources, lastStructuredMeta} = getResourcesFromRoot(
441 + headRoot,
442 + );
443 + if (typeof charSet === 'string') {
444 + matcher = 'meta[charset]';
445 + } else if (typeof content === 'string') {
446 + if (typeof httpEquiv === 'string') {
447 + matcher = `meta[http-equiv="${escapeSelectorAttributeValueInsideDoubleQuotes(
448 + httpEquiv,
449 + )}"][content="${escapeSelectorAttributeValueInsideDoubleQuotes(
450 + content,
451 + )}"]`;
452 + } else if (typeof property === 'string') {
453 + propertyString = property;
454 + matcher = `meta[property="${escapeSelectorAttributeValueInsideDoubleQuotes(
455 + property,
456 + )}"][content="${escapeSelectorAttributeValueInsideDoubleQuotes(
457 + content,
458 + )}"]`;
459 +
460 + const parentPropertyPath = property
461 + .split(':')
462 + .slice(0, -1)
463 + .join(':');
464 + parentResource = lastStructuredMeta.get(parentPropertyPath);
465 + if (parentResource) {
466 + // When using parentResource the matcher is not functional for locating
467 + // the instance in the DOM but it still serves as a unique key.
468 + matcher = parentResource.matcher + matcher;
469 + }
470 + } else if (typeof name === 'string') {
471 + matcher = `meta[name="${escapeSelectorAttributeValueInsideDoubleQuotes(
472 + name,
473 + )}"][content="${escapeSelectorAttributeValueInsideDoubleQuotes(
474 + content,
475 + )}"]`;
476 + } else if (typeof itemProp === 'string') {
477 + matcher = `meta[itemprop="${escapeSelectorAttributeValueInsideDoubleQuotes(
478 + itemProp,
479 + )}"][content="${escapeSelectorAttributeValueInsideDoubleQuotes(
480 + content,
481 + )}"]`;
482 + }
483 + }
484 + if (matcher) {
485 + let resource = headResources.get(matcher);
486 + if (!resource) {
487 + resource = {
488 + type: 'meta',
489 + matcher,
490 + property: propertyString,
491 + parentResource,
492 + props: Object.assign({}, pendingProps),
493 + count: 0,
494 + instance: null,
495 + root: headRoot,
496 + };
497 + headResources.set(matcher, resource);
498 + }
499 + if (typeof resource.property === 'string') {
500 + // We cast because flow doesn't know that this resource must be a Meta resource
501 + lastStructuredMeta.set(resource.property, (resource: any));
502 + }
503 + return resource;
504 + }
505 + return null;
506 + }
507 case 'title': {
508 let child = pendingProps.children;
509 if (Array.isArray(child) && child.length === 1) {
@@ -421,13 +516,14 @@ export function getResource(
516 let resource = headResources.get(key);
517 if (!resource) {
518 const titleProps = titlePropsFromRawProps(child, pendingProps);
424 - resource = createHeadResource(
425 - headResources,
426 - headRoot,
427 - 'title',
428 - key,
429 - titleProps,
430 - );
519 + resource = {
520 + type: 'title',
521 + props: titleProps,
522 + count: 0,
523 + instance: null,
524 + root: headRoot,
525 + };
526 + headResources.set(key, resource);
527 }
528 return resource;
529 }
@@ -588,8 +684,8 @@ function preloadPropsFromRawProps(
684 function titlePropsFromRawProps(
685 child: string | number,
686 rawProps: Props,
591 -): HeadProps {
592 - const props: HeadProps = Object.assign({}, rawProps);
687 +): TitleProps {
688 + const props: TitleProps = Object.assign({}, rawProps);
689 props.children = child;
690 return props;
691 }
@@ -613,7 +709,8 @@ function scriptPropsFromRawProps(rawProps: ScriptQualifyingProps): ScriptProps {
709
710 export function acquireResource(resource: Resource): Instance {
711 switch (resource.type) {
616 - case 'head': {
712 + case 'title':
713 + case 'meta': {
714 return acquireHeadResource(resource);
715 }
716 case 'style': {
@@ -635,7 +732,8 @@ export function acquireResource(resource: Resource): Instance {
732
733 export function releaseResource(resource: Resource): void {
734 switch (resource.type) {
638 - case 'head': {
735 + case 'title':
736 + case 'meta': {
737 return releaseHeadResource(resource);
738 }
739 case 'style': {
@@ -668,35 +766,6 @@ function createResourceInstance(
766 return element;
767 }
768
671 -function createHeadResource(
672 - headResources: Map<string, HeadResource>,
673 - root: Document,
674 - instanceType: string,
675 - key: string,
676 - props: HeadProps,
677 -): HeadResource {
678 - if (__DEV__) {
679 - if (headResources.has(key)) {
680 - console.error(
681 - 'createHeadResource was called when a head Resource matching the same key already exists. This is a bug in React.',
682 - );
683 - }
684 - }
685 -
686 - const resource: HeadResource = {
687 - type: 'head',
688 - instanceType,
689 - props,
690 -
691 - count: 0,
692 - instance: null,
693 - root,
694 - };
695 -
696 - headResources.set(key, resource);
697 - return resource;
698 -}
699 -
769 function createStyleResource(
770 styleResources: Map<string, StyleResource>,
771 root: FloatRoot,
@@ -894,7 +963,7 @@ function createPreloadResource(
963 );
964 if (!element) {
965 element = createResourceInstance('link', props, ownerDocument);
897 - appendResourceInstance(element, ownerDocument);
966 + insertResourceInstanceBefore(ownerDocument, element, null);
967 } else {
968 markNodeAsResource(element);
969 }
@@ -911,8 +980,8 @@ function acquireHeadResource(resource: HeadResource): Instance {
980 resource.count++;
981 let instance = resource.instance;
982 if (!instance) {
914 - const {props, root, instanceType} = resource;
915 - switch (instanceType) {
983 + const {props, root, type} = resource;
984 + switch (type) {
985 case 'title': {
986 const titles = root.querySelectorAll('title');
987 for (let i = 0; i < titles.length; i++) {
@@ -922,18 +991,70 @@ function acquireHeadResource(resource: HeadResource): Instance {
991 return instance;
992 }
993 }
994 + instance = resource.instance = createResourceInstance(
995 + type,
996 + props,
997 + root,
998 + );
999 + insertResourceInstanceBefore(root, instance, titles.item(0));
1000 + break;
1001 + }
1002 + case 'meta': {
1003 + let insertBefore = null;
1004 +
1005 + const metaResource: MetaResource = (resource: any);
1006 + const {matcher, property, parentResource} = metaResource;
1007 +
1008 + if (parentResource && typeof property === 'string') {
1009 + // This resoruce is a structured meta type with a parent.
1010 + // Instead of using the matcher we just traverse forward
1011 + // siblings of the parent instance until we find a match
1012 + // or exhaust.
1013 + const parent = parentResource.instance;
1014 + if (parent) {
1015 + let node = null;
1016 + let nextNode = (insertBefore = parent.nextSibling);
1017 + while ((node = nextNode)) {
1018 + nextNode = node.nextSibling;
1019 + if (node.nodeName === 'META') {
1020 + const meta: Element = (node: any);
1021 + const propertyAttr = meta.getAttribute('property');
1022 + if (typeof propertyAttr !== 'string') {
1023 + continue;
1024 + } else if (
1025 + propertyAttr === property &&
1026 + meta.getAttribute('content') === props.content
1027 + ) {
1028 + resource.instance = meta;
1029 + markNodeAsResource(meta);
1030 + return meta;
1031 + } else if (property.startsWith(propertyAttr + ':')) {
1032 + // This meta starts a new instance of a parent structure for this meta type
1033 + // We need to halt our search here because even if we find a later match it
1034 + // is for a different parent element
1035 + break;
1036 + }
1037 + }
1038 + }
1039 + }
1040 + } else if ((instance = root.querySelector(matcher))) {
1041 + resource.instance = instance;
1042 + markNodeAsResource(instance);
1043 + return instance;
1044 + }
1045 + instance = resource.instance = createResourceInstance(
1046 + type,
1047 + props,
1048 + root,
1049 + );
1050 + insertResourceInstanceBefore(root, instance, insertBefore);
1051 + break;
1052 + }
1053 + default: {
1054 + throw new Error(
1055 + `acquireHeadResource encountered a resource type it did not expect: "${type}". This is a bug in React.`,
1056 + );
1057 }
926 - }
927 - instance = resource.instance = createResourceInstance(
928 - instanceType,
929 - props,
930 - root,
931 - );
932 -
933 - if (instanceType === 'title') {
934 - prependResourceInstance(instance, root);
935 - } else {
936 - appendResourceInstance(instance, root);
1058 }
1059 }
1060 return instance;
@@ -1010,7 +1131,7 @@ function acquireScriptResource(resource: ScriptResource): Instance {
1131 getDocumentFromRoot(root),
1132 );
1133
1013 - appendResourceInstance(instance, getDocumentFromRoot(root));
1134 + insertResourceInstanceBefore(getDocumentFromRoot(root), instance, null);
1135 }
1136 }
1137 return instance;
@@ -1113,45 +1234,22 @@ function insertStyleInstance(
1234 }
1235 }
1236
1116 -function prependResourceInstance(
1117 - instance: Instance,
1237 +function insertResourceInstanceBefore(
1238 ownerDocument: Document,
1119 -): void {
1120 - if (__DEV__) {
1121 - if (instance.tagName === 'LINK' && (instance: any).rel === 'stylesheet') {
1122 - console.error(
1123 - 'prependResourceInstance was called with a stylesheet. Stylesheets must be' +
1124 - ' inserted with insertStyleInstance instead. This is a bug in React.',
1125 - );
1126 - }
1127 - }
1128 -
1129 - const parent = ownerDocument.head;
1130 - if (parent) {
1131 - parent.insertBefore(instance, parent.firstChild);
1132 - } else {
1133 - throw new Error(
1134 - 'While attempting to insert a Resource, React expected the Document to contain' +
1135 - ' a head element but it was not found.',
1136 - );
1137 - }
1138 -}
1139 -
1140 -function appendResourceInstance(
1239 instance: Instance,
1142 - ownerDocument: Document,
1240 + before: ?Node,
1241 ): void {
1242 if (__DEV__) {
1243 if (instance.tagName === 'LINK' && (instance: any).rel === 'stylesheet') {
1244 console.error(
1147 - 'appendResourceInstance was called with a stylesheet. Stylesheets must be' +
1245 + 'insertResourceInstanceBefore was called with a stylesheet. Stylesheets must be' +
1246 ' inserted with insertStyleInstance instead. This is a bug in React.',
1247 );
1248 }
1249 }
1152 - const parent = ownerDocument.head;
1250 + const parent = (before && before.parentNode) || ownerDocument.head;
1251 if (parent) {
1154 - parent.appendChild(instance);
1252 + parent.insertBefore(instance, before);
1253 } else {
1254 throw new Error(
1255 'While attempting to insert a Resource, React expected the Document to contain' +
@@ -1162,6 +1260,7 @@ function appendResourceInstance(
1260
1261 export function isHostResourceType(type: string, props: Props): boolean {
1262 switch (type) {
1263 + case 'meta':
1264 case 'title': {
1265 return true;
1266 }
packages/react-dom-bindings/src/client/ReactDOMHostConfig.js
+6 -5
@@ -714,13 +714,14 @@ export function unhideTextInstance(
714 export function clearContainer(container: Container): void {
715 if (enableHostSingletons) {
716 const nodeType = container.nodeType;
717 - if (nodeType === DOCUMENT_NODE || nodeType === ELEMENT_NODE) {
717 + if (nodeType === DOCUMENT_NODE) {
718 + clearContainerSparingly(container);
719 + } else if (nodeType === ELEMENT_NODE) {
720 switch (container.nodeName) {
719 - case '#document':
721 case 'HTML':
722 case 'HEAD':
723 case 'BODY':
723 - clearContainerChildren(container);
724 + clearContainerSparingly(container);
725 return;
726 default: {
727 container.textContent = '';
@@ -742,7 +743,7 @@ export function clearContainer(container: Container): void {
743 }
744 }
745
745 -function clearContainerChildren(container: Node) {
746 +function clearContainerSparingly(container: Node) {
747 let node;
748 let nextNode: ?Node = container.firstChild;
749 if (nextNode && nextNode.nodeType === DOCUMENT_TYPE_NODE) {
@@ -756,7 +757,7 @@ function clearContainerChildren(container: Node) {
757 case 'HEAD':
758 case 'BODY': {
759 const element: Element = (node: any);
759 - clearContainerChildren(element);
760 + clearContainerSparingly(element);
761 // If these singleton instances had previously been rendered with React they
762 // may still hold on to references to the previous fiber tree. We detatch them
763 // prospectively to reset them to a baseline starting state since we cannot create
packages/react-dom-bindings/src/server/ReactDOMFloatServer.js
+79 -41
@@ -68,19 +68,29 @@ type ScriptResource = {
68 hint: PreloadResource,
69 };
70
71 -type HeadProps = {
71 +type TitleProps = {
72 [string]: mixed,
73 };
74 -type HeadResource = {
75 - type: 'head',
76 - instanceType: string,
77 - props: HeadProps,
74 +type TitleResource = {
75 + type: 'title',
76 + props: TitleProps,
77 +
78 + flushed: boolean,
79 +};
80 +
81 +type MetaProps = {
82 + [string]: mixed,
83 +};
84 +type MetaResource = {
85 + type: 'meta',
86 + key: string,
87 + props: MetaProps,
88
89 flushed: boolean,
80 - allowLate: boolean,
90 };
91
92 export type Resource = PreloadResource | StyleResource | ScriptResource;
93 +export type HeadResource = TitleResource | MetaResource;
94
95 export type Resources = {
96 // Request local cache
@@ -90,7 +100,7 @@ export type Resources = {
100 headsMap: Map<string, HeadResource>,
101
102 // Flushing queues for Resource dependencies
93 - charset: null | HeadResource,
103 + charset: null | MetaResource,
104 fontPreloads: Set<PreloadResource>,
105 // usedImagePreloads: Set<PreloadResource>,
106 precedences: Map<string, Set<StyleResource>>,
@@ -102,6 +112,9 @@ export type Resources = {
112 explicitScriptPreloads: Set<PreloadResource>,
113 headResources: Set<HeadResource>,
114
115 + // cache for tracking structured meta tags
116 + structuredMetaKeys: Map<string, MetaResource>,
117 +
118 // Module-global-like reference for current boundary resources
119 boundaryResources: ?BoundaryResources,
120 ...
@@ -129,6 +142,9 @@ export function createResources(): Resources {
142 explicitScriptPreloads: new Set(),
143 headResources: new Set(),
144
145 + // cache for tracking structured meta tags
146 + structuredMetaKeys: new Map(),
147 +
148 // like a module global for currently rendering boundary
149 boundaryResources: null,
150 };
@@ -581,40 +597,10 @@ function adoptPreloadPropsForScriptProps(
597 resourceProps.integrity = preloadProps.integrity;
598 }
599
584 -function createHeadResource(
585 - resources: Resources,
586 - key: string,
587 - instanceType: string,
588 - props: HeadProps,
589 -): HeadResource {
590 - if (__DEV__) {
591 - if (resources.headsMap.has(key)) {
592 - console.error(
593 - 'createScriptResource was called when a script Resource matching the same src already exists. This is a bug in React.',
594 - );
595 - }
596 - }
597 -
598 - const resource: HeadResource = {
599 - type: 'head',
600 - instanceType,
601 - props,
602 -
603 - flushed: false,
604 - allowLate: true,
605 - };
606 - resources.headsMap.set(key, resource);
607 - return resource;
608 -}
609 -
610 -function getTitleKey(child: string | number): string {
611 - return 'title' + child;
612 -}
613 -
600 function titlePropsFromRawProps(
601 child: string | number,
602 rawProps: Props,
617 -): HeadProps {
603 +): TitleProps {
604 const props = Object.assign({}, rawProps);
605 props.children = child;
606 return props;
@@ -634,17 +620,69 @@ export function resourcesFromElement(type: string, props: Props): boolean {
620 child = child[0];
621 }
622 if (typeof child === 'string' || typeof child === 'number') {
637 - const key = getTitleKey(child);
623 + const key = 'title::' + child;
624 let resource = resources.headsMap.get(key);
625 if (!resource) {
640 - const titleProps = titlePropsFromRawProps(child, props);
641 - resource = createHeadResource(resources, key, 'title', titleProps);
626 + resource = {
627 + type: 'title',
628 + props: titlePropsFromRawProps(child, props),
629 + flushed: false,
630 + };
631 + resources.headsMap.set(key, resource);
632 resources.headResources.add(resource);
633 }
634 return true;
635 }
636 return false;
637 }
638 + case 'meta': {
639 + let key, propertyPath;
640 + if (typeof props.charSet === 'string') {
641 + key = 'charSet';
642 + } else if (typeof props.content === 'string') {
643 + const contentKey = '::' + props.content;
644 + if (typeof props.httpEquiv === 'string') {
645 + key = 'httpEquiv::' + props.httpEquiv + contentKey;
646 + } else if (typeof props.name === 'string') {
647 + key = 'name::' + props.name + contentKey;
648 + } else if (typeof props.itemProp === 'string') {
649 + key = 'itemProp::' + props.itemProp + contentKey;
650 + } else if (typeof props.property === 'string') {
651 + const {property} = props;
652 + key = 'property::' + property + contentKey;
653 + propertyPath = property;
654 + const parentPath = property
655 + .split(':')
656 + .slice(0, -1)
657 + .join(':');
658 + const parentResource = resources.structuredMetaKeys.get(parentPath);
659 + if (parentResource) {
660 + key = parentResource.key + '::child::' + key;
661 + }
662 + }
663 + }
664 + if (key) {
665 + if (!resources.headsMap.has(key)) {
666 + const resource = {
667 + type: 'meta',
668 + key,
669 + props: Object.assign({}, props),
670 + flushed: false,
671 + };
672 + resources.headsMap.set(key, resource);
673 + if (key === 'charSet') {
674 + resources.charset = resource;
675 + } else {
676 + if (propertyPath) {
677 + resources.structuredMetaKeys.set(propertyPath, resource);
678 + }
679 + resources.headResources.add(resource);
680 + }
681 + }
682 + return true;
683 + }
684 + return false;
685 + }
686 }
687 return false;
688 }
packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js
+60 -11
@@ -1150,6 +1150,26 @@ function pushStartTextArea(
1150 return null;
1151 }
1152
1153 +function pushMeta(
1154 + target: Array<Chunk | PrecomputedChunk>,
1155 + props: Object,
1156 + responseState: ResponseState,
1157 + textEmbedded: boolean,
1158 +): ReactNodeList {
1159 + if (enableFloat && resourcesFromElement('meta', props)) {
1160 + if (textEmbedded) {
1161 + // This link follows text but we aren't writing a tag. while not as efficient as possible we need
1162 + // to be safe and assume text will follow by inserting a textSeparator
1163 + target.push(textSeparator);
1164 + }
1165 + // We have converted this link exclusively to a resource and no longer
1166 + // need to emit it
1167 + return null;
1168 + }
1169 +
1170 + return pushSelfClosing(target, props, 'meta', responseState);
1171 +}
1172 +
1173 function pushLink(
1174 target: Array<Chunk | PrecomputedChunk>,
1175 props: Object,
@@ -1688,6 +1708,8 @@ export function pushStartInstance(
1708 return pushLink(target, props, responseState, textEmbedded);
1709 case 'script':
1710 return pushStartScript(target, props, responseState, textEmbedded);
1711 + case 'meta':
1712 + return pushMeta(target, props, responseState, textEmbedded);
1713 // Newline eating tags
1714 case 'listing':
1715 case 'pre': {
@@ -1702,7 +1724,6 @@ export function pushStartInstance(
1724 case 'hr':
1725 case 'img':
1726 case 'keygen':
1705 - case 'meta':
1727 case 'param':
1728 case 'source':
1729 case 'track':
@@ -2318,6 +2339,7 @@ export function writeInitialResources(
2339 const target = [];
2340
2341 const {
2342 + charset,
2343 fontPreloads,
2344 precedences,
2345 usedStylePreloads,
@@ -2328,6 +2350,12 @@ export function writeInitialResources(
2350 headResources,
2351 } = resources;
2352
2353 + if (charset) {
2354 + pushSelfClosing(target, charset.props, 'meta', responseState);
2355 + charset.flushed = true;
2356 + resources.charset = null;
2357 + }
2358 +
2359 fontPreloads.forEach(r => {
2360 // font preload Resources should not already be flushed so we elide this check
2361 pushLinkImpl(target, r.props, responseState);
@@ -2377,12 +2405,19 @@ export function writeInitialResources(
2405 explicitScriptPreloads.clear();
2406
2407 headResources.forEach(r => {
2380 - if (r.instanceType === 'title') {
2381 - pushStartTitleImpl(target, r.props, responseState);
2382 - if (typeof r.props.children === 'string') {
2383 - target.push(escapeTextForBrowser(stringToChunk(r.props.children)));
2408 + switch (r.type) {
2409 + case 'title': {
2410 + pushStartTitleImpl(target, r.props, responseState);
2411 + if (typeof r.props.children === 'string') {
2412 + target.push(escapeTextForBrowser(stringToChunk(r.props.children)));
2413 + }
2414 + pushEndInstance(target, target, 'title', r.props);
2415 + break;
2416 + }
2417 + case 'meta': {
2418 + pushSelfClosing(target, r.props, 'meta', responseState);
2419 + break;
2420 }
2385 - pushEndInstance(target, target, 'title', r.props);
2421 }
2422 r.flushed = true;
2423 });
@@ -2414,6 +2449,7 @@ export function writeImmediateResources(
2449 const target = [];
2450
2451 const {
2452 + charset,
2453 fontPreloads,
2454 usedStylePreloads,
2455 scripts,
@@ -2423,6 +2459,12 @@ export function writeImmediateResources(
2459 headResources,
2460 } = resources;
2461
2462 + if (charset) {
2463 + pushSelfClosing(target, charset.props, 'meta', responseState);
2464 + charset.flushed = true;
2465 + resources.charset = null;
2466 + }
2467 +
2468 fontPreloads.forEach(r => {
2469 // font preload Resources should not already be flushed so we elide this check
2470 pushLinkImpl(target, r.props, responseState);
@@ -2452,12 +2494,19 @@ export function writeImmediateResources(
2494 explicitScriptPreloads.clear();
2495
2496 headResources.forEach(r => {
2455 - if (r.instanceType === 'title') {
2456 - pushStartTitle(target, r.props, responseState);
2457 - if (typeof r.props.children === 'string') {
2458 - target.push(escapeTextForBrowser(stringToChunk(r.props.children)));
2497 + switch (r.type) {
2498 + case 'title': {
2499 + pushStartTitleImpl(target, r.props, responseState);
2500 + if (typeof r.props.children === 'string') {
2501 + target.push(escapeTextForBrowser(stringToChunk(r.props.children)));
2502 + }
2503 + pushEndInstance(target, target, 'title', r.props);
2504 + break;
2505 + }
2506 + case 'meta': {
2507 + pushSelfClosing(target, r.props, 'meta', responseState);
2508 + break;
2509 }
2460 - pushEndInstance(target, target, 'title', r.props);
2510 }
2511 r.flushed = true;
2512 });
packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+472
@@ -941,6 +941,478 @@ describe('ReactDOMFloat', () => {
941 });
942
943 describe('head resources', () => {
944 + // @gate enableFloat
945 + it('can hydrate the right instances for deeply nested structured metas', async () => {
946 + await actIntoEmptyDocument(() => {
947 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
948 + <>
949 + <html>
950 + <head />
951 + <body>
952 + <div>hello world</div>
953 + </body>
954 + </html>
955 + <meta property="og:foo" content="one" />
956 + <meta property="og:foo:bar" content="bar" />
957 + <meta property="og:foo:bar:baz" content="baz" />
958 + <meta property="og:foo" content="two" />
959 + <meta property="og:foo:bar" content="bar" />
960 + <meta property="og:foo:bar:baz" content="baz" />
961 + </>,
962 + );
963 + pipe(writable);
964 + });
965 + expect(getMeaningfulChildren(document)).toEqual(
966 + <html>
967 + <head>
968 + <meta property="og:foo" content="one" />
969 + <meta property="og:foo:bar" content="bar" />
970 + <meta property="og:foo:bar:baz" content="baz" />
971 + <meta property="og:foo" content="two" />
972 + <meta property="og:foo:bar" content="bar" />
973 + <meta property="og:foo:bar:baz" content="baz" />
974 + </head>
975 + <body>
976 + <div>hello world</div>
977 + </body>
978 + </html>,
979 + );
980 +
981 + const root = ReactDOMClient.hydrateRoot(
982 + document,
983 + <>
984 + <html>
985 + <head />
986 + <body>
987 + <div>hello world</div>
988 + </body>
989 + </html>
990 + <meta property="og:foo" content="one" />
991 + <meta property="og:foo:bar" content="bar" />
992 + <meta property="og:foo:bar:baz" content="baz" />
993 + <meta property="og:foo" content="two" />
994 + <meta property="og:foo:bar" content="bar" />
995 + <meta property="og:foo:bar:baz" content="baz" />
996 + </>,
997 + );
998 + expect(Scheduler).toFlushWithoutYielding();
999 + expect(getMeaningfulChildren(document)).toEqual(
1000 + <html>
1001 + <head>
1002 + <meta property="og:foo" content="one" />
1003 + <meta property="og:foo:bar" content="bar" />
1004 + <meta property="og:foo:bar:baz" content="baz" />
1005 + <meta property="og:foo" content="two" />
1006 + <meta property="og:foo:bar" content="bar" />
1007 + <meta property="og:foo:bar:baz" content="baz" />
1008 + </head>
1009 + <body>
1010 + <div>hello world</div>
1011 + </body>
1012 + </html>,
1013 + );
1014 +
1015 + root.render(
1016 + <>
1017 + <html>
1018 + <head />
1019 + <body>
1020 + <div>hello world</div>
1021 + </body>
1022 + </html>
1023 + </>,
1024 + );
1025 + expect(Scheduler).toFlushWithoutYielding();
1026 + expect(getMeaningfulChildren(document)).toEqual(
1027 + <html>
1028 + <head />
1029 + <body>
1030 + <div>hello world</div>
1031 + </body>
1032 + </html>,
1033 + );
1034 + });
1035 +
1036 + // @gate enableFloat
1037 + it('can insert meta tags in the expected location', async () => {
1038 + await actIntoEmptyDocument(() => {
1039 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
1040 + <>
1041 + <html>
1042 + <head />
1043 + <body>
1044 + <div>hello world</div>
1045 + </body>
1046 + </html>
1047 + <meta charSet="utf-8" />
1048 + <meta name="google-site-verification" content="somehash1" />
1049 + <meta name="google-site-verification" content="somehash2" />
1050 + <meta
1051 + name="description"
1052 + property="og:description"
1053 + content="my site"
1054 + />
1055 + <meta property="og:image" content="foo" />
1056 + <meta property="og:image:width" content="100" />
1057 + <meta httpEquiv="refresh" content="dont actually" />
1058 + <meta property="og:image:height" content="100" />
1059 + <meta property="og:image" content="bar" />
1060 + <meta property="og:image:width" content="100" />
1061 + <meta itemProp="someprop" content="somevalue" />
1062 + <meta property="og:image:height" content="100" />
1063 + <meta property="og:description:foo" content="foo" />
1064 + </>,
1065 + );
1066 + pipe(writable);
1067 + });
1068 + expect(getMeaningfulChildren(document)).toEqual(
1069 + <html>
1070 + <head>
1071 + <meta charset="utf-8" />
1072 + <meta name="google-site-verification" content="somehash1" />
1073 + <meta name="google-site-verification" content="somehash2" />
1074 + <meta
1075 + name="description"
1076 + property="og:description"
1077 + content="my site"
1078 + />
1079 + <meta property="og:image" content="foo" />
1080 + <meta property="og:image:width" content="100" />
1081 + <meta http-equiv="refresh" content="dont actually" />
1082 + <meta property="og:image:height" content="100" />
1083 + <meta property="og:image" content="bar" />
1084 + <meta property="og:image:width" content="100" />
1085 + <meta itemprop="someprop" content="somevalue" />
1086 + <meta property="og:image:height" content="100" />
1087 + <meta property="og:description:foo" content="foo" />
1088 + </head>
1089 + <body>
1090 + <div>hello world</div>
1091 + </body>
1092 + </html>,
1093 + );
1094 +
1095 + const root = ReactDOMClient.hydrateRoot(
1096 + document,
1097 + <html>
1098 + <head>
1099 + <meta property="og:image" content="bar" />
1100 + <meta property="og:image:width" content="100" />
1101 + <meta property="og:image:height" content="100" />
1102 + </head>
1103 + <body>
1104 + <div>hello world</div>
1105 + </body>
1106 + </html>,
1107 + );
1108 + expect(Scheduler).toFlushWithoutYielding();
1109 + expect(getMeaningfulChildren(document)).toEqual(
1110 + <html>
1111 + <head>
1112 + <meta charset="utf-8" />
1113 + <meta name="google-site-verification" content="somehash1" />
1114 + <meta name="google-site-verification" content="somehash2" />
1115 + <meta
1116 + name="description"
1117 + property="og:description"
1118 + content="my site"
1119 + />
1120 + <meta property="og:image" content="foo" />
1121 + <meta property="og:image:width" content="100" />
1122 + <meta http-equiv="refresh" content="dont actually" />
1123 + <meta property="og:image:height" content="100" />
1124 + <meta property="og:image" content="bar" />
1125 + <meta property="og:image:width" content="100" />
1126 + <meta itemprop="someprop" content="somevalue" />
1127 + <meta property="og:image:height" content="100" />
1128 + <meta property="og:description:foo" content="foo" />
1129 + </head>
1130 + <body>
1131 + <div>hello world</div>
1132 + </body>
1133 + </html>,
1134 + );
1135 +
1136 + root.render(
1137 + <html>
1138 + <head>
1139 + <meta property="og:image" content="bar" />
1140 + <meta property="og:image:width" content="100" />
1141 + <meta property="og:image:height" content="100" />
1142 + <meta property="og:description" content="my site" />
1143 + <meta
1144 + itemProp="description bar"
1145 + property="og:description:bar"
1146 + content="bar"
1147 + />
1148 + </head>
1149 + <body>
1150 + <div>hello world</div>
1151 + </body>
1152 + </html>,
1153 + );
1154 + expect(Scheduler).toFlushWithoutYielding();
1155 + expect(getMeaningfulChildren(document)).toEqual(
1156 + <html>
1157 + <head>
1158 + <meta charset="utf-8" />
1159 + <meta name="google-site-verification" content="somehash1" />
1160 + <meta name="google-site-verification" content="somehash2" />
1161 + <meta
1162 + name="description"
1163 + property="og:description"
1164 + content="my site"
1165 + />
1166 + <meta
1167 + itemprop="description bar"
1168 + property="og:description:bar"
1169 + content="bar"
1170 + />
1171 + <meta property="og:image" content="foo" />
1172 + <meta property="og:image:width" content="100" />
1173 + <meta http-equiv="refresh" content="dont actually" />
1174 + <meta property="og:image:height" content="100" />
1175 + <meta property="og:image" content="bar" />
1176 + <meta property="og:image:width" content="100" />
1177 + <meta itemprop="someprop" content="somevalue" />
1178 + <meta property="og:image:height" content="100" />
1179 + <meta property="og:description:foo" content="foo" />
1180 + </head>
1181 + <body>
1182 + <div>hello world</div>
1183 + </body>
1184 + </html>,
1185 + );
1186 + });
1187 +
1188 + // @gate enableFloat
1189 + it('can render meta tags with og properties with structured data', async () => {
1190 + await actIntoEmptyDocument(() => {
1191 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
1192 + <>
1193 + <html>
1194 + <head />
1195 + <body>
1196 + <div>hello world</div>
1197 + </body>
1198 + </html>
1199 + <meta property="og:image" content="foo" />
1200 + <meta property="og:image:width" content="100" />
1201 + <meta property="og:image:height" content="100" />
1202 + <meta property="og:image" content="bar" />
1203 + <meta property="og:image:width" content="100" />
1204 + <meta property="og:image:height" content="100" />
1205 + </>,
1206 + );
1207 + pipe(writable);
1208 + });
1209 + expect(getMeaningfulChildren(document)).toEqual(
1210 + <html>
1211 + <head>
1212 + <meta property="og:image" content="foo" />
1213 + <meta property="og:image:width" content="100" />
1214 + <meta property="og:image:height" content="100" />
1215 + <meta property="og:image" content="bar" />
1216 + <meta property="og:image:width" content="100" />
1217 + <meta property="og:image:height" content="100" />
1218 + </head>
1219 + <body>
1220 + <div>hello world</div>
1221 + </body>
1222 + </html>,
1223 + );
1224 +
1225 + const root = ReactDOMClient.hydrateRoot(
1226 + document,
1227 + <html>
1228 + <head />
1229 + <body>
1230 + <meta property="og:image" content="foo" />
1231 + <meta property="og:image:width" content="100" />
1232 + <meta property="og:image:height" content="100" />
1233 + <meta property="og:image" content="bar" />
1234 + <meta property="og:image:width" content="100" />
1235 + <meta property="og:image:height" content="100" />
1236 + <div>hello world</div>
1237 + </body>
1238 + </html>,
1239 + );
1240 + expect(Scheduler).toFlushWithoutYielding();
1241 + expect(getMeaningfulChildren(document)).toEqual(
1242 + <html>
1243 + <head>
1244 + <meta property="og:image" content="foo" />
1245 + <meta property="og:image:width" content="100" />
1246 + <meta property="og:image:height" content="100" />
1247 + <meta property="og:image" content="bar" />
1248 + <meta property="og:image:width" content="100" />
1249 + <meta property="og:image:height" content="100" />
1250 + </head>
1251 + <body>
1252 + <div>hello world</div>
1253 + </body>
1254 + </html>,
1255 + );
1256 +
1257 + root.render(
1258 + <html>
1259 + <head />
1260 + <body>
1261 + <meta property="og:image" content="foo" />
1262 + <meta property="og:image:width" content="100" />
1263 + <meta property="og:image:height" content="100" />
1264 + <meta property="og:image" content="bar" />
1265 + <meta property="og:image:height" content="100" />
1266 + <div>hello world</div>
1267 + </body>
1268 + </html>,
1269 + );
1270 + expect(Scheduler).toFlushWithoutYielding();
1271 + expect(getMeaningfulChildren(document)).toEqual(
1272 + <html>
1273 + <head>
1274 + <meta property="og:image" content="foo" />
1275 + <meta property="og:image:width" content="100" />
1276 + <meta property="og:image:height" content="100" />
1277 + <meta property="og:image" content="bar" />
1278 + <meta property="og:image:height" content="100" />
1279 + </head>
1280 + <body>
1281 + <div>hello world</div>
1282 + </body>
1283 + </html>,
1284 + );
1285 +
1286 + root.render(
1287 + <html>
1288 + <head />
1289 + <body>
1290 + <meta property="og:image" content="foo" />
1291 + <meta property="og:image:width" content="100" />
1292 + <meta property="og:image:height" content="100" />
1293 + <meta property="og:image:foo" content="foo" />
1294 + <meta property="og:image" content="bar" />
1295 + <meta property="og:image:height" content="100" />
1296 + <div>hello world</div>
1297 + </body>
1298 + </html>,
1299 + );
1300 + expect(Scheduler).toFlushWithoutYielding();
1301 + expect(getMeaningfulChildren(document)).toEqual(
1302 + <html>
1303 + <head>
1304 + <meta property="og:image" content="foo" />
1305 + <meta property="og:image:foo" content="foo" />
1306 + <meta property="og:image:width" content="100" />
1307 + <meta property="og:image:height" content="100" />
1308 + <meta property="og:image" content="bar" />
1309 + <meta property="og:image:height" content="100" />
1310 + </head>
1311 + <body>
1312 + <div>hello world</div>
1313 + </body>
1314 + </html>,
1315 + );
1316 +
1317 + root.render(
1318 + <html>
1319 + <head />
1320 + <body>
1321 + <meta property="og:image" content="foo" />
1322 + <meta property="og:image:width" content="100" />
1323 + <meta property="og:image:width:bar" content="bar" />
1324 + <meta property="og:image:height" content="100" />
1325 + <meta property="og:image:foo" content="foo" />
1326 + <meta property="og:image" content="bar" />
1327 + <meta property="og:image:height" content="100" />
1328 + <div>hello world</div>
1329 + </body>
1330 + </html>,
1331 + );
1332 + expect(Scheduler).toFlushWithoutYielding();
1333 + expect(getMeaningfulChildren(document)).toEqual(
1334 + <html>
1335 + <head>
1336 + <meta property="og:image" content="foo" />
1337 + <meta property="og:image:foo" content="foo" />
1338 + <meta property="og:image:height" content="100" />
1339 + <meta property="og:image:width" content="100" />
1340 + <meta property="og:image:width:bar" content="bar" />
1341 + <meta property="og:image" content="bar" />
1342 + <meta property="og:image:height" content="100" />
1343 + </head>
1344 + <body>
1345 + <div>hello world</div>
1346 + </body>
1347 + </html>,
1348 + );
1349 + });
1350 +
1351 + // @gate enableFloat
1352 + it('can render meta tags as resources', async () => {
1353 + await actIntoEmptyDocument(() => {
1354 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
1355 + <>
1356 + <html>
1357 + <head />
1358 + <body>
1359 + <div>hello world</div>
1360 + </body>
1361 + </html>
1362 + <meta name="robots" content="noindex" />
1363 + <meta httpEquiv="content-security-policy" content="foo" />
1364 + <meta itemProp="description" content="desc" />
1365 + <meta property="description" content="desc2" />
1366 + <meta charSet="utf-8" />
1367 + </>,
1368 + );
1369 + pipe(writable);
1370 + });
1371 + expect(getMeaningfulChildren(document)).toEqual(
1372 + <html>
1373 + <head>
1374 + <meta charset="utf-8" />
1375 + <meta name="robots" content="noindex" />
1376 + <meta http-equiv="content-security-policy" content="foo" />
1377 + <meta itemprop="description" content="desc" />
1378 + <meta property="description" content="desc2" />
1379 + </head>
1380 + <body>
1381 + <div>hello world</div>
1382 + </body>
1383 + </html>,
1384 + );
1385 +
1386 + ReactDOMClient.hydrateRoot(
1387 + document,
1388 + <html>
1389 + <head>
1390 + <meta charSet="utf-8" />
1391 + </head>
1392 + <body>
1393 + <meta name="robots" content="noindex" />
1394 + <meta httpEquiv="content-security-policy" content="foo" />
1395 + <div>hello world</div>
1396 + </body>
1397 + </html>,
1398 + );
1399 + expect(Scheduler).toFlushWithoutYielding();
1400 + expect(getMeaningfulChildren(document)).toEqual(
1401 + <html>
1402 + <head>
1403 + <meta charset="utf-8" />
1404 + <meta name="robots" content="noindex" />
1405 + <meta http-equiv="content-security-policy" content="foo" />
1406 + <meta itemprop="description" content="desc" />
1407 + <meta property="description" content="desc2" />
1408 + </head>
1409 + <body>
1410 + <div>hello world</div>
1411 + </body>
1412 + </html>,
1413 + );
1414 + });
1415 +
1416 // @gate enableFloat
1417 it('can rendering title tags anywhere in the tree', async () => {
1418 await actIntoEmptyDocument(() => {
packages/react-dom/src/__tests__/ReactDOMServerIntegrationAttributes-test.js
+3 -3
@@ -497,9 +497,9 @@ describe('ReactDOMServerIntegration', () => {
497 itRenders(
498 'badly cased aliased HTML attribute with a warning',
499 async render => {
500 - const e = await render(<meta httpequiv="refresh" />, 1);
501 - expect(e.hasAttribute('http-equiv')).toBe(false);
502 - expect(e.getAttribute('httpequiv')).toBe('refresh');
500 + const e = await render(<form acceptcharset="utf-8" />, 1);
501 + expect(e.hasAttribute('accept-charset')).toBe(false);
502 + expect(e.getAttribute('acceptcharset')).toBe('utf-8');
503 },
504 );
505
packages/react-dom/src/__tests__/ReactDOMSingletonComponents-test.js
+7 -8
@@ -287,10 +287,10 @@ describe('ReactDOM HostSingleton', () => {
287 expect(getVisibleChildren(document)).toEqual(
288 <html data-client-foo="foo">
289 <head>
290 - <title>a client title</title>
290 <link rel="stylesheet" href="resource" />
291 <link rel="stylesheet" href="3rdparty" />
292 <link rel="stylesheet" href="3rdparty2" />
293 + <title>a client title</title>
294 </head>
295 <body data-client-baz="baz">
296 <style>
@@ -326,11 +326,10 @@ describe('ReactDOM HostSingleton', () => {
326 expect(getVisibleChildren(document)).toEqual(
327 <html data-client-foo="foo">
328 <head>
329 - <title>a client title</title>
329 <link rel="stylesheet" href="resource" />
330 <link rel="stylesheet" href="3rdparty" />
331 <link rel="stylesheet" href="3rdparty2" />
333 - <meta />
332 + <title>a client title</title>
333 </head>
334 <body data-client-baz="baz">
335 <style>
@@ -365,10 +364,10 @@ describe('ReactDOM HostSingleton', () => {
364 expect(getVisibleChildren(document)).toEqual(
365 <html data-client-foo="foo">
366 <head>
368 - <title>a client title</title>
367 <link rel="stylesheet" href="resource" />
368 <link rel="stylesheet" href="3rdparty" />
369 <link rel="stylesheet" href="3rdparty2" />
370 + <title>a client title</title>
371 </head>
372 <body data-client-baz="baz">
373 <style>
@@ -401,10 +400,10 @@ describe('ReactDOM HostSingleton', () => {
400 expect(getVisibleChildren(document)).toEqual(
401 <html data-client-foo="foo">
402 <head>
404 - <title>a client title</title>
403 <link rel="stylesheet" href="resource" />
404 <link rel="stylesheet" href="3rdparty" />
405 <link rel="stylesheet" href="3rdparty2" />
406 + <title>a client title</title>
407 </head>
408 <body>
409 <style>
@@ -498,10 +497,10 @@ describe('ReactDOM HostSingleton', () => {
497 expect(getVisibleChildren(document)).toEqual(
498 <html data-client-foo="foo">
499 <head>
501 - <title>a client title</title>
500 <link rel="stylesheet" href="resource" />
501 <link rel="stylesheet" href="3rdparty" />
502 <link rel="stylesheet" href="3rdparty2" />
503 + <title>a client title</title>
504 </head>
505 <body data-client-baz="baz">
506 <style>
@@ -764,9 +763,9 @@ describe('ReactDOM HostSingleton', () => {
763 expect(getVisibleChildren(document)).toEqual(
764 <html>
765 <head>
767 - <title>something new</title>
766 <link rel="stylesheet" href="headbefore" />
767 <link rel="stylesheet" href="headafter" />
768 + <title>something new</title>
769 </head>
770 <body>
771 <link rel="stylesheet" href="bodybefore" />
@@ -800,9 +799,9 @@ describe('ReactDOM HostSingleton', () => {
799 expect(getVisibleChildren(document)).toEqual(
800 <html>
801 <head>
803 - <title>something new</title>
802 <link rel="stylesheet" href="before" />
803 <link rel="stylesheet" href="after" />
804 + <title>something new</title>
805 </head>
806 <body />
807 </html>,
packages/react-reconciler/src/ReactFiberCommitWork.new.js
+3 -1
@@ -2057,7 +2057,9 @@ function commitDeletionEffectsOnFiber(
2057 nearestMountedAncestor,
2058 deletedFiber,
2059 );
2060 - releaseResource(deletedFiber.memoizedState);
2060 + if (deletedFiber.memoizedState) {
2061 + releaseResource(deletedFiber.memoizedState);
2062 + }
2063 return;
2064 }
2065 }
packages/react-reconciler/src/ReactFiberCommitWork.old.js
+3 -1
@@ -2057,7 +2057,9 @@ function commitDeletionEffectsOnFiber(
2057 nearestMountedAncestor,
2058 deletedFiber,
2059 );
2060 - releaseResource(deletedFiber.memoizedState);
2060 + if (deletedFiber.memoizedState) {
2061 + releaseResource(deletedFiber.memoizedState);
2062 + }
2063 return;
2064 }
2065 }
scripts/error-codes/codes.json
+2 -1
@@ -441,5 +441,6 @@
441 "453": "React expected a <head> element (document.head) to exist in the Document but one was not found. React never removes the head for any Document it renders into so the cause is likely in some other script running on this page.",
442 "454": "React expected a <body> element (document.body) to exist in the Document but one was not found. React never removes the body for any Document it renders into so the cause is likely in some other script running on this page.",
443 "455": "This CacheSignal was requested outside React which means that it is immediately aborted.",
444 - "456": "Calling Offscreen.detach before instance handle has been set."
444 + "456": "Calling Offscreen.detach before instance handle has been set.",
445 + "457": "acquireHeadResource encountered a resource type it did not expect: \"%s\". This is a bug in React."
446 }