@samitouri / QOS-React / commits / 1f7a2f577b

[Float] support title tags as Resources (#25508)

Adds a category of Resources of type `head` which will be used to track the tags that go into the <head> Currently only implements for `<title>`. titles are keyed off their textContent so each time the title changes a new resource will be created. Currently insertion is done by prepending in the <head>. The argument here is that the newest title should "win" if there are multiple rendered. This also helps when a navigation or update causes a server rendered title to hang around but it is not the most recent one.

Josh Story committed Oct 20, 2022 at 23:13 UTC 1f7a2f577bc9e0264256d834d3daffbd7d6618f9
12 files changed +801 -112
packages/react-dom-bindings/src/client/ReactDOMComponentTree.js
+12 -8
@@ -7,11 +7,7 @@
7 * @flow
8 */
9
10 -import type {
11 - FloatRoot,
12 - StyleResource,
13 - ScriptResource,
14 -} from './ReactDOMFloatClient';
10 +import type {FloatRoot, RootResources} from './ReactDOMFloatClient';
11 import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
12 import type {ReactScopeInstance} from 'shared/ReactTypes';
13 import type {
@@ -53,6 +49,7 @@ const internalEventHandlersKey = '__reactEvents$' + randomKey;
49 const internalEventHandlerListenersKey = '__reactListeners$' + randomKey;
50 const internalEventHandlesSetKey = '__reactHandles$' + randomKey;
51 const internalRootNodeResourcesKey = '__reactResources$' + randomKey;
52 +const internalResourceMarker = '__reactMarker$' + randomKey;
53
54 export function detachDeletedInstance(node: Instance): void {
55 // TODO: This function is only called on host components. I don't think all of
@@ -282,15 +279,22 @@ export function doesTargetHaveEventHandle(
279 return eventHandles.has(eventHandle);
280 }
281
285 -export function getResourcesFromRoot(
286 - root: FloatRoot,
287 -): {styles: Map<string, StyleResource>, scripts: Map<string, ScriptResource>} {
282 +export function getResourcesFromRoot(root: FloatRoot): RootResources {
283 let resources = (root: any)[internalRootNodeResourcesKey];
284 if (!resources) {
285 resources = (root: any)[internalRootNodeResourcesKey] = {
286 styles: new Map(),
287 scripts: new Map(),
288 + head: new Map(),
289 };
290 }
291 return resources;
292 }
293 +
294 +export function isMarkedResource(node: Node): boolean {
295 + return !!(node: any)[internalResourceMarker];
296 +}
297 +
298 +export function markNodeAsResource(node: Node) {
299 + (node: any)[internalResourceMarker] = true;
300 +}
packages/react-dom-bindings/src/client/ReactDOMFloatClient.js
+190 -18
@@ -24,7 +24,10 @@ import {
24 validatePreinitArguments,
25 } from '../shared/ReactDOMResourceValidation';
26 import {createElement, setInitialProperties} from './ReactDOMComponent';
27 -import {getResourcesFromRoot} from './ReactDOMComponentTree';
27 +import {
28 + getResourcesFromRoot,
29 + markNodeAsResource,
30 +} from './ReactDOMComponentTree';
31 import {HTML_NAMESPACE} from '../shared/DOMNamespaces';
32 import {getCurrentRootHostContainer} from 'react-reconciler/src/ReactFiberHostContext';
33
@@ -85,9 +88,28 @@ export type ScriptResource = {
88 root: FloatRoot,
89 };
90
91 +type HeadProps = {
92 + [string]: mixed,
93 +};
94 +export type HeadResource = {
95 + type: 'head',
96 + instanceType: string,
97 + props: HeadProps,
98 +
99 + count: number,
100 + instance: ?Element,
101 + root: Document,
102 +};
103 +
104 type Props = {[string]: mixed};
105
90 -type Resource = StyleResource | ScriptResource | PreloadResource;
106 +type Resource = StyleResource | ScriptResource | PreloadResource | HeadResource;
107 +
108 +export type RootResources = {
109 + styles: Map<string, StyleResource>,
110 + scripts: Map<string, ScriptResource>,
111 + head: Map<string, HeadResource>,
112 +};
113
114 // Brief on purpose due to insertion by script when streaming late boundaries
115 // s = Status
@@ -370,6 +392,10 @@ type ScriptQualifyingProps = {
392 [string]: mixed,
393 };
394
395 +function getTitleKey(child: string | number): string {
396 + return 'title:' + child;
397 +}
398 +
399 // This function is called in begin work and we should always have a currentDocument set
400 export function getResource(
401 type: string,
@@ -383,6 +409,30 @@ export function getResource(
409 );
410 }
411 switch (type) {
412 + case 'title': {
413 + let child = pendingProps.children;
414 + if (Array.isArray(child) && child.length === 1) {
415 + child = child[0];
416 + }
417 + if (typeof child === 'string' || typeof child === 'number') {
418 + const headRoot: Document = getDocumentFromRoot(resourceRoot);
419 + const headResources = getResourcesFromRoot(headRoot).head;
420 + const key = getTitleKey(child);
421 + let resource = headResources.get(key);
422 + if (!resource) {
423 + const titleProps = titlePropsFromRawProps(child, pendingProps);
424 + resource = createHeadResource(
425 + headResources,
426 + headRoot,
427 + 'title',
428 + key,
429 + titleProps,
430 + );
431 + }
432 + return resource;
433 + }
434 + return null;
435 + }
436 case 'link': {
437 const {rel} = pendingProps;
438 switch (rel) {
@@ -535,6 +585,15 @@ function preloadPropsFromRawProps(
585 return Object.assign({}, rawBorrowedProps);
586 }
587
588 +function titlePropsFromRawProps(
589 + child: string | number,
590 + rawProps: Props,
591 +): HeadProps {
592 + const props: HeadProps = Object.assign({}, rawProps);
593 + props.children = child;
594 + return props;
595 +}
596 +
597 function stylePropsFromRawProps(rawProps: StyleQualifyingProps): StyleProps {
598 const props: StyleProps = Object.assign({}, rawProps);
599 props['data-precedence'] = rawProps.precedence;
@@ -554,6 +613,9 @@ function scriptPropsFromRawProps(rawProps: ScriptQualifyingProps): ScriptProps {
613
614 export function acquireResource(resource: Resource): Instance {
615 switch (resource.type) {
616 + case 'head': {
617 + return acquireHeadResource(resource);
618 + }
619 case 'style': {
620 return acquireStyleResource(resource);
621 }
@@ -571,11 +633,27 @@ export function acquireResource(resource: Resource): Instance {
633 }
634 }
635
574 -export function releaseResource(resource: Resource) {
636 +export function releaseResource(resource: Resource): void {
637 switch (resource.type) {
638 + case 'head': {
639 + return releaseHeadResource(resource);
640 + }
641 case 'style': {
642 resource.count--;
643 + return;
644 + }
645 + }
646 +}
647 +
648 +function releaseHeadResource(resource: HeadResource): void {
649 + if (--resource.count === 0) {
650 + // the instance will have existed since we acquired it
651 + const instance: Instance = (resource.instance: any);
652 + const parent = instance.parentNode;
653 + if (parent) {
654 + parent.removeChild(instance);
655 }
656 + resource.instance = null;
657 }
658 }
659
@@ -586,9 +664,39 @@ function createResourceInstance(
664 ): Instance {
665 const element = createElement(type, props, ownerDocument, HTML_NAMESPACE);
666 setInitialProperties(element, type, props);
667 + markNodeAsResource(element);
668 return element;
669 }
670
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 +
700 function createStyleResource(
701 styleResources: Map<string, StyleResource>,
702 root: FloatRoot,
@@ -754,6 +862,8 @@ function createScriptResource(
862 (resource: any)._dev_preload_props = preloadProps;
863 }
864 }
865 + } else {
866 + markNodeAsResource(existingEl);
867 }
868
869 return resource;
@@ -784,7 +894,9 @@ function createPreloadResource(
894 );
895 if (!element) {
896 element = createResourceInstance('link', props, ownerDocument);
787 - insertResourceInstance(element, ownerDocument);
897 + appendResourceInstance(element, ownerDocument);
898 + } else {
899 + markNodeAsResource(element);
900 }
901 return {
902 type: 'preload',
@@ -795,8 +907,41 @@ function createPreloadResource(
907 };
908 }
909
910 +function acquireHeadResource(resource: HeadResource): Instance {
911 + resource.count++;
912 + let instance = resource.instance;
913 + if (!instance) {
914 + const {props, root, instanceType} = resource;
915 + switch (instanceType) {
916 + case 'title': {
917 + const titles = root.querySelectorAll('title');
918 + for (let i = 0; i < titles.length; i++) {
919 + if (titles[i].textContent === props.children) {
920 + instance = resource.instance = titles[i];
921 + markNodeAsResource(instance);
922 + return instance;
923 + }
924 + }
925 + }
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);
937 + }
938 + }
939 + return instance;
940 +}
941 +
942 function acquireStyleResource(resource: StyleResource): Instance {
799 - if (!resource.instance) {
943 + let instance = resource.instance;
944 + if (!instance) {
945 const {props, root, precedence} = resource;
946 const limitedEscapedHref = escapeSelectorAttributeValueInsideDoubleQuotes(
947 props.href,
@@ -805,7 +950,8 @@ function acquireStyleResource(resource: StyleResource): Instance {
950 `link[rel="stylesheet"][data-precedence][href="${limitedEscapedHref}"]`,
951 );
952 if (existingEl) {
808 - resource.instance = existingEl;
953 + instance = resource.instance = existingEl;
954 + markNodeAsResource(instance);
955 resource.preloaded = true;
956 const loadingState: ?StyleResourceLoadingState = (existingEl: any)._p;
957 if (loadingState) {
@@ -830,7 +976,7 @@ function acquireStyleResource(resource: StyleResource): Instance {
976 resource.loaded = true;
977 }
978 } else {
833 - const instance = createResourceInstance(
979 + instance = resource.instance = createResourceInstance(
980 'link',
981 resource.props,
982 getDocumentFromRoot(root),
@@ -838,16 +984,15 @@ function acquireStyleResource(resource: StyleResource): Instance {
984
985 attachLoadListeners(instance, resource);
986 insertStyleInstance(instance, precedence, root);
841 - resource.instance = instance;
987 }
988 }
989 resource.count++;
845 - // $FlowFixMe[incompatible-return] found when upgrading Flow
846 - return resource.instance;
990 + return instance;
991 }
992
993 function acquireScriptResource(resource: ScriptResource): Instance {
850 - if (!resource.instance) {
994 + let instance = resource.instance;
995 + if (!instance) {
996 const {props, root} = resource;
997 const limitedEscapedSrc = escapeSelectorAttributeValueInsideDoubleQuotes(
998 props.src,
@@ -856,19 +1001,19 @@ function acquireScriptResource(resource: ScriptResource): Instance {
1001 `script[async][src="${limitedEscapedSrc}"]`,
1002 );
1003 if (existingEl) {
859 - resource.instance = existingEl;
1004 + instance = resource.instance = existingEl;
1005 + markNodeAsResource(instance);
1006 } else {
861 - const instance = createResourceInstance(
1007 + instance = resource.instance = createResourceInstance(
1008 'script',
1009 resource.props,
1010 getDocumentFromRoot(root),
1011 );
1012
867 - insertResourceInstance(instance, getDocumentFromRoot(root));
868 - resource.instance = instance;
1013 + appendResourceInstance(instance, getDocumentFromRoot(root));
1014 }
1015 }
871 - return resource.instance;
1016 + return instance;
1017 }
1018
1019 function attachLoadListeners(instance: Instance, resource: StyleResource) {
@@ -968,14 +1113,38 @@ function insertStyleInstance(
1113 }
1114 }
1115
971 -function insertResourceInstance(
1116 +function prependResourceInstance(
1117 instance: Instance,
1118 ownerDocument: Document,
1119 ): void {
1120 if (__DEV__) {
1121 if (instance.tagName === 'LINK' && (instance: any).rel === 'stylesheet') {
1122 console.error(
978 - 'insertResourceInstance was called with a stylesheet. Stylesheets must be' +
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(
1141 + instance: Instance,
1142 + ownerDocument: Document,
1143 +): void {
1144 + if (__DEV__) {
1145 + if (instance.tagName === 'LINK' && (instance: any).rel === 'stylesheet') {
1146 + console.error(
1147 + 'appendResourceInstance was called with a stylesheet. Stylesheets must be' +
1148 ' inserted with insertStyleInstance instead. This is a bug in React.',
1149 );
1150 }
@@ -993,6 +1162,9 @@ function insertResourceInstance(
1162
1163 export function isHostResourceType(type: string, props: Props): boolean {
1164 switch (type) {
1165 + case 'title': {
1166 + return true;
1167 + }
1168 case 'link': {
1169 switch (props.rel) {
1170 case 'stylesheet': {
packages/react-dom-bindings/src/client/ReactDOMHostConfig.js
+46 -40
@@ -25,6 +25,7 @@ import {
25 getInstanceFromNode as getInstanceFromNodeDOMTree,
26 isContainerMarkedAsRoot,
27 detachDeletedInstance,
28 + isMarkedResource,
29 } from './ReactDOMComponentTree';
30 export {detachDeletedInstance};
31 import {hasRole} from './DOMAccessibilityRoles';
@@ -58,6 +59,7 @@ import {
59 TEXT_NODE,
60 COMMENT_NODE,
61 DOCUMENT_NODE,
62 + DOCUMENT_TYPE_NODE,
63 DOCUMENT_FRAGMENT_NODE,
64 } from '../shared/HTMLNodeType';
65 import dangerousStyleValue from '../shared/dangerousStyleValue';
@@ -711,50 +713,15 @@ export function unhideTextInstance(
713
714 export function clearContainer(container: Container): void {
715 if (enableHostSingletons) {
714 - // We have refined the container to Element type
716 const nodeType = container.nodeType;
717 if (nodeType === DOCUMENT_NODE || nodeType === ELEMENT_NODE) {
718 switch (container.nodeName) {
719 case '#document':
720 case 'HTML':
721 case 'HEAD':
721 - case 'BODY': {
722 - let node = container.firstChild;
723 - while (node) {
724 - const nextNode = node.nextSibling;
725 - const nodeName = node.nodeName;
726 - switch (nodeName) {
727 - case 'HTML':
728 - case 'HEAD':
729 - case 'BODY': {
730 - clearContainer((node: any));
731 - // If these singleton instances had previously been rendered with React they
732 - // may still hold on to references to the previous fiber tree. We detatch them
733 - // prospectiveyl to reset them to a baseline starting state since we cannot create
734 - // new instances.
735 - detachDeletedInstance((node: any));
736 - break;
737 - }
738 - case 'STYLE': {
739 - break;
740 - }
741 - case 'LINK': {
742 - if (
743 - ((node: any): HTMLLinkElement).rel.toLowerCase() ===
744 - 'stylesheet'
745 - ) {
746 - break;
747 - }
748 - }
749 - // eslint-disable-next-line no-fallthrough
750 - default: {
751 - container.removeChild(node);
752 - }
753 - }
754 - node = nextNode;
755 - }
722 + case 'BODY':
723 + clearContainerChildren(container);
724 return;
757 - }
725 default: {
726 container.textContent = '';
727 }
@@ -775,6 +742,42 @@ export function clearContainer(container: Container): void {
742 }
743 }
744
745 +function clearContainerChildren(container: Node) {
746 + let node;
747 + let nextNode: ?Node = container.firstChild;
748 + if (nextNode && nextNode.nodeType === DOCUMENT_TYPE_NODE) {
749 + nextNode = nextNode.nextSibling;
750 + }
751 + while (nextNode) {
752 + node = nextNode;
753 + nextNode = nextNode.nextSibling;
754 + switch (node.nodeName) {
755 + case 'HTML':
756 + case 'HEAD':
757 + case 'BODY': {
758 + const element: Element = (node: any);
759 + clearContainerChildren(element);
760 + // If these singleton instances had previously been rendered with React they
761 + // may still hold on to references to the previous fiber tree. We detatch them
762 + // prospectively to reset them to a baseline starting state since we cannot create
763 + // new instances.
764 + detachDeletedInstance(element);
765 + continue;
766 + }
767 + case 'STYLE': {
768 + continue;
769 + }
770 + case 'LINK': {
771 + if (((node: any): HTMLLinkElement).rel.toLowerCase() === 'stylesheet') {
772 + continue;
773 + }
774 + }
775 + }
776 + container.removeChild(node);
777 + }
778 + return;
779 +}
780 +
781 // Making this so we can eventually move all of the instance caching to the commit phase.
782 // Currently this is only used to associate fiber and props to instances for hydrating
783 // HostSingletons. The reason we need it here is we only want to make this binding on commit
@@ -923,6 +926,7 @@ function getNextHydratable(node) {
926 }
927 break;
928 }
929 + case 'TITLE':
930 case 'HTML':
931 case 'HEAD':
932 case 'BODY': {
@@ -948,6 +952,9 @@ function getNextHydratable(node) {
952 }
953 break;
954 }
955 + case 'TITLE': {
956 + continue;
957 + }
958 case 'STYLE': {
959 const styleEl: HTMLStyleElement = (element: any);
960 if (styleEl.hasAttribute('data-precedence')) {
@@ -1666,9 +1673,8 @@ export function clearSingleton(instance: Instance): void {
1673 while (node) {
1674 const nextNode = node.nextSibling;
1675 const nodeName = node.nodeName;
1669 - if (getInstanceFromNodeDOMTree(node)) {
1670 - // retain nodes owned by React
1671 - } else if (
1676 + if (
1677 + isMarkedResource(node) ||
1678 nodeName === 'HEAD' ||
1679 nodeName === 'BODY' ||
1680 nodeName === 'STYLE' ||
packages/react-dom-bindings/src/server/ReactDOMFloatServer.js
+86
@@ -68,6 +68,18 @@ type ScriptResource = {
68 hint: PreloadResource,
69 };
70
71 +type HeadProps = {
72 + [string]: mixed,
73 +};
74 +type HeadResource = {
75 + type: 'head',
76 + instanceType: string,
77 + props: HeadProps,
78 +
79 + flushed: boolean,
80 + allowLate: boolean,
81 +};
82 +
83 export type Resource = PreloadResource | StyleResource | ScriptResource;
84
85 export type Resources = {
@@ -75,8 +87,10 @@ export type Resources = {
87 preloadsMap: Map<string, PreloadResource>,
88 stylesMap: Map<string, StyleResource>,
89 scriptsMap: Map<string, ScriptResource>,
90 + headsMap: Map<string, HeadResource>,
91
92 // Flushing queues for Resource dependencies
93 + charset: null | HeadResource,
94 fontPreloads: Set<PreloadResource>,
95 // usedImagePreloads: Set<PreloadResource>,
96 precedences: Map<string, Set<StyleResource>>,
@@ -86,6 +100,7 @@ export type Resources = {
100 explicitStylePreloads: Set<PreloadResource>,
101 // explicitImagePreloads: Set<PreloadResource>,
102 explicitScriptPreloads: Set<PreloadResource>,
103 + headResources: Set<HeadResource>,
104
105 // Module-global-like reference for current boundary resources
106 boundaryResources: ?BoundaryResources,
@@ -99,8 +114,10 @@ export function createResources(): Resources {
114 preloadsMap: new Map(),
115 stylesMap: new Map(),
116 scriptsMap: new Map(),
117 + headsMap: new Map(),
118
119 // cleared on flush
120 + charset: null,
121 fontPreloads: new Set(),
122 // usedImagePreloads: new Set(),
123 precedences: new Map(),
@@ -110,6 +127,7 @@ export function createResources(): Resources {
127 explicitStylePreloads: new Set(),
128 // explicitImagePreloads: new Set(),
129 explicitScriptPreloads: new Set(),
130 + headResources: new Set(),
131
132 // like a module global for currently rendering boundary
133 boundaryResources: null,
@@ -563,6 +581,74 @@ function adoptPreloadPropsForScriptProps(
581 resourceProps.integrity = preloadProps.integrity;
582 }
583
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 +
614 +function titlePropsFromRawProps(
615 + child: string | number,
616 + rawProps: Props,
617 +): HeadProps {
618 + const props = Object.assign({}, rawProps);
619 + props.children = child;
620 + return props;
621 +}
622 +
623 +export function resourcesFromElement(type: string, props: Props): boolean {
624 + if (!currentResources) {
625 + throw new Error(
626 + '"currentResources" was expected to exist. This is a bug in React.',
627 + );
628 + }
629 + const resources = currentResources;
630 + switch (type) {
631 + case 'title': {
632 + let child = props.children;
633 + if (Array.isArray(child) && child.length === 1) {
634 + child = child[0];
635 + }
636 + if (typeof child === 'string' || typeof child === 'number') {
637 + const key = getTitleKey(child);
638 + let resource = resources.headsMap.get(key);
639 + if (!resource) {
640 + const titleProps = titlePropsFromRawProps(child, props);
641 + resource = createHeadResource(resources, key, 'title', titleProps);
642 + resources.headResources.add(resource);
643 + }
644 + return true;
645 + }
646 + return false;
647 + }
648 + }
649 + return false;
650 +}
651 +
652 // Construct a resource from link props.
653 export function resourcesFromLink(props: Props): boolean {
654 if (!currentResources) {
packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js
+41
@@ -64,6 +64,7 @@ import isArray from 'shared/isArray';
64 import {
65 prepareToRenderResources,
66 finishRenderingResources,
67 + resourcesFromElement,
68 resourcesFromLink,
69 resourcesFromScript,
70 ReactDOMServerDispatcher,
@@ -1277,6 +1278,20 @@ function pushStartTitle(
1278 target: Array<Chunk | PrecomputedChunk>,
1279 props: Object,
1280 responseState: ResponseState,
1281 +): ReactNodeList {
1282 + if (enableFloat && resourcesFromElement('title', props)) {
1283 + // We have converted this link exclusively to a resource and no longer
1284 + // need to emit it
1285 + return null;
1286 + }
1287 +
1288 + return pushStartTitleImpl(target, props, responseState);
1289 +}
1290 +
1291 +function pushStartTitleImpl(
1292 + target: Array<Chunk | PrecomputedChunk>,
1293 + props: Object,
1294 + responseState: ResponseState,
1295 ): ReactNodeList {
1296 target.push(startChunkForTag('title'));
1297
@@ -2310,6 +2325,7 @@ export function writeInitialResources(
2325 usedScriptPreloads,
2326 explicitStylePreloads,
2327 explicitScriptPreloads,
2328 + headResources,
2329 } = resources;
2330
2331 fontPreloads.forEach(r => {
@@ -2360,6 +2376,18 @@ export function writeInitialResources(
2376 explicitScriptPreloads.forEach(flushLinkResource);
2377 explicitScriptPreloads.clear();
2378
2379 + 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)));
2384 + }
2385 + pushEndInstance(target, target, 'title', r.props);
2386 + }
2387 + r.flushed = true;
2388 + });
2389 + headResources.clear();
2390 +
2391 let i;
2392 let r = true;
2393 for (i = 0; i < target.length - 1; i++) {
@@ -2392,6 +2420,7 @@ export function writeImmediateResources(
2420 usedScriptPreloads,
2421 explicitStylePreloads,
2422 explicitScriptPreloads,
2423 + headResources,
2424 } = resources;
2425
2426 fontPreloads.forEach(r => {
@@ -2422,6 +2451,18 @@ export function writeImmediateResources(
2451 explicitScriptPreloads.forEach(flushLinkResource);
2452 explicitScriptPreloads.clear();
2453
2454 + 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)));
2459 + }
2460 + pushEndInstance(target, target, 'title', r.props);
2461 + }
2462 + r.flushed = true;
2463 + });
2464 + headResources.clear();
2465 +
2466 let i;
2467 let r = true;
2468 for (i = 0; i < target.length - 1; i++) {
packages/react-dom-bindings/src/shared/HTMLNodeType.js
+1
@@ -15,4 +15,5 @@ export const ELEMENT_NODE = 1;
15 export const TEXT_NODE = 3;
16 export const COMMENT_NODE = 8;
17 export const DOCUMENT_NODE = 9;
18 +export const DOCUMENT_TYPE_NODE = 10;
19 export const DOCUMENT_FRAGMENT_NODE = 11;
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+21 -12
@@ -4968,18 +4968,27 @@ describe('ReactDOMFizzServer', () => {
4968 },
4969 });
4970 expect(Scheduler).toFlushAndYield([]);
4971 - expect(errors).toEqual(
4972 - [
4973 - gate(flags => flags.enableClientRenderFallbackOnTextMismatch)
4974 - ? 'Text content does not match server-rendered HTML.'
4975 - : null,
4976 - 'Hydration failed because the initial UI does not match what was rendered on the server.',
4977 - 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
4978 - ].filter(Boolean),
4979 - );
4980 - expect(getVisibleChildren(container)).toEqual(
4981 - <title>{['hello1', 'hello2']}</title>,
4982 - );
4971 + if (gate(flags => flags.enableFloat)) {
4972 + expect(errors).toEqual([]);
4973 + // with float, the title doesn't render on the client because it is not a simple child
4974 + // we end up seeing the server rendered title
4975 + expect(getVisibleChildren(container)).toEqual(
4976 + <title>{'hello1<!-- -->hello2'}</title>,
4977 + );
4978 + } else {
4979 + expect(errors).toEqual(
4980 + [
4981 + gate(flags => flags.enableClientRenderFallbackOnTextMismatch)
4982 + ? 'Text content does not match server-rendered HTML.'
4983 + : null,
4984 + 'Hydration failed because the initial UI does not match what was rendered on the server.',
4985 + 'There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.',
4986 + ].filter(Boolean),
4987 + );
4988 + expect(getVisibleChildren(container)).toEqual(
4989 + <title>{['hello1', 'hello2']}</title>,
4990 + );
4991 + }
4992 } finally {
4993 console.error = originalConsoleError;
4994 }
packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+330
@@ -235,6 +235,80 @@ describe('ReactDOMFloat', () => {
235 return readText(text);
236 }
237
238 + // @gate enableFloat
239 + it('can render resources before singletons', async () => {
240 + const root = ReactDOMClient.createRoot(document);
241 + root.render(
242 + <>
243 + <title>foo</title>
244 + <html>
245 + <head>
246 + <link rel="foo" href="foo" />
247 + </head>
248 + <body>hello world</body>
249 + </html>
250 + </>,
251 + );
252 + try {
253 + expect(Scheduler).toFlushWithoutYielding();
254 + } catch (e) {
255 + // for DOMExceptions that happen when expecting this test to fail we need
256 + // to clear the scheduler first otherwise the expected failure will fail
257 + expect(Scheduler).toFlushWithoutYielding();
258 + throw e;
259 + }
260 + expect(getMeaningfulChildren(document)).toEqual(
261 + <html>
262 + <head>
263 + <title>foo</title>
264 + <link rel="foo" href="foo" />
265 + </head>
266 + <body>hello world</body>
267 + </html>,
268 + );
269 + });
270 +
271 + // @gate enableFloat
272 + it('can acquire a resource after releasing it in the same commit', async () => {
273 + const root = ReactDOMClient.createRoot(container);
274 + root.render(
275 + <>
276 + <title>foo</title>
277 + </>,
278 + );
279 + expect(Scheduler).toFlushWithoutYielding();
280 + expect(getMeaningfulChildren(document)).toEqual(
281 + <html>
282 + <head>
283 + <title>foo</title>
284 + </head>
285 + <body>
286 + <div id="container" />
287 + </body>
288 + </html>,
289 + );
290 +
291 + // title is keyed off children so this second resource should match the first one
292 + root.render(
293 + <>
294 + {null}
295 + <title data-new="new">foo</title>
296 + </>,
297 + );
298 + expect(Scheduler).toFlushWithoutYielding();
299 + // we don't see the attribute because the resource is the same and was not reconstructed
300 + expect(getMeaningfulChildren(document)).toEqual(
301 + <html>
302 + <head>
303 + <title>foo</title>
304 + </head>
305 + <body>
306 + <div id="container" />
307 + </body>
308 + </html>,
309 + );
310 + });
311 +
312 // @gate enableFloat
313 it('errors if the document does not contain a head when inserting a resource', async () => {
314 document.head.parentNode.removeChild(document.head);
@@ -866,6 +940,261 @@ describe('ReactDOMFloat', () => {
940 });
941 });
942
943 + describe('head resources', () => {
944 + // @gate enableFloat
945 + it('can rendering title tags anywhere in the tree', async () => {
946 + await actIntoEmptyDocument(() => {
947 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
948 + <>
949 + <title>before</title>
950 + <>
951 + <html>
952 + <head>
953 + <title>in head</title>
954 + </head>
955 + <body>
956 + <div>
957 + <title>during</title>
958 + hello world
959 + </div>
960 + </body>
961 + </html>
962 + </>
963 + <title>after</title>
964 + </>,
965 + );
966 + pipe(writable);
967 + });
968 +
969 + expect(getMeaningfulChildren(document)).toEqual(
970 + <html>
971 + <head>
972 + <title>before</title>
973 + <title>in head</title>
974 + <title>during</title>
975 + <title>after</title>
976 + </head>
977 + <body>
978 + <div>hello world</div>
979 + </body>
980 + </html>,
981 + );
982 +
983 + ReactDOMClient.hydrateRoot(
984 + document,
985 + <>
986 + <title>before</title>
987 + <>
988 + <html>
989 + <head>
990 + <title>in head</title>
991 + </head>
992 + <body>
993 + <div>
994 + <title>during</title>
995 + hello world
996 + </div>
997 + </body>
998 + </html>
999 + </>
1000 + <title>after</title>
1001 + </>,
1002 + );
1003 + expect(Scheduler).toFlushWithoutYielding();
1004 + expect(getMeaningfulChildren(document)).toEqual(
1005 + <html>
1006 + <head>
1007 + <title>before</title>
1008 + <title>in head</title>
1009 + <title>during</title>
1010 + <title>after</title>
1011 + </head>
1012 + <body>
1013 + <div>hello world</div>
1014 + </body>
1015 + </html>,
1016 + );
1017 + });
1018 +
1019 + // @gate enableFloat
1020 + it('prepends new titles on the client so newer ones override older ones, including orphaned server rendered titles', async () => {
1021 + await actIntoEmptyDocument(() => {
1022 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
1023 + <html>
1024 + <head>
1025 + <title>server</title>
1026 + </head>
1027 + <body>
1028 + <div>hello world</div>
1029 + </body>
1030 + </html>,
1031 + );
1032 + pipe(writable);
1033 + });
1034 +
1035 + expect(getMeaningfulChildren(document)).toEqual(
1036 + <html>
1037 + <head>
1038 + <title>server</title>
1039 + </head>
1040 + <body>
1041 + <div>hello world</div>
1042 + </body>
1043 + </html>,
1044 + );
1045 +
1046 + ReactDOMClient.hydrateRoot(
1047 + document,
1048 + <html>
1049 + <title>html</title>
1050 + <head>
1051 + <title>head</title>
1052 + </head>
1053 + <body>
1054 + <title>body</title>
1055 + <div>hello world</div>
1056 + </body>
1057 + </html>,
1058 + );
1059 + expect(Scheduler).toFlushWithoutYielding();
1060 + expect(getMeaningfulChildren(document)).toEqual(
1061 + <html>
1062 + <head>
1063 + <title>body</title>
1064 + <title>head</title>
1065 + <title>html</title>
1066 + <title>server</title>
1067 + </head>
1068 + <body>
1069 + <div>hello world</div>
1070 + </body>
1071 + </html>,
1072 + );
1073 + });
1074 +
1075 + // @gate enableFloat
1076 + it('keys titles on text children and only removes them when no more instances refer to that title', async () => {
1077 + const root = ReactDOMClient.createRoot(container);
1078 + root.render(
1079 + <div>
1080 + <title>{[2]}</title>hello world<title>2</title>
1081 + </div>,
1082 + );
1083 + expect(Scheduler).toFlushWithoutYielding();
1084 + expect(getMeaningfulChildren(document)).toEqual(
1085 + <html>
1086 + <head>
1087 + <title>2</title>
1088 + </head>
1089 + <body>
1090 + <div id="container">
1091 + <div>hello world</div>
1092 + </div>
1093 + </body>
1094 + </html>,
1095 + );
1096 +
1097 + root.render(
1098 + <div>
1099 + {null}hello world<title>2</title>
1100 + </div>,
1101 + );
1102 + expect(Scheduler).toFlushWithoutYielding();
1103 + expect(getMeaningfulChildren(document)).toEqual(
1104 + <html>
1105 + <head>
1106 + <title>2</title>
1107 + </head>
1108 + <body>
1109 + <div id="container">
1110 + <div>hello world</div>
1111 + </div>
1112 + </body>
1113 + </html>,
1114 + );
1115 + root.render(
1116 + <div>
1117 + {null}hello world{null}
1118 + </div>,
1119 + );
1120 + expect(Scheduler).toFlushWithoutYielding();
1121 + expect(getMeaningfulChildren(document)).toEqual(
1122 + <html>
1123 + <head />
1124 + <body>
1125 + <div id="container">
1126 + <div>hello world</div>
1127 + </div>
1128 + </body>
1129 + </html>,
1130 + );
1131 + });
1132 +
1133 + // @gate enableFloat
1134 + it('can render a title before a singleton even if that singleton clears its contents', async () => {
1135 + await actIntoEmptyDocument(() => {
1136 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
1137 + <>
1138 + <title>foo</title>
1139 + <html>
1140 + <head />
1141 + <body>
1142 + <div>server</div>
1143 + </body>
1144 + </html>
1145 + ,
1146 + </>,
1147 + );
1148 + pipe(writable);
1149 + });
1150 +
1151 + const errors = [];
1152 + ReactDOMClient.hydrateRoot(
1153 + document,
1154 + <>
1155 + <title>foo</title>
1156 + <html>
1157 + <head />
1158 + <body>
1159 + <div>client</div>
1160 + </body>
1161 + </html>
1162 + </>,
1163 + {
1164 + onRecoverableError(err) {
1165 + errors.push(err.message);
1166 + },
1167 + },
1168 + );
1169 + try {
1170 + expect(() => {
1171 + expect(Scheduler).toFlushWithoutYielding();
1172 + }).toErrorDev(
1173 + [
1174 + 'Warning: Text content did not match. Server: "server" Client: "client"',
1175 + 'Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.',
1176 + ],
1177 + {withoutStack: 1},
1178 + );
1179 + } catch (e) {
1180 + // When gates are false this test fails on a DOMException if you don't clear the scheduler after catching.
1181 + // When gates are true this branch should not be hit
1182 + expect(Scheduler).toFlushWithoutYielding();
1183 + throw e;
1184 + }
1185 + expect(getMeaningfulChildren(document)).toEqual(
1186 + <html>
1187 + <head>
1188 + <title>foo</title>
1189 + </head>
1190 + <body>
1191 + <div>client</div>
1192 + </body>
1193 + </html>,
1194 + );
1195 + });
1196 + });
1197 +
1198 describe('style resources', () => {
1199 // @gate enableFloat
1200 it('treats link rel stylesheet elements as a style resource when it includes a precedence when server rendering', async () => {
@@ -1229,6 +1558,7 @@ describe('ReactDOMFloat', () => {
1558 <link rel="stylesheet" href="foo" data-precedence="foo" />
1559 <link rel="stylesheet" href="bar" data-precedence="bar" />
1560 <link rel="stylesheet" href="baz" data-precedence="baz" />
1561 + <link rel="preload" href="baz" as="style" />
1562 </head>
1563 <body>hello</body>
1564 </html>,
packages/react-dom/src/__tests__/ReactDOMSingletonComponents-test.js
+45 -16
@@ -155,8 +155,8 @@ describe('ReactDOM HostSingleton', () => {
155 expect(getVisibleChildren(document)).toEqual(
156 <html>
157 <head lang="es" data-foo="foo">
158 - <title>Hello</title>
158 <title>Hola</title>
159 + <title>Hello</title>
160 </head>
161 <body />
162 </html>,
@@ -241,8 +241,8 @@ describe('ReactDOM HostSingleton', () => {
241 <link rel="preload" href="resource" as="style" />
242 <link rel="preload" href="3rdparty" as="style" />
243 <link rel="preload" href="3rdparty2" as="style" />
244 - <link rel="stylesheet" href="resource" />
244 <title>a server title</title>
245 + <link rel="stylesheet" href="resource" />
246 <link rel="stylesheet" href="3rdparty" />
247 <link rel="stylesheet" href="3rdparty2" />
248 </head>
@@ -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>
291 <link rel="stylesheet" href="resource" />
292 <link rel="stylesheet" href="3rdparty" />
293 <link rel="stylesheet" href="3rdparty2" />
293 - <title>a client title</title>
294 </head>
295 <body data-client-baz="baz">
296 <style>
@@ -326,10 +326,10 @@ describe('ReactDOM HostSingleton', () => {
326 expect(getVisibleChildren(document)).toEqual(
327 <html data-client-foo="foo">
328 <head>
329 + <title>a client title</title>
330 <link rel="stylesheet" href="resource" />
331 <link rel="stylesheet" href="3rdparty" />
332 <link rel="stylesheet" href="3rdparty2" />
332 - <title>a client title</title>
333 <meta />
334 </head>
335 <body data-client-baz="baz">
@@ -365,10 +365,10 @@ describe('ReactDOM HostSingleton', () => {
365 expect(getVisibleChildren(document)).toEqual(
366 <html data-client-foo="foo">
367 <head>
368 + <title>a client title</title>
369 <link rel="stylesheet" href="resource" />
370 <link rel="stylesheet" href="3rdparty" />
371 <link rel="stylesheet" href="3rdparty2" />
371 - <title>a client title</title>
372 </head>
373 <body data-client-baz="baz">
374 <style>
@@ -401,10 +401,10 @@ describe('ReactDOM HostSingleton', () => {
401 expect(getVisibleChildren(document)).toEqual(
402 <html data-client-foo="foo">
403 <head>
404 + <title>a client title</title>
405 <link rel="stylesheet" href="resource" />
406 <link rel="stylesheet" href="3rdparty" />
407 <link rel="stylesheet" href="3rdparty2" />
407 - <title>a client title</title>
408 </head>
409 <body>
410 <style>
@@ -472,19 +472,15 @@ describe('ReactDOM HostSingleton', () => {
472 expect(Scheduler).toFlushWithoutYielding();
473 }).toErrorDev(
474 [
475 - `Warning: Expected server HTML to contain a matching <title> in <head>.
476 - in title (at **)
477 - in head (at **)
475 + `Warning: Expected server HTML to contain a matching <div> in <body>.
476 + in div (at **)
477 + in body (at **)
478 in html (at **)`,
479 `Warning: An error occurred during hydration. The server HTML was replaced with client content in <#document>.`,
480 ],
481 {withoutStack: 1},
482 );
483 expect(hydrationErrors).toEqual([
484 - [
485 - 'Hydration failed because the initial UI does not match what was rendered on the server.',
486 - 'at title',
487 - ],
484 [
485 'Hydration failed because the initial UI does not match what was rendered on the server.',
486 'at div',
@@ -502,10 +498,10 @@ describe('ReactDOM HostSingleton', () => {
498 expect(getVisibleChildren(document)).toEqual(
499 <html data-client-foo="foo">
500 <head>
501 + <title>a client title</title>
502 <link rel="stylesheet" href="resource" />
503 <link rel="stylesheet" href="3rdparty" />
504 <link rel="stylesheet" href="3rdparty2" />
508 - <title>a client title</title>
505 </head>
506 <body data-client-baz="baz">
507 <style>
@@ -768,9 +764,9 @@ describe('ReactDOM HostSingleton', () => {
764 expect(getVisibleChildren(document)).toEqual(
765 <html>
766 <head>
767 + <title>something new</title>
768 <link rel="stylesheet" href="headbefore" />
769 <link rel="stylesheet" href="headafter" />
773 - <title>something new</title>
770 </head>
771 <body>
772 <link rel="stylesheet" href="bodybefore" />
@@ -804,9 +800,9 @@ describe('ReactDOM HostSingleton', () => {
800 expect(getVisibleChildren(document)).toEqual(
801 <html>
802 <head>
803 + <title>something new</title>
804 <link rel="stylesheet" href="before" />
805 <link rel="stylesheet" href="after" />
809 - <title>something new</title>
806 </head>
807 <body />
808 </html>,
@@ -979,4 +975,37 @@ describe('ReactDOM HostSingleton', () => {
975 </html>,
976 );
977 });
978 +
979 + // @gate enableHostSingletons
980 + it('allows for hydrating without a head', async () => {
981 + await actIntoEmptyDocument(() => {
982 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
983 + <html>
984 + <body>foo</body>
985 + </html>,
986 + );
987 + pipe(writable);
988 + });
989 +
990 + expect(getVisibleChildren(document)).toEqual(
991 + <html>
992 + <head />
993 + <body>foo</body>
994 + </html>,
995 + );
996 +
997 + ReactDOMClient.hydrateRoot(
998 + document,
999 + <html>
1000 + <body>foo</body>
1001 + </html>,
1002 + );
1003 + expect(Scheduler).toFlushWithoutYielding();
1004 + expect(getVisibleChildren(document)).toEqual(
1005 + <html>
1006 + <head />
1007 + <body>foo</body>
1008 + </html>,
1009 + );
1010 + });
1011 });
packages/react-dom/src/__tests__/ReactRenderDocument-test.js
+15 -4
@@ -253,10 +253,21 @@ describe('rendering React components at document', () => {
253 }
254 }
255
256 - // getTestDocument() has an extra <meta> that we didn't render.
257 - expect(() =>
258 - ReactDOM.hydrate(<Component text="Hello world" />, testDocument),
259 - ).toErrorDev('Did not expect server HTML to contain a <meta> in <head>.');
256 + if (gate(flags => flags.enableFloat)) {
257 + // with float the title no longer is a hydration mismatch so we get an error on the body mismatch
258 + expect(() =>
259 + ReactDOM.hydrate(<Component text="Hello world" />, testDocument),
260 + ).toErrorDev(
261 + 'Expected server HTML to contain a matching text node for "Hello world" in <body>',
262 + );
263 + } else {
264 + // getTestDocument() has an extra <meta> that we didn't render.
265 + expect(() =>
266 + ReactDOM.hydrate(<Component text="Hello world" />, testDocument),
267 + ).toErrorDev(
268 + 'Did not expect server HTML to contain a <meta> in <head>.',
269 + );
270 + }
271 expect(testDocument.body.innerHTML).toBe('Hello world');
272 });
273
packages/react-reconciler/src/ReactFiberBeginWork.new.js
+7 -7
@@ -1599,13 +1599,13 @@ function updateHostResource(current, workInProgress, renderLanes) {
1599 workInProgress.pendingProps,
1600 currentProps,
1601 );
1602 - reconcileChildren(
1603 - current,
1604 - workInProgress,
1605 - workInProgress.pendingProps.children,
1606 - renderLanes,
1607 - );
1608 - return workInProgress.child;
1602 + // Resources never have reconciler managed children. It is possible for
1603 + // the host implementation of getResource to consider children in the
1604 + // resource construction but they will otherwise be discarded. In practice
1605 + // this precludes all but the simplest children and Host specific warnings
1606 + // should be implemented to warn when children are passsed when otherwise not
1607 + // expected
1608 + return null;
1609 }
1610
1611 function updateHostSingleton(
packages/react-reconciler/src/ReactFiberBeginWork.old.js
+7 -7
@@ -1599,13 +1599,13 @@ function updateHostResource(current, workInProgress, renderLanes) {
1599 workInProgress.pendingProps,
1600 currentProps,
1601 );
1602 - reconcileChildren(
1603 - current,
1604 - workInProgress,
1605 - workInProgress.pendingProps.children,
1606 - renderLanes,
1607 - );
1608 - return workInProgress.child;
1602 + // Resources never have reconciler managed children. It is possible for
1603 + // the host implementation of getResource to consider children in the
1604 + // resource construction but they will otherwise be discarded. In practice
1605 + // this precludes all but the simplest children and Host specific warnings
1606 + // should be implemented to warn when children are passsed when otherwise not
1607 + // expected
1608 + return null;
1609 }
1610
1611 function updateHostSingleton(