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