[Float] support <base> as Resource (#25546)
keys off `target` and `href`. prepends on insertion similar to title. only flushes on the server in the shell (should probably add a warning if there are any to flush in a boundary)
Josh Story committed
Oct 23, 2022 at 15:03 UTC
61f9b5e97b36de205c7d16256334cd85b92adc2d
4 files changed
+187
-7
packages/react-dom-bindings/src/client/ReactDOMFloatClient.js
+63
-2
@@ -133,9 +133,19 @@ type LinkResource = {
133
root: Document,
134
};
135
136
+type BaseResource = {
137
+ type: 'base',
138
+ matcher: string,
139
+ props: Props,
140
+
141
+ count: number,
142
+ instance: ?Element,
143
+ root: Document,
144
+};
145
+
146
type Props = {[string]: mixed};
147
138
-type HeadResource = TitleResource | MetaResource | LinkResource;
148
+type HeadResource = TitleResource | MetaResource | LinkResource | BaseResource;
149
type Resource = StyleResource | ScriptResource | PreloadResource | HeadResource;
150
151
export type RootResources = {
@@ -443,6 +453,35 @@ export function getResource(
453
);
454
}
455
switch (type) {
456
+ case 'base': {
457
+ const headRoot: Document = getDocumentFromRoot(resourceRoot);
458
+ const headResources = getResourcesFromRoot(headRoot).head;
459
+ const {target, href} = pendingProps;
460
+ let matcher = 'base';
461
+ matcher +=
462
+ typeof href === 'string'
463
+ ? `[href="${escapeSelectorAttributeValueInsideDoubleQuotes(href)}"]`
464
+ : ':not([href])';
465
+ matcher +=
466
+ typeof target === 'string'
467
+ ? `[target="${escapeSelectorAttributeValueInsideDoubleQuotes(
468
+ target,
469
+ )}"]`
470
+ : ':not([target])';
471
+ let resource = headResources.get(matcher);
472
+ if (!resource) {
473
+ resource = {
474
+ type: 'base',
475
+ matcher,
476
+ props: Object.assign({}, pendingProps),
477
+ count: 0,
478
+ instance: null,
479
+ root: headRoot,
480
+ };
481
+ headResources.set(matcher, resource);
482
+ }
483
+ return resource;
484
+ }
485
case 'meta': {
486
let matcher, propertyString, parentResource;
487
const {
@@ -748,6 +787,7 @@ function scriptPropsFromRawProps(rawProps: ScriptQualifyingProps): ScriptProps {
787
788
export function acquireResource(resource: Resource): Instance {
789
switch (resource.type) {
790
+ case 'base':
791
case 'title':
792
case 'link':
793
case 'meta': {
@@ -1126,6 +1166,27 @@ function acquireHeadResource(resource: HeadResource): Instance {
1166
insertResourceInstanceBefore(root, instance, null);
1167
return instance;
1168
}
1169
+ case 'base': {
1170
+ const baseResource: BaseResource = (resource: any);
1171
+ const {matcher} = baseResource;
1172
+ const base = root.querySelector(matcher);
1173
+ if (base) {
1174
+ instance = resource.instance = base;
1175
+ markNodeAsResource(instance);
1176
+ } else {
1177
+ instance = resource.instance = createResourceInstance(
1178
+ type,
1179
+ props,
1180
+ root,
1181
+ );
1182
+ insertResourceInstanceBefore(
1183
+ root,
1184
+ instance,
1185
+ root.querySelector('base'),
1186
+ );
1187
+ }
1188
+ return instance;
1189
+ }
1190
default: {
1191
throw new Error(
1192
`acquireHeadResource encountered a resource type it did not expect: "${type}". This is a bug in React.`,
@@ -1341,6 +1402,7 @@ export function isHostResourceType(type: string, props: Props): boolean {
1402
resourceFormOnly = getResourceFormOnly(hostContext);
1403
}
1404
switch (type) {
1405
+ case 'base':
1406
case 'meta':
1407
case 'title': {
1408
return true;
@@ -1403,7 +1465,6 @@ export function isHostResourceType(type: string, props: Props): boolean {
1465
}
1466
return (async: any) && typeof src === 'string' && !onLoad && !onError;
1467
}
1406
- case 'base':
1468
case 'template':
1469
case 'style':
1470
case 'noscript': {
packages/react-dom-bindings/src/server/ReactDOMFloatServer.js
+35
-3
@@ -101,8 +101,19 @@ type LinkResource = {
101
flushed: boolean,
102
};
103
104
+type BaseResource = {
105
+ type: 'base',
106
+ props: Props,
107
+
108
+ flushed: boolean,
109
+};
110
+
111
export type Resource = PreloadResource | StyleResource | ScriptResource;
105
-export type HeadResource = TitleResource | MetaResource | LinkResource;
112
+export type HeadResource =
113
+ | TitleResource
114
+ | MetaResource
115
+ | LinkResource
116
+ | BaseResource;
117
118
export type Resources = {
119
// Request local cache
@@ -113,6 +124,7 @@ export type Resources = {
124
125
// Flushing queues for Resource dependencies
126
charset: null | MetaResource,
127
+ bases: Set<BaseResource>,
128
preconnects: Set<LinkResource>,
129
fontPreloads: Set<PreloadResource>,
130
// usedImagePreloads: Set<PreloadResource>,
@@ -144,6 +156,7 @@ export function createResources(): Resources {
156
157
// cleared on flush
158
charset: null,
159
+ bases: new Set(),
160
preconnects: new Set(),
161
fontPreloads: new Set(),
162
// usedImagePreloads: new Set(),
@@ -692,9 +705,28 @@ export function resourcesFromElement(type: string, props: Props): boolean {
705
resources.headResources.add(resource);
706
}
707
}
695
- return true;
708
}
697
- return false;
709
+ return true;
710
+ }
711
+ case 'base': {
712
+ const {target, href} = props;
713
+ // We mirror the key construction on the client since we will likely unify
714
+ // this code in the future to better guarantee key semantics are identical
715
+ // in both environments
716
+ let key = 'base';
717
+ key += typeof href === 'string' ? `[href="${href}"]` : ':not([href])';
718
+ key +=
719
+ typeof target === 'string' ? `[target="${target}"]` : ':not([target])';
720
+ if (!resources.headsMap.has(key)) {
721
+ const resource = {
722
+ type: 'base',
723
+ props: Object.assign({}, props),
724
+ flushed: false,
725
+ };
726
+ resources.headsMap.set(key, resource);
727
+ resources.bases.add(resource);
728
+ }
729
+ return true;
730
}
731
}
732
return false;
packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js
+29
-1
@@ -1150,6 +1150,26 @@ function pushStartTextArea(
1150
return null;
1151
}
1152
1153
+function pushBase(
1154
+ target: Array<Chunk | PrecomputedChunk>,
1155
+ props: Object,
1156
+ responseState: ResponseState,
1157
+ textEmbedded: boolean,
1158
+): ReactNodeList {
1159
+ if (enableFloat && resourcesFromElement('base', props)) {
1160
+ if (textEmbedded) {
1161
+ // This link follows text but we aren't writing a tag. while not as efficient as possible we need
1162
+ // to be safe and assume text will follow by inserting a textSeparator
1163
+ target.push(textSeparator);
1164
+ }
1165
+ // We have converted this link exclusively to a resource and no longer
1166
+ // need to emit it
1167
+ return null;
1168
+ }
1169
+
1170
+ return pushSelfClosing(target, props, 'base', responseState);
1171
+}
1172
+
1173
function pushMeta(
1174
target: Array<Chunk | PrecomputedChunk>,
1175
props: Object,
@@ -1853,6 +1873,8 @@ export function pushStartInstance(
1873
: pushStartGenericElement(target, props, type, responseState);
1874
case 'meta':
1875
return pushMeta(target, props, responseState, textEmbedded);
1876
+ case 'base':
1877
+ return pushBase(target, props, responseState, textEmbedded);
1878
// Newline eating tags
1879
case 'listing':
1880
case 'pre': {
@@ -1860,7 +1882,6 @@ export function pushStartInstance(
1882
}
1883
// Omitted close tags
1884
case 'area':
1863
- case 'base':
1885
case 'br':
1886
case 'col':
1887
case 'embed':
@@ -2493,6 +2514,7 @@ export function writeInitialResources(
2514
2515
const {
2516
charset,
2517
+ bases,
2518
preconnects,
2519
fontPreloads,
2520
precedences,
@@ -2510,6 +2532,12 @@ export function writeInitialResources(
2532
resources.charset = null;
2533
}
2534
2535
+ bases.forEach(r => {
2536
+ pushSelfClosing(target, r.props, 'base', responseState);
2537
+ r.flushed = true;
2538
+ });
2539
+ bases.clear();
2540
+
2541
preconnects.forEach(r => {
2542
// font preload Resources should not already be flushed so we elide this check
2543
pushLinkImpl(target, r.props, responseState);
packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+60
-1
@@ -1189,8 +1189,67 @@ describe('ReactDOMFloat', () => {
1189
</html>,
1190
);
1191
});
1192
+
1193
+ // @gate enableFloat
1194
+ it('can render <base> as a Resource', async () => {
1195
+ await actIntoEmptyDocument(() => {
1196
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
1197
+ <html>
1198
+ <head />
1199
+ <body>
1200
+ <base target="_blank" />
1201
+ <base href="foo" />
1202
+ <base target="_self" href="bar" />
1203
+ <div>hello world</div>
1204
+ </body>
1205
+ </html>,
1206
+ );
1207
+ pipe(writable);
1208
+ });
1209
+ expect(getMeaningfulChildren(document)).toEqual(
1210
+ <html>
1211
+ <head>
1212
+ <base target="_blank" />
1213
+ <base href="foo" />
1214
+ <base target="_self" href="bar" />
1215
+ </head>
1216
+ <body>
1217
+ <div>hello world</div>
1218
+ </body>
1219
+ </html>,
1220
+ );
1221
+
1222
+ ReactDOMClient.hydrateRoot(
1223
+ document,
1224
+ <html>
1225
+ <head />
1226
+ <body>
1227
+ <base target="_blank" />
1228
+ <base href="foo" />
1229
+ <base target="_self" href="bar" />
1230
+ <base target="_top" href="baz" />
1231
+ <div>hello world</div>
1232
+ </body>
1233
+ </html>,
1234
+ );
1235
+ expect(Scheduler).toFlushWithoutYielding();
1236
+ expect(getMeaningfulChildren(document)).toEqual(
1237
+ <html>
1238
+ <head>
1239
+ <base target="_top" href="baz" />
1240
+ <base target="_blank" />
1241
+ <base href="foo" />
1242
+ <base target="_self" href="bar" />
1243
+ </head>
1244
+ <body>
1245
+ <div>hello world</div>
1246
+ </body>
1247
+ </html>,
1248
+ );
1249
+ });
1250
+
1251
// @gate enableFloat
1193
- it('can render icons and apple-touch-icons as resources', async () => {
1252
+ it('can render icons and apple-touch-icons as Resources', async () => {
1253
await actIntoEmptyDocument(() => {
1254
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
1255
<>