diff --git a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js index 7091db0525b9f..f4332cef91945 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js +++ b/packages/react-dom-bindings/src/client/ReactDOMFloatClient.js @@ -18,6 +18,8 @@ import {DOCUMENT_NODE} from '../shared/HTMLNodeType'; import { validatePreloadArguments, validatePreinitArguments, + getValueDescriptorExpectingObjectForWarning, + getValueDescriptorExpectingEnumForWarning, } from '../shared/ReactDOMResourceValidation'; import {createElement, setInitialProperties} from './ReactDOMComponent'; import { @@ -103,12 +105,18 @@ export function cleanupAfterRenderResources() { // We want this to be the default dispatcher on ReactDOMSharedInternals but we don't want to mutate // internals in Module scope. Instead we export it and Internals will import it. There is already a cycle // from Internals -> ReactDOM -> FloatClient -> Internals so this doesn't introduce a new one. -export const ReactDOMClientDispatcher = {preload, preinit}; +export const ReactDOMClientDispatcher = { + prefetchDNS, + preconnect, + preload, + preinit, +}; export type HoistableRoot = Document | ShadowRoot; -// global maps of Resources +// global collections of Resources const preloadPropsMap: Map = new Map(); +const preconnectsSet: Set = new Set(); // getRootNode is missing from IE and old jsdom versions export function getHoistableRoot(container: Container): HoistableRoot { @@ -148,8 +156,101 @@ function getDocumentFromRoot(root: HoistableRoot): Document { return root.ownerDocument || root; } +function preconnectAs( + rel: 'preconnect' | 'dns-prefetch', + crossOrigin: null | '' | 'use-credentials', + href: string, +) { + const ownerDocument = getDocumentForPreloads(); + if (typeof href === 'string' && href && ownerDocument) { + const limitedEscapedHref = + escapeSelectorAttributeValueInsideDoubleQuotes(href); + let key = `link[rel="${rel}"][href="${limitedEscapedHref}"]`; + if (typeof crossOrigin === 'string') { + key += `[crossorigin="${crossOrigin}"]`; + } + if (!preconnectsSet.has(key)) { + preconnectsSet.add(key); + + const preconnectProps = {rel, crossOrigin, href}; + if (null === ownerDocument.querySelector(key)) { + const preloadInstance = createElement( + 'link', + preconnectProps, + ownerDocument, + HTML_NAMESPACE, + ); + setInitialProperties(preloadInstance, 'link', preconnectProps); + markNodeAsResource(preloadInstance); + (ownerDocument.head: any).appendChild(preloadInstance); + } + } + } +} + +// -------------------------------------- +// ReactDOM.prefetchDNS +// -------------------------------------- +function prefetchDNS(href: string, options?: mixed) { + if (__DEV__) { + if (typeof href !== 'string' || !href) { + console.error( + 'ReactDOM.prefetchDNS(): Expected the `href` argument (first) to be a non-empty string but encountered %s instead.', + getValueDescriptorExpectingObjectForWarning(href), + ); + } else if (options != null) { + if ( + typeof options === 'object' && + options.hasOwnProperty('crossOrigin') + ) { + console.error( + 'ReactDOM.prefetchDNS(): Expected only one argument, `href`, but encountered %s as a second argument instead. This argument is reserved for future options and is currently disallowed. It looks like the you are attempting to set a crossOrigin property for this DNS lookup hint. Browsers do not perform DNS queries using CORS and setting this attribute on the resource hint has no effect. Try calling ReactDOM.prefetchDNS() with just a single string argument, `href`.', + getValueDescriptorExpectingEnumForWarning(options), + ); + } else { + console.error( + 'ReactDOM.prefetchDNS(): Expected only one argument, `href`, but encountered %s as a second argument instead. This argument is reserved for future options and is currently disallowed. Try calling ReactDOM.prefetchDNS() with just a single string argument, `href`.', + getValueDescriptorExpectingEnumForWarning(options), + ); + } + } + } + preconnectAs('dns-prefetch', null, href); +} + +// -------------------------------------- +// ReactDOM.preconnect +// -------------------------------------- +function preconnect(href: string, options?: {crossOrigin?: string}) { + if (__DEV__) { + if (typeof href !== 'string' || !href) { + console.error( + 'ReactDOM.preconnect(): Expected the `href` argument (first) to be a non-empty string but encountered %s instead.', + getValueDescriptorExpectingObjectForWarning(href), + ); + } else if (options != null && typeof options !== 'object') { + console.error( + 'ReactDOM.preconnect(): Expected the `options` argument (second) to be an object but encountered %s instead. The only supported option at this time is `crossOrigin` which accepts a string.', + getValueDescriptorExpectingEnumForWarning(options), + ); + } else if (options != null && typeof options.crossOrigin !== 'string') { + console.error( + 'ReactDOM.preconnect(): Expected the `crossOrigin` option (second argument) to be a string but encountered %s instead. Try removing this option or passing a string value instead.', + getValueDescriptorExpectingObjectForWarning(options.crossOrigin), + ); + } + } + const crossOrigin = + options == null || typeof options.crossOrigin !== 'string' + ? null + : options.crossOrigin === 'use-credentials' + ? 'use-credentials' + : ''; + preconnectAs('preconnect', crossOrigin, href); +} + // -------------------------------------- -// ReactDOM.Preload +// ReactDOM.preload // -------------------------------------- type PreloadAs = ResourceType; type PreloadOptions = {as: PreloadAs, crossOrigin?: string, integrity?: string}; diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index 782818ba2837c..ce7f63b4ccf6f 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -86,6 +86,8 @@ const ReactDOMCurrentDispatcher = ReactDOMSharedInternals.Dispatcher; const ReactDOMServerDispatcher = enableFloat ? { + prefetchDNS, + preconnect, preload, preinit, } @@ -3464,6 +3466,10 @@ export function writePreamble( } charsetChunks.length = 0; + // emit preconnect resources + resources.preconnects.forEach(flushResourceInPreamble, destination); + resources.preconnects.clear(); + const preconnectChunks = responseState.preconnectChunks; for (i = 0; i < preconnectChunks.length; i++) { writeChunk(destination, preconnectChunks[i]); @@ -3559,6 +3565,9 @@ export function writeHoistables( // We omit charsetChunks because we have already sent the shell and if it wasn't // already sent it is too late now. + resources.preconnects.forEach(flushResourceLate, destination); + resources.preconnects.clear(); + const preconnectChunks = responseState.preconnectChunks; for (i = 0; i < preconnectChunks.length; i++) { writeChunk(destination, preconnectChunks[i]); @@ -4068,7 +4077,10 @@ const Blocked /* */ = 0b0100; // This generally only makes sense for Resources other than PreloadResource const PreloadFlushed /* */ = 0b1000; -type TResource = { +type TResource< + T: 'stylesheet' | 'style' | 'script' | 'preload' | 'preconnect', + P, +> = { type: T, chunks: Array, state: ResourceStateTag, @@ -4099,6 +4111,13 @@ type ResourceDEV = | ImperativeResourceDEV | ImplicitResourceDEV; +type PreconnectProps = { + rel: 'preconnect' | 'dns-prefetch', + href: string, + [string]: mixed, +}; +type PreconnectResource = TResource<'preconnect', null>; + type PreloadProps = { rel: 'preload', as: string, @@ -4131,15 +4150,21 @@ type ScriptProps = { }; type ScriptResource = TResource<'script', null>; -type Resource = StyleResource | ScriptResource | PreloadResource; +type Resource = + | StyleResource + | ScriptResource + | PreloadResource + | PreconnectResource; export type Resources = { // Request local cache preloadsMap: Map, + preconnectsMap: Map, stylesMap: Map, scriptsMap: Map, // Flushing queues for Resource dependencies + preconnects: Set, fontPreloads: Set, // usedImagePreloads: Set, precedences: Map>, @@ -4161,10 +4186,12 @@ export function createResources(): Resources { return { // persistent preloadsMap: new Map(), + preconnectsMap: new Map(), stylesMap: new Map(), scriptsMap: new Map(), // cleared on flush + preconnects: new Set(), fontPreloads: new Set(), // usedImagePreloads: new Set(), precedences: new Map(), @@ -4198,6 +4225,120 @@ function getResourceKey(as: string, href: string): string { return `[${as}]${href}`; } +export function prefetchDNS(href: string, options?: mixed) { + if (!currentResources) { + // While we expect that preconnect calls are primarily going to be observed + // during render because effects and events don't run on the server it is + // still possible that these get called in module scope. This is valid on + // the client since there is still a document to interact with but on the + // server we need a request to associate the call to. Because of this we + // simply return and do not warn. + return; + } + const resources = currentResources; + if (__DEV__) { + if (typeof href !== 'string' || !href) { + console.error( + 'ReactDOM.prefetchDNS(): Expected the `href` argument (first) to be a non-empty string but encountered %s instead.', + getValueDescriptorExpectingObjectForWarning(href), + ); + } else if (options != null) { + if ( + typeof options === 'object' && + options.hasOwnProperty('crossOrigin') + ) { + console.error( + 'ReactDOM.prefetchDNS(): Expected only one argument, `href`, but encountered %s as a second argument instead. This argument is reserved for future options and is currently disallowed. It looks like the you are attempting to set a crossOrigin property for this DNS lookup hint. Browsers do not perform DNS queries using CORS and setting this attribute on the resource hint has no effect. Try calling ReactDOM.prefetchDNS() with just a single string argument, `href`.', + getValueDescriptorExpectingEnumForWarning(options), + ); + } else { + console.error( + 'ReactDOM.prefetchDNS(): Expected only one argument, `href`, but encountered %s as a second argument instead. This argument is reserved for future options and is currently disallowed. Try calling ReactDOM.prefetchDNS() with just a single string argument, `href`.', + getValueDescriptorExpectingEnumForWarning(options), + ); + } + } + } + + if (typeof href === 'string' && href) { + const key = getResourceKey('prefetchDNS', href); + let resource = resources.preconnectsMap.get(key); + if (!resource) { + resource = { + type: 'preconnect', + chunks: [], + state: NoState, + props: null, + }; + resources.preconnectsMap.set(key, resource); + pushLinkImpl( + resource.chunks, + ({href, rel: 'dns-prefetch'}: PreconnectProps), + ); + } + resources.preconnects.add(resource); + } +} + +export function preconnect(href: string, options?: {crossOrigin?: string}) { + if (!currentResources) { + // While we expect that preconnect calls are primarily going to be observed + // during render because effects and events don't run on the server it is + // still possible that these get called in module scope. This is valid on + // the client since there is still a document to interact with but on the + // server we need a request to associate the call to. Because of this we + // simply return and do not warn. + return; + } + const resources = currentResources; + if (__DEV__) { + if (typeof href !== 'string' || !href) { + console.error( + 'ReactDOM.preconnect(): Expected the `href` argument (first) to be a non-empty string but encountered %s instead.', + getValueDescriptorExpectingObjectForWarning(href), + ); + } else if (options != null && typeof options !== 'object') { + console.error( + 'ReactDOM.preconnect(): Expected the `options` argument (second) to be an object but encountered %s instead. The only supported option at this time is `crossOrigin` which accepts a string.', + getValueDescriptorExpectingEnumForWarning(options), + ); + } else if (options != null && typeof options.crossOrigin !== 'string') { + console.error( + 'ReactDOM.preconnect(): Expected the `crossOrigin` option (second argument) to be a string but encountered %s instead. Try removing this option or passing a string value instead.', + getValueDescriptorExpectingObjectForWarning(options.crossOrigin), + ); + } + } + + if (typeof href === 'string' && href) { + const crossOrigin = + options == null || typeof options.crossOrigin !== 'string' + ? null + : options.crossOrigin === 'use-credentials' + ? 'use-credentials' + : ''; + + const key = `[preconnect][${ + crossOrigin === null ? 'null' : crossOrigin + }]${href}`; + let resource = resources.preconnectsMap.get(key); + if (!resource) { + resource = { + type: 'preconnect', + chunks: [], + state: NoState, + props: null, + }; + resources.preconnectsMap.set(key, resource); + pushLinkImpl( + resource.chunks, + ({rel: 'preconnect', href, crossOrigin}: PreconnectProps), + ); + } + resources.preconnects.add(resource); + } +} + type PreloadAs = 'style' | 'font' | 'script'; type PreloadOptions = { as: PreloadAs, diff --git a/packages/react-dom-bindings/src/shared/ReactDOMFloat.js b/packages/react-dom-bindings/src/shared/ReactDOMFloat.js index 3fabd9fb922f3..99a867286d361 100644 --- a/packages/react-dom-bindings/src/shared/ReactDOMFloat.js +++ b/packages/react-dom-bindings/src/shared/ReactDOMFloat.js @@ -1,11 +1,21 @@ import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals'; -export function preinit() { +export function prefetchDNS() { const dispatcher = ReactDOMSharedInternals.Dispatcher.current; if (dispatcher) { - dispatcher.preinit.apply(this, arguments); + dispatcher.prefetchDNS.apply(this, arguments); } - // We don't error because preinit needs to be resilient to being called in a variety of scopes + // We don't error because preconnect needs to be resilient to being called in a variety of scopes + // and the runtime may not be capable of responding. The function is optimistic and not critical + // so we favor silent bailout over warning or erroring. +} + +export function preconnect() { + const dispatcher = ReactDOMSharedInternals.Dispatcher.current; + if (dispatcher) { + dispatcher.preconnect.apply(this, arguments); + } + // We don't error because preconnect needs to be resilient to being called in a variety of scopes // and the runtime may not be capable of responding. The function is optimistic and not critical // so we favor silent bailout over warning or erroring. } @@ -19,3 +29,13 @@ export function preload() { // and the runtime may not be capable of responding. The function is optimistic and not critical // so we favor silent bailout over warning or erroring. } + +export function preinit() { + const dispatcher = ReactDOMSharedInternals.Dispatcher.current; + if (dispatcher) { + dispatcher.preinit.apply(this, arguments); + } + // We don't error because preinit needs to be resilient to being called in a variety of scopes + // and the runtime may not be capable of responding. The function is optimistic and not critical + // so we favor silent bailout over warning or erroring. +} diff --git a/packages/react-dom/index.classic.fb.js b/packages/react-dom/index.classic.fb.js index 3b559bb43c419..65425ce24a235 100644 --- a/packages/react-dom/index.classic.fb.js +++ b/packages/react-dom/index.classic.fb.js @@ -32,8 +32,10 @@ export { unstable_flushControlled, unstable_renderSubtreeIntoContainer, unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority. - preinit, + prefetchDNS, + preconnect, preload, + preinit, version, } from './src/client/ReactDOM'; diff --git a/packages/react-dom/index.experimental.js b/packages/react-dom/index.experimental.js index 7b8326fb30fbe..5e905c19a6f57 100644 --- a/packages/react-dom/index.experimental.js +++ b/packages/react-dom/index.experimental.js @@ -20,7 +20,9 @@ export { unstable_batchedUpdates, unstable_renderSubtreeIntoContainer, unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority. - preinit, + prefetchDNS, + preconnect, preload, + preinit, version, } from './src/client/ReactDOM'; diff --git a/packages/react-dom/index.js b/packages/react-dom/index.js index 1ea57d00072e5..f57e1e97c9249 100644 --- a/packages/react-dom/index.js +++ b/packages/react-dom/index.js @@ -24,7 +24,9 @@ export { unstable_flushControlled, unstable_renderSubtreeIntoContainer, unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority. - preinit, + prefetchDNS, + preconnect, preload, + preinit, version, } from './src/client/ReactDOM'; diff --git a/packages/react-dom/index.modern.fb.js b/packages/react-dom/index.modern.fb.js index deddb938ec987..cac8ec594f7b5 100644 --- a/packages/react-dom/index.modern.fb.js +++ b/packages/react-dom/index.modern.fb.js @@ -17,7 +17,9 @@ export { unstable_createEventHandle, unstable_flushControlled, unstable_runWithPriority, // DO NOT USE: Temporarily exposed to migrate off of Scheduler.runWithPriority. - preinit, + prefetchDNS, + preconnect, preload, + preinit, version, } from './src/client/ReactDOM'; diff --git a/packages/react-dom/index.stable.js b/packages/react-dom/index.stable.js index 5d64bf771c6f6..b3b07fe97dc6d 100644 --- a/packages/react-dom/index.stable.js +++ b/packages/react-dom/index.stable.js @@ -19,7 +19,9 @@ export { unmountComponentAtNode, unstable_batchedUpdates, unstable_renderSubtreeIntoContainer, - preinit, + prefetchDNS, + preconnect, preload, + preinit, version, } from './src/client/ReactDOM'; diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 5f59f81731eb4..e1cc69988c801 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -2295,6 +2295,147 @@ body { ); }); + describe('ReactDOM.prefetchDNS(href)', () => { + it('creates a dns-prefetch resource when called', async () => { + function App({url}) { + ReactDOM.prefetchDNS(url); + ReactDOM.prefetchDNS(url); + ReactDOM.prefetchDNS(url, {}); + ReactDOM.prefetchDNS(url, {crossOrigin: 'use-credentials'}); + return ( + + hello world + + ); + } + + await expect(async () => { + await actIntoEmptyDocument(() => { + renderToPipeableStream().pipe(writable); + }); + }).toErrorDev([ + 'ReactDOM.prefetchDNS(): Expected only one argument, `href`, but encountered something with type "object" as a second argument instead. This argument is reserved for future options and is currently disallowed. Try calling ReactDOM.prefetchDNS() with just a single string argument, `href`.', + 'ReactDOM.prefetchDNS(): Expected only one argument, `href`, but encountered something with type "object" as a second argument instead. This argument is reserved for future options and is currently disallowed. It looks like the you are attempting to set a crossOrigin property for this DNS lookup hint. Browsers do not perform DNS queries using CORS and setting this attribute on the resource hint has no effect. Try calling ReactDOM.prefetchDNS() with just a single string argument, `href`.', + ]); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + hello world + , + ); + + const root = ReactDOMClient.hydrateRoot(document, ); + expect(() => { + expect(Scheduler).toFlushWithoutYielding(); + }).toErrorDev([ + 'ReactDOM.prefetchDNS(): Expected only one argument, `href`, but encountered something with type "object" as a second argument instead. This argument is reserved for future options and is currently disallowed. Try calling ReactDOM.prefetchDNS() with just a single string argument, `href`.', + 'ReactDOM.prefetchDNS(): Expected only one argument, `href`, but encountered something with type "object" as a second argument instead. This argument is reserved for future options and is currently disallowed. It looks like the you are attempting to set a crossOrigin property for this DNS lookup hint. Browsers do not perform DNS queries using CORS and setting this attribute on the resource hint has no effect. Try calling ReactDOM.prefetchDNS() with just a single string argument, `href`.', + ]); + expect(getMeaningfulChildren(document)).toEqual( + + + + + hello world + , + ); + + root.render(); + expect(() => { + expect(Scheduler).toFlushWithoutYielding(); + }).toErrorDev([ + 'ReactDOM.prefetchDNS(): Expected only one argument, `href`, but encountered something with type "object" as a second argument instead. This argument is reserved for future options and is currently disallowed. Try calling ReactDOM.prefetchDNS() with just a single string argument, `href`.', + 'ReactDOM.prefetchDNS(): Expected only one argument, `href`, but encountered something with type "object" as a second argument instead. This argument is reserved for future options and is currently disallowed. It looks like the you are attempting to set a crossOrigin property for this DNS lookup hint. Browsers do not perform DNS queries using CORS and setting this attribute on the resource hint has no effect. Try calling ReactDOM.prefetchDNS() with just a single string argument, `href`.', + ]); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + hello world + , + ); + }); + }); + + describe('ReactDOM.preconnect(href, { crossOrigin })', () => { + it('creates a preconnect resource when called', async () => { + function App({url}) { + ReactDOM.preconnect(url); + ReactDOM.preconnect(url); + ReactDOM.preconnect(url, {crossOrigin: true}); + ReactDOM.preconnect(url, {crossOrigin: ''}); + ReactDOM.preconnect(url, {crossOrigin: 'anonymous'}); + ReactDOM.preconnect(url, {crossOrigin: 'use-credentials'}); + return ( + + hello world + + ); + } + + await expect(async () => { + await actIntoEmptyDocument(() => { + renderToPipeableStream().pipe(writable); + }); + }).toErrorDev( + 'ReactDOM.preconnect(): Expected the `crossOrigin` option (second argument) to be a string but encountered something with type "boolean" instead. Try removing this option or passing a string value instead.', + ); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + hello world + , + ); + + const root = ReactDOMClient.hydrateRoot(document, ); + expect(() => { + expect(Scheduler).toFlushWithoutYielding(); + }).toErrorDev( + 'ReactDOM.preconnect(): Expected the `crossOrigin` option (second argument) to be a string but encountered something with type "boolean" instead. Try removing this option or passing a string value instead.', + ); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + hello world + , + ); + + root.render(); + expect(() => { + expect(Scheduler).toFlushWithoutYielding(); + }).toErrorDev( + 'ReactDOM.preconnect(): Expected the `crossOrigin` option (second argument) to be a string but encountered something with type "boolean" instead. Try removing this option or passing a string value instead.', + ); + expect(getMeaningfulChildren(document)).toEqual( + + + + + + + + + + hello world + , + ); + }); + }); + describe('ReactDOM.preload(href, { as: ... })', () => { // @gate enableFloat it('creates a preload resource when called', async () => { diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js index 2da511119aec1..aa7526e15d192 100644 --- a/packages/react-dom/src/client/ReactDOM.js +++ b/packages/react-dom/src/client/ReactDOM.js @@ -75,7 +75,12 @@ import { } from 'react-dom-bindings/src/events/ReactDOMControlledComponent'; import Internals from '../ReactDOMSharedInternals'; -export {preinit, preload} from 'react-dom-bindings/src/shared/ReactDOMFloat'; +export { + prefetchDNS, + preconnect, + preload, + preinit, +} from 'react-dom-bindings/src/shared/ReactDOMFloat'; setAttemptSynchronousHydration(attemptSynchronousHydration); setAttemptDiscreteHydration(attemptDiscreteHydration);