Skip to content

Commit 973b90b

Browse files
authored
[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.
1 parent 79c5829 commit 973b90b

11 files changed

+823
-159
lines changed

packages/react-dom-bindings/src/client/ReactDOMComponentTree.js

+1
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ export function getResourcesFromRoot(root: FloatRoot): RootResources {
286286
styles: new Map(),
287287
scripts: new Map(),
288288
head: new Map(),
289+
lastStructuredMeta: new Map(),
289290
};
290291
}
291292
return resources;

packages/react-dom-bindings/src/client/ReactDOMFloatClient.js

+187-88
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,29 @@ export type ScriptResource = {
8888
root: FloatRoot,
8989
};
9090

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 = {
92106
[string]: mixed,
93107
};
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,
98114

99115
count: number,
100116
instance: ?Element,
@@ -109,6 +125,7 @@ export type RootResources = {
109125
styles: Map<string, StyleResource>,
110126
scripts: Map<string, ScriptResource>,
111127
head: Map<string, HeadResource>,
128+
lastStructuredMeta: Map<string, MetaResource>,
112129
};
113130

114131
// Brief on purpose due to insertion by script when streaming late boundaries
@@ -409,6 +426,84 @@ export function getResource(
409426
);
410427
}
411428
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+
}
412507
case 'title': {
413508
let child = pendingProps.children;
414509
if (Array.isArray(child) && child.length === 1) {
@@ -421,13 +516,14 @@ export function getResource(
421516
let resource = headResources.get(key);
422517
if (!resource) {
423518
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);
431527
}
432528
return resource;
433529
}
@@ -588,8 +684,8 @@ function preloadPropsFromRawProps(
588684
function titlePropsFromRawProps(
589685
child: string | number,
590686
rawProps: Props,
591-
): HeadProps {
592-
const props: HeadProps = Object.assign({}, rawProps);
687+
): TitleProps {
688+
const props: TitleProps = Object.assign({}, rawProps);
593689
props.children = child;
594690
return props;
595691
}
@@ -613,7 +709,8 @@ function scriptPropsFromRawProps(rawProps: ScriptQualifyingProps): ScriptProps {
613709

614710
export function acquireResource(resource: Resource): Instance {
615711
switch (resource.type) {
616-
case 'head': {
712+
case 'title':
713+
case 'meta': {
617714
return acquireHeadResource(resource);
618715
}
619716
case 'style': {
@@ -635,7 +732,8 @@ export function acquireResource(resource: Resource): Instance {
635732

636733
export function releaseResource(resource: Resource): void {
637734
switch (resource.type) {
638-
case 'head': {
735+
case 'title':
736+
case 'meta': {
639737
return releaseHeadResource(resource);
640738
}
641739
case 'style': {
@@ -668,35 +766,6 @@ function createResourceInstance(
668766
return element;
669767
}
670768

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-
700769
function createStyleResource(
701770
styleResources: Map<string, StyleResource>,
702771
root: FloatRoot,
@@ -894,7 +963,7 @@ function createPreloadResource(
894963
);
895964
if (!element) {
896965
element = createResourceInstance('link', props, ownerDocument);
897-
appendResourceInstance(element, ownerDocument);
966+
insertResourceInstanceBefore(ownerDocument, element, null);
898967
} else {
899968
markNodeAsResource(element);
900969
}
@@ -911,8 +980,8 @@ function acquireHeadResource(resource: HeadResource): Instance {
911980
resource.count++;
912981
let instance = resource.instance;
913982
if (!instance) {
914-
const {props, root, instanceType} = resource;
915-
switch (instanceType) {
983+
const {props, root, type} = resource;
984+
switch (type) {
916985
case 'title': {
917986
const titles = root.querySelectorAll('title');
918987
for (let i = 0; i < titles.length; i++) {
@@ -922,18 +991,70 @@ function acquireHeadResource(resource: HeadResource): Instance {
922991
return instance;
923992
}
924993
}
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+
);
9251057
}
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);
9371058
}
9381059
}
9391060
return instance;
@@ -1010,7 +1131,7 @@ function acquireScriptResource(resource: ScriptResource): Instance {
10101131
getDocumentFromRoot(root),
10111132
);
10121133

1013-
appendResourceInstance(instance, getDocumentFromRoot(root));
1134+
insertResourceInstanceBefore(getDocumentFromRoot(root), instance, null);
10141135
}
10151136
}
10161137
return instance;
@@ -1113,45 +1234,22 @@ function insertStyleInstance(
11131234
}
11141235
}
11151236

1116-
function prependResourceInstance(
1117-
instance: Instance,
1237+
function insertResourceInstanceBefore(
11181238
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(
11411239
instance: Instance,
1142-
ownerDocument: Document,
1240+
before: ?Node,
11431241
): void {
11441242
if (__DEV__) {
11451243
if (instance.tagName === 'LINK' && (instance: any).rel === 'stylesheet') {
11461244
console.error(
1147-
'appendResourceInstance was called with a stylesheet. Stylesheets must be' +
1245+
'insertResourceInstanceBefore was called with a stylesheet. Stylesheets must be' +
11481246
' inserted with insertStyleInstance instead. This is a bug in React.',
11491247
);
11501248
}
11511249
}
1152-
const parent = ownerDocument.head;
1250+
const parent = (before && before.parentNode) || ownerDocument.head;
11531251
if (parent) {
1154-
parent.appendChild(instance);
1252+
parent.insertBefore(instance, before);
11551253
} else {
11561254
throw new Error(
11571255
'While attempting to insert a Resource, React expected the Document to contain' +
@@ -1162,6 +1260,7 @@ function appendResourceInstance(
11621260

11631261
export function isHostResourceType(type: string, props: Props): boolean {
11641262
switch (type) {
1263+
case 'meta':
11651264
case 'title': {
11661265
return true;
11671266
}

0 commit comments

Comments
 (0)