From cc45100ad1e09320d7d466f370401f5d40afc5d0 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Wed, 19 Oct 2022 13:17:35 -0700 Subject: [PATCH] support icon and apple-touch-icon as resources --- .../src/client/ReactDOMFloatClient.js | 76 +++++++++++++++++-- .../src/server/ReactDOMFloatServer.js | 29 ++++++- .../src/server/ReactDOMServerFormatConfig.js | 8 ++ .../src/__tests__/ReactDOMFloat-test.js | 54 +++++++++++++ 4 files changed, 160 insertions(+), 7 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js index 67c44b102074d..0a67341ba8612 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js +++ b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js @@ -54,7 +54,7 @@ type StyleProps = { 'data-precedence': string, [string]: mixed, }; -export type StyleResource = { +type StyleResource = { type: 'style', // Ref count for resource @@ -79,7 +79,7 @@ type ScriptProps = { src: string, [string]: mixed, }; -export type ScriptResource = { +type ScriptResource = { type: 'script', src: string, props: ScriptProps, @@ -88,12 +88,10 @@ export type ScriptResource = { root: FloatRoot, }; -export type HeadResource = TitleResource | MetaResource; - type TitleProps = { [string]: mixed, }; -export type TitleResource = { +type TitleResource = { type: 'title', props: TitleProps, @@ -105,7 +103,7 @@ export type TitleResource = { type MetaProps = { [string]: mixed, }; -export type MetaResource = { +type MetaResource = { type: 'meta', matcher: string, property: ?string, @@ -117,8 +115,23 @@ export type MetaResource = { root: Document, }; +type LinkProps = { + href: string, + rel: string, + [string]: mixed, +}; +type LinkResource = { + type: 'link', + props: LinkProps, + + count: number, + instance: ?Element, + root: Document, +}; + type Props = {[string]: mixed}; +type HeadResource = TitleResource | MetaResource | LinkResource; type Resource = StyleResource | ScriptResource | PreloadResource | HeadResource; export type RootResources = { @@ -616,6 +629,28 @@ export function getResource( } return null; } + case 'icon': + case 'apple-touch-icon': { + const {href} = pendingProps; + if (typeof href === 'string') { + const key = rel + href; + const headRoot = getDocumentFromRoot(resourceRoot); + const headResources = getResourcesFromRoot(resourceRoot).head; + let resource = headResources.get(key); + if (!resource) { + resource = { + type: 'link', + props: Object.assign({}, pendingProps), + count: 0, + instance: null, + root: headRoot, + }; + headResources.set(key, resource); + } + return resource; + } + return null; + } default: { if (__DEV__) { validateUnmatchedLinkResourceProps(pendingProps, currentProps); @@ -710,6 +745,7 @@ function scriptPropsFromRawProps(rawProps: ScriptQualifyingProps): ScriptProps { export function acquireResource(resource: Resource): Instance { switch (resource.type) { case 'title': + case 'link': case 'meta': { return acquireHeadResource(resource); } @@ -1050,6 +1086,30 @@ function acquireHeadResource(resource: HeadResource): Instance { insertResourceInstanceBefore(root, instance, insertBefore); break; } + case 'link': { + const linkProps: LinkProps = (props: any); + const limitedEscapedRel = escapeSelectorAttributeValueInsideDoubleQuotes( + linkProps.rel, + ); + const limitedEscapedHref = escapeSelectorAttributeValueInsideDoubleQuotes( + linkProps.href, + ); + const existingEl = root.querySelector( + `link[rel="${limitedEscapedRel}"][href="${limitedEscapedHref}"]`, + ); + if (existingEl) { + instance = resource.instance = existingEl; + markNodeAsResource(instance); + return instance; + } + instance = resource.instance = createResourceInstance( + type, + props, + root, + ); + insertResourceInstanceBefore(root, instance, null); + return instance; + } default: { throw new Error( `acquireHeadResource encountered a resource type it did not expect: "${type}". This is a bug in React.`, @@ -1283,6 +1343,10 @@ export function isHostResourceType(type: string, props: Props): boolean { const {href, onLoad, onError} = props; return !onLoad && !onError && typeof href === 'string'; } + case 'icon': + case 'apple-touch-icon': { + return true; + } } return false; } diff --git a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js index e71f8e465ce85..5f7051365a0dc 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js +++ b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js @@ -89,8 +89,20 @@ type MetaResource = { flushed: boolean, }; +type LinkProps = { + href: string, + rel: string, + [string]: mixed, +}; +type LinkResource = { + type: 'link', + props: LinkProps, + + flushed: boolean, +}; + export type Resource = PreloadResource | StyleResource | ScriptResource; -export type HeadResource = TitleResource | MetaResource; +export type HeadResource = TitleResource | MetaResource | LinkResource; export type Resources = { // Request local cache @@ -815,6 +827,21 @@ export function resourcesFromLink(props: Props): boolean { } return false; } + case 'icon': + case 'apple-touch-icon': { + const key = rel + href; + let resource = resources.headsMap.get(key); + if (!resource) { + resource = { + type: 'link', + props: Object.assign({}, props), + flushed: false, + }; + resources.headsMap.set(key, resource); + resources.headResources.add(resource); + } + return true; + } } return false; } diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index 181ac82f9194f..83d9b243a9ea0 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -2418,6 +2418,10 @@ export function writeInitialResources( pushSelfClosing(target, r.props, 'meta', responseState); break; } + case 'link': { + pushLinkImpl(target, r.props, responseState); + break; + } } r.flushed = true; }); @@ -2507,6 +2511,10 @@ export function writeImmediateResources( pushSelfClosing(target, r.props, 'meta', responseState); break; } + case 'link': { + pushLinkImpl(target, r.props, responseState); + break; + } } r.flushed = true; }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 0ef44d9743319..f15f9806840d3 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -941,6 +941,60 @@ describe('ReactDOMFloat', () => { }); describe('head resources', () => { + // @gate enableFloat + it('can render icons and apple-touch-icons as resources', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + <> + + + + +
hello world
+ + + + , + ); + pipe(writable); + }); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + +
hello world
+ + , + ); + + ReactDOMClient.hydrateRoot( + document, + + + + + +
hello world
+ + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + +
hello world
+ + , + ); + }); + // @gate enableFloat it('can hydrate the right instances for deeply nested structured metas', async () => { await actIntoEmptyDocument(() => {