diff --git a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js index 67c44b102074d..c9681bb27c836 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js +++ b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js @@ -13,7 +13,7 @@ import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals.js'; const {Dispatcher} = ReactDOMSharedInternals; import {DOCUMENT_NODE} from '../shared/HTMLNodeType'; import { - validateUnmatchedLinkResourceProps, + warnOnMissingHrefAndRel, validatePreloadResourceDifference, validateURLKeyedUpdatedProps, validateStyleResourceDifference, @@ -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 = { @@ -617,8 +630,30 @@ export function getResource( return null; } default: { + const {href, sizes, media} = pendingProps; + if (typeof rel === 'string' && typeof href === 'string') { + const sizeKey = + '::sizes:' + (typeof sizes === 'string' ? sizes : ''); + const mediaKey = + '::media:' + (typeof media === 'string' ? media : ''); + const key = 'rel:' + rel + '::href:' + href + sizeKey + mediaKey; + const headRoot = getDocumentFromRoot(resourceRoot); + const headResources = getResourcesFromRoot(headRoot).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; + } if (__DEV__) { - validateUnmatchedLinkResourceProps(pendingProps, currentProps); + warnOnMissingHrefAndRel(pendingProps, currentProps); } return null; } @@ -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); } @@ -732,6 +768,7 @@ export function acquireResource(resource: Resource): Instance { export function releaseResource(resource: Resource): void { switch (resource.type) { + case 'link': case 'title': case 'meta': { return releaseHeadResource(resource); @@ -1050,6 +1087,41 @@ 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, + ); + let selector = `link[rel="${limitedEscapedRel}"][href="${limitedEscapedHref}"]`; + if (typeof linkProps.sizes === 'string') { + const limitedEscapedSizes = escapeSelectorAttributeValueInsideDoubleQuotes( + linkProps.sizes, + ); + selector += `[sizes="${limitedEscapedSizes}"]`; + } + if (typeof linkProps.media === 'string') { + const limitedEscapedMedia = escapeSelectorAttributeValueInsideDoubleQuotes( + linkProps.media, + ); + selector += `[media="${limitedEscapedMedia}"]`; + } + const existingEl = root.querySelector(selector); + 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.`, @@ -1265,26 +1337,27 @@ export function isHostResourceType(type: string, props: Props): boolean { return true; } case 'link': { + const {onLoad, onError} = props; + if (onLoad || onError) { + return false; + } switch (props.rel) { case 'stylesheet': { if (__DEV__) { validateLinkPropsForStyleResource(props); } - const {href, precedence, onLoad, onError, disabled} = props; + const {href, precedence, disabled} = props; return ( typeof href === 'string' && typeof precedence === 'string' && - !onLoad && - !onError && disabled == null ); } - case 'preload': { - const {href, onLoad, onError} = props; - return !onLoad && !onError && typeof href === 'string'; + default: { + const {rel, href} = props; + return typeof href === 'string' && typeof rel === 'string'; } } - return false; } case 'script': { // We don't validate because it is valid to use async with onLoad/onError unlike combining diff --git a/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js b/packages/react-dom-bindings/src/server/ReactDOMFloatServer.js index e71f8e465ce85..91a37bfc1f0dc 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 @@ -101,6 +113,7 @@ export type Resources = { // Flushing queues for Resource dependencies charset: null | MetaResource, + preconnects: Set, fontPreloads: Set, // usedImagePreloads: Set, precedences: Map>, @@ -131,6 +144,7 @@ export function createResources(): Resources { // cleared on flush charset: null, + preconnects: new Set(), fontPreloads: new Set(), // usedImagePreloads: new Set(), precedences: new Map(), @@ -697,10 +711,11 @@ export function resourcesFromLink(props: Props): boolean { const resources = currentResources; const {rel, href} = props; - if (!href || typeof href !== 'string') { + if (!href || typeof href !== 'string' || !rel || typeof rel !== 'string') { return false; } + let key = ''; switch (rel) { case 'stylesheet': { const {onLoad, onError, precedence, disabled} = props; @@ -813,10 +828,37 @@ export function resourcesFromLink(props: Props): boolean { return true; } } - return false; + break; } } - return false; + if (props.onLoad || props.onError) { + return false; + } + + const sizes = typeof props.sizes === 'string' ? props.sizes : ''; + const media = typeof props.media === 'string' ? props.media : ''; + key = + 'rel:' + rel + '::href:' + href + '::sizes:' + sizes + '::media:' + media; + let resource = resources.headsMap.get(key); + if (!resource) { + resource = { + type: 'link', + props: Object.assign({}, props), + flushed: false, + }; + resources.headsMap.set(key, resource); + switch (rel) { + case 'preconnect': + case 'dns-prefetch': { + resources.preconnects.add(resource); + break; + } + default: { + resources.headResources.add(resource); + } + } + } + return true; } // Construct a resource from link props. diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index 181ac82f9194f..c62945a02520a 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -2340,6 +2340,7 @@ export function writeInitialResources( const { charset, + preconnects, fontPreloads, precedences, usedStylePreloads, @@ -2356,6 +2357,13 @@ export function writeInitialResources( resources.charset = null; } + preconnects.forEach(r => { + // font preload Resources should not already be flushed so we elide this check + pushLinkImpl(target, r.props, responseState); + r.flushed = true; + }); + preconnects.clear(); + fontPreloads.forEach(r => { // font preload Resources should not already be flushed so we elide this check pushLinkImpl(target, r.props, responseState); @@ -2418,6 +2426,10 @@ export function writeInitialResources( pushSelfClosing(target, r.props, 'meta', responseState); break; } + case 'link': { + pushLinkImpl(target, r.props, responseState); + break; + } } r.flushed = true; }); @@ -2450,6 +2462,7 @@ export function writeImmediateResources( const { charset, + preconnects, fontPreloads, usedStylePreloads, scripts, @@ -2465,6 +2478,13 @@ export function writeImmediateResources( resources.charset = null; } + preconnects.forEach(r => { + // font preload Resources should not already be flushed so we elide this check + pushLinkImpl(target, r.props, responseState); + r.flushed = true; + }); + preconnects.clear(); + fontPreloads.forEach(r => { // font preload Resources should not already be flushed so we elide this check pushLinkImpl(target, r.props, responseState); @@ -2507,6 +2527,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-bindings/src/shared/ReactDOMResourceValidation.js b/packages/react-dom-bindings/src/shared/ReactDOMResourceValidation.js index 8d5fda5c243d6..bd6f527cecc6c 100644 --- a/packages/react-dom-bindings/src/shared/ReactDOMResourceValidation.js +++ b/packages/react-dom-bindings/src/shared/ReactDOMResourceValidation.js @@ -11,7 +11,7 @@ import hasOwnProperty from 'shared/hasOwnProperty'; type Props = {[string]: mixed}; -export function validateUnmatchedLinkResourceProps( +export function warnOnMissingHrefAndRel( pendingProps: Props, currentProps: ?Props, ) { @@ -24,34 +24,52 @@ export function validateUnmatchedLinkResourceProps( const originalRelStatement = getValueDescriptorExpectingEnumForWarning( currentProps.rel, ); - const pendingRelStatement = getValueDescriptorExpectingEnumForWarning( + const pendingRel = getValueDescriptorExpectingEnumForWarning( pendingProps.rel, ); - const pendingHrefStatement = - typeof pendingProps.href === 'string' - ? ` and the updated href is "${pendingProps.href}"` - : ''; - console.error( - 'A previously rendered as a %s but was updated with a rel type that is not' + - ' valid for a Resource type. Generally Resources are not expected to ever have updated' + - ' props however in some limited circumstances it can be valid when changing the href.' + - ' When React encounters props that invalidate the Resource it is the same as not rendering' + - ' a Resource at all. valid rel types for Resources are "stylesheet" and "preload". The previous' + - ' rel for this instance was %s. The updated rel is %s%s.', - originalResourceName, - originalRelStatement, - pendingRelStatement, - pendingHrefStatement, + const pendingHref = getValueDescriptorExpectingEnumForWarning( + pendingProps.href, ); + if (typeof pendingProps.rel !== 'string') { + console.error( + 'A previously rendered as a %s with rel "%s" but was updated with an invalid rel: %s. When a link' + + ' does not have a valid rel prop it is not represented in the DOM. If this is intentional, instead' + + ' do not render the anymore.', + originalResourceName, + originalRelStatement, + pendingRel, + ); + } else if (typeof pendingProps.href !== 'string') { + console.error( + 'A previously rendered as a %s but was updated with an invalid href prop: %s. When a link' + + ' does not have a valid href prop it is not represented in the DOM. If this is intentional, instead' + + ' do not render the anymore.', + originalResourceName, + pendingHref, + ); + } } else { - const pendingRelStatement = getValueDescriptorExpectingEnumForWarning( + const pendingRel = getValueDescriptorExpectingEnumForWarning( pendingProps.rel, ); - console.error( - 'A is rendering as a Resource but has an invalid rel property. The rel encountered is %s.' + - ' This is a bug in React.', - pendingRelStatement, + const pendingHref = getValueDescriptorExpectingEnumForWarning( + pendingProps.href, ); + if (typeof pendingProps.rel !== 'string') { + console.error( + 'A is rendering with an invalid rel: %s. When a link' + + ' does not have a valid rel prop it is not represented in the DOM. If this is intentional, instead' + + ' do not render the anymore.', + pendingRel, + ); + } else if (typeof pendingProps.href !== 'string') { + console.error( + 'A is rendering with an invalid href: %s. When a link' + + ' does not have a valid href prop it is not represented in the DOM. If this is intentional, instead' + + ' do not render the anymore.', + pendingHref, + ); + } } } } diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 0ef44d9743319..7c31700cdafed 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -389,29 +389,29 @@ describe('ReactDOMFloat', () => { root.render(
+
, ); expect(Scheduler).toFlushWithoutYielding(); root.render(
- + {}} href="bar" /> + {}} />
, ); expect(() => { expect(Scheduler).toFlushWithoutYielding(); - }).toErrorDev( - 'Warning: A previously rendered as a Resource with href "foo" but was updated with a rel type that is not' + - ' valid for a Resource type. Generally Resources are not expected to ever have updated' + - ' props however in some limited circumstances it can be valid when changing the href.' + - ' When React encounters props that invalidate the Resource it is the same as not rendering' + - ' a Resource at all. valid rel types for Resources are "stylesheet" and "preload". The previous' + - ' rel for this instance was "stylesheet". The updated rel is "author" and the updated href is "bar".', - ); + }).toErrorDev([ + 'Warning: A previously rendered as a Resource with href "foo" with rel ""stylesheet"" but was updated with an invalid rel: something with type "function". When a link does not have a valid rel prop it is not represented in the DOM. If this is intentional, instead do not render the anymore.', + 'Warning: A previously rendered as a Resource with href "bar" but was updated with an invalid href prop: something with type "function". When a link does not have a valid href prop it is not represented in the DOM. If this is intentional, instead do not render the anymore.', + ]); expect(getMeaningfulChildren(document)).toEqual( + +
@@ -941,6 +941,170 @@ describe('ReactDOMFloat', () => { }); describe('head resources', () => { + // @gate enableFloat + it('supports preconnects, prefetc-dns, and arbitrary other link types', async () => { + await actIntoEmptyDocument(() => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream( + + + +
hello world
+ + + + + + + + + + + + + , + ); + pipe(writable); + }); + // "preconnect" and "dns-prefetch" get hoisted to the front. + // All other generic links (not styles, or typed preloads) + // get emitted after styles and other higher priority Resources + // Sizes and Media are part of generic link keys + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + + + + + + +
hello world
+ + , + ); + + const root = ReactDOMClient.hydrateRoot( + document, + + + +
hello world
+ + + + + + + + + + + + + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + + + + + + +
hello world
+ + , + ); + + root.render( + + + +
hello world
+ + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + +
hello world
+ + , + ); + }); + // @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(() => {