[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)
This commit is contained in:
Josh Story 2022-10-23 15:03:52 -07:00 committed by GitHub
parent 1d3fc9c9c4
commit 61f9b5e97b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 187 additions and 7 deletions

View File

@ -133,9 +133,19 @@ type LinkResource = {
root: Document, root: Document,
}; };
type BaseResource = {
type: 'base',
matcher: string,
props: Props,
count: number,
instance: ?Element,
root: Document,
};
type Props = {[string]: mixed}; type Props = {[string]: mixed};
type HeadResource = TitleResource | MetaResource | LinkResource; type HeadResource = TitleResource | MetaResource | LinkResource | BaseResource;
type Resource = StyleResource | ScriptResource | PreloadResource | HeadResource; type Resource = StyleResource | ScriptResource | PreloadResource | HeadResource;
export type RootResources = { export type RootResources = {
@ -443,6 +453,35 @@ export function getResource(
); );
} }
switch (type) { switch (type) {
case 'base': {
const headRoot: Document = getDocumentFromRoot(resourceRoot);
const headResources = getResourcesFromRoot(headRoot).head;
const {target, href} = pendingProps;
let matcher = 'base';
matcher +=
typeof href === 'string'
? `[href="${escapeSelectorAttributeValueInsideDoubleQuotes(href)}"]`
: ':not([href])';
matcher +=
typeof target === 'string'
? `[target="${escapeSelectorAttributeValueInsideDoubleQuotes(
target,
)}"]`
: ':not([target])';
let resource = headResources.get(matcher);
if (!resource) {
resource = {
type: 'base',
matcher,
props: Object.assign({}, pendingProps),
count: 0,
instance: null,
root: headRoot,
};
headResources.set(matcher, resource);
}
return resource;
}
case 'meta': { case 'meta': {
let matcher, propertyString, parentResource; let matcher, propertyString, parentResource;
const { const {
@ -748,6 +787,7 @@ function scriptPropsFromRawProps(rawProps: ScriptQualifyingProps): ScriptProps {
export function acquireResource(resource: Resource): Instance { export function acquireResource(resource: Resource): Instance {
switch (resource.type) { switch (resource.type) {
case 'base':
case 'title': case 'title':
case 'link': case 'link':
case 'meta': { case 'meta': {
@ -1126,6 +1166,27 @@ function acquireHeadResource(resource: HeadResource): Instance {
insertResourceInstanceBefore(root, instance, null); insertResourceInstanceBefore(root, instance, null);
return instance; return instance;
} }
case 'base': {
const baseResource: BaseResource = (resource: any);
const {matcher} = baseResource;
const base = root.querySelector(matcher);
if (base) {
instance = resource.instance = base;
markNodeAsResource(instance);
} else {
instance = resource.instance = createResourceInstance(
type,
props,
root,
);
insertResourceInstanceBefore(
root,
instance,
root.querySelector('base'),
);
}
return instance;
}
default: { default: {
throw new Error( throw new Error(
`acquireHeadResource encountered a resource type it did not expect: "${type}". This is a bug in React.`, `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 {
resourceFormOnly = getResourceFormOnly(hostContext); resourceFormOnly = getResourceFormOnly(hostContext);
} }
switch (type) { switch (type) {
case 'base':
case 'meta': case 'meta':
case 'title': { case 'title': {
return true; return true;
@ -1403,7 +1465,6 @@ export function isHostResourceType(type: string, props: Props): boolean {
} }
return (async: any) && typeof src === 'string' && !onLoad && !onError; return (async: any) && typeof src === 'string' && !onLoad && !onError;
} }
case 'base':
case 'template': case 'template':
case 'style': case 'style':
case 'noscript': { case 'noscript': {

View File

@ -101,8 +101,19 @@ type LinkResource = {
flushed: boolean, flushed: boolean,
}; };
type BaseResource = {
type: 'base',
props: Props,
flushed: boolean,
};
export type Resource = PreloadResource | StyleResource | ScriptResource; export type Resource = PreloadResource | StyleResource | ScriptResource;
export type HeadResource = TitleResource | MetaResource | LinkResource; export type HeadResource =
| TitleResource
| MetaResource
| LinkResource
| BaseResource;
export type Resources = { export type Resources = {
// Request local cache // Request local cache
@ -113,6 +124,7 @@ export type Resources = {
// Flushing queues for Resource dependencies // Flushing queues for Resource dependencies
charset: null | MetaResource, charset: null | MetaResource,
bases: Set<BaseResource>,
preconnects: Set<LinkResource>, preconnects: Set<LinkResource>,
fontPreloads: Set<PreloadResource>, fontPreloads: Set<PreloadResource>,
// usedImagePreloads: Set<PreloadResource>, // usedImagePreloads: Set<PreloadResource>,
@ -144,6 +156,7 @@ export function createResources(): Resources {
// cleared on flush // cleared on flush
charset: null, charset: null,
bases: new Set(),
preconnects: new Set(), preconnects: new Set(),
fontPreloads: new Set(), fontPreloads: new Set(),
// usedImagePreloads: new Set(), // usedImagePreloads: new Set(),
@ -692,9 +705,28 @@ export function resourcesFromElement(type: string, props: Props): boolean {
resources.headResources.add(resource); resources.headResources.add(resource);
} }
} }
return true;
} }
return false; return true;
}
case 'base': {
const {target, href} = props;
// We mirror the key construction on the client since we will likely unify
// this code in the future to better guarantee key semantics are identical
// in both environments
let key = 'base';
key += typeof href === 'string' ? `[href="${href}"]` : ':not([href])';
key +=
typeof target === 'string' ? `[target="${target}"]` : ':not([target])';
if (!resources.headsMap.has(key)) {
const resource = {
type: 'base',
props: Object.assign({}, props),
flushed: false,
};
resources.headsMap.set(key, resource);
resources.bases.add(resource);
}
return true;
} }
} }
return false; return false;

View File

@ -1150,6 +1150,26 @@ function pushStartTextArea(
return null; return null;
} }
function pushBase(
target: Array<Chunk | PrecomputedChunk>,
props: Object,
responseState: ResponseState,
textEmbedded: boolean,
): ReactNodeList {
if (enableFloat && resourcesFromElement('base', props)) {
if (textEmbedded) {
// This link follows text but we aren't writing a tag. while not as efficient as possible we need
// to be safe and assume text will follow by inserting a textSeparator
target.push(textSeparator);
}
// We have converted this link exclusively to a resource and no longer
// need to emit it
return null;
}
return pushSelfClosing(target, props, 'base', responseState);
}
function pushMeta( function pushMeta(
target: Array<Chunk | PrecomputedChunk>, target: Array<Chunk | PrecomputedChunk>,
props: Object, props: Object,
@ -1853,6 +1873,8 @@ export function pushStartInstance(
: pushStartGenericElement(target, props, type, responseState); : pushStartGenericElement(target, props, type, responseState);
case 'meta': case 'meta':
return pushMeta(target, props, responseState, textEmbedded); return pushMeta(target, props, responseState, textEmbedded);
case 'base':
return pushBase(target, props, responseState, textEmbedded);
// Newline eating tags // Newline eating tags
case 'listing': case 'listing':
case 'pre': { case 'pre': {
@ -1860,7 +1882,6 @@ export function pushStartInstance(
} }
// Omitted close tags // Omitted close tags
case 'area': case 'area':
case 'base':
case 'br': case 'br':
case 'col': case 'col':
case 'embed': case 'embed':
@ -2493,6 +2514,7 @@ export function writeInitialResources(
const { const {
charset, charset,
bases,
preconnects, preconnects,
fontPreloads, fontPreloads,
precedences, precedences,
@ -2510,6 +2532,12 @@ export function writeInitialResources(
resources.charset = null; resources.charset = null;
} }
bases.forEach(r => {
pushSelfClosing(target, r.props, 'base', responseState);
r.flushed = true;
});
bases.clear();
preconnects.forEach(r => { preconnects.forEach(r => {
// font preload Resources should not already be flushed so we elide this check // font preload Resources should not already be flushed so we elide this check
pushLinkImpl(target, r.props, responseState); pushLinkImpl(target, r.props, responseState);

View File

@ -1189,8 +1189,67 @@ describe('ReactDOMFloat', () => {
</html>, </html>,
); );
}); });
// @gate enableFloat // @gate enableFloat
it('can render icons and apple-touch-icons as resources', async () => { it('can render <base> as a Resource', async () => {
await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<html>
<head />
<body>
<base target="_blank" />
<base href="foo" />
<base target="_self" href="bar" />
<div>hello world</div>
</body>
</html>,
);
pipe(writable);
});
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<base target="_blank" />
<base href="foo" />
<base target="_self" href="bar" />
</head>
<body>
<div>hello world</div>
</body>
</html>,
);
ReactDOMClient.hydrateRoot(
document,
<html>
<head />
<body>
<base target="_blank" />
<base href="foo" />
<base target="_self" href="bar" />
<base target="_top" href="baz" />
<div>hello world</div>
</body>
</html>,
);
expect(Scheduler).toFlushWithoutYielding();
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<base target="_top" href="baz" />
<base target="_blank" />
<base href="foo" />
<base target="_self" href="bar" />
</head>
<body>
<div>hello world</div>
</body>
</html>,
);
});
// @gate enableFloat
it('can render icons and apple-touch-icons as Resources', async () => {
await actIntoEmptyDocument(() => { await actIntoEmptyDocument(() => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream( const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<> <>