@samitouri / QOS-React-2 / commits / fc929cf4ea

[Float][Fizz][Fiber] support imagesrcset and imagesizes for `ReactDOM.preload()` (#26940)

For float methods the href argument is usually all we need to uniquely key the request. However when preloading responsive images it is possible that you may need more than one preload for differing imagesizes attributes. When using imagesrcset for preloads the href attribute acts more like a fallback href. For keying purposes the imagesrcset becomes the primary key conceptually. This change updates the keying logic for `ReactDOM.preload()` when you pass `{as: "image"}` 1. If `options.imageSrcSet` is a non-emtpy string the key is defined as `options.imageSrcSet + options.imageSizes`. The `href` argument is still required but does not participate in keying. 2. If `options.imageSrcSet` is empty, missing, or an invalid format the key is defined as the `href`. Changing the `options.imageSizes` does not affect the key as this option is inert when not using `options.imageSrcSet` Additionally, currently there is a bug in webkit (Safari) that causes preload links to fail to use imageSrcSet and fallback to href even when the browser will correctly resolve srcset on an `<img>` tag. Because the drawbacks of preloading the wrong image (href over imagesrcset) in a modern browser outweight the drawbacks of not preloading anything for responsive images in browsers that do not support srcset at all we will omit the `href` attribute whenever `options.imageSrcSet` is provided. We still require you provide an href since we want to be able to revert this behavior once all major browsers support it bug link: https://bugs.webkit.org/show_bug.cgi?id=231150

Josh Story committed Jun 15, 2023 at 14:50 UTC fc929cf4ead35f99c4e9612a95e8a0bb8f5df25d
7 files changed +254 -86
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+57 -8
@@ -105,7 +105,6 @@ import {
105 } from 'react-reconciler/src/ReactWorkTags';
106 import {listenToAllSupportedEvents} from '../events/DOMPluginEventSystem';
107 import {
108 - validatePreloadArguments,
108 validatePreinitArguments,
109 validateLinkPropsForStyleResource,
110 getValueDescriptorExpectingObjectForWarning,
@@ -2016,7 +2015,7 @@ type ScriptProps = {
2015
2016 type PreloadProps = {
2017 rel: 'preload',
2019 - href: string,
2018 + href: ?string,
2019 [string]: mixed,
2020 };
2021
@@ -2167,7 +2166,29 @@ function preload(href: string, options: PreloadOptions) {
2166 return;
2167 }
2168 if (__DEV__) {
2170 - validatePreloadArguments(href, options);
2169 + // TODO move this to ReactDOMFloat and expose a stricter function interface or possibly
2170 + // typed functions (preloadImage, preloadStyle, ...)
2171 + let encountered = '';
2172 + if (typeof href !== 'string' || !href) {
2173 + encountered += `The \`href\` argument encountered was ${getValueDescriptorExpectingObjectForWarning(
2174 + href,
2175 + )}.`;
2176 + }
2177 + if (options == null || typeof options !== 'object') {
2178 + encountered += `The \`options\` argument encountered was ${getValueDescriptorExpectingObjectForWarning(
2179 + options,
2180 + )}.`;
2181 + } else if (typeof options.as !== 'string' || !options.as) {
2182 + encountered += `The \`as\` option encountered was ${getValueDescriptorExpectingObjectForWarning(
2183 + options.as,
2184 + )}.`;
2185 + }
2186 + if (encountered) {
2187 + console.error(
2188 + 'ReactDOM.preload(): Expected two arguments, a non-empty `href` string and an `options` object with an `as` property valid for a `<link rel="preload" as="..." />` tag. %s',
2189 + encountered,
2190 + );
2191 + }
2192 }
2193 const ownerDocument = getDocumentForImperativeFloatMethods();
2194 if (
@@ -2175,13 +2196,35 @@ function preload(href: string, options: PreloadOptions) {
2196 href &&
2197 typeof options === 'object' &&
2198 options !== null &&
2199 + typeof options.as === 'string' &&
2200 + options.as &&
2201 ownerDocument
2202 ) {
2203 const as = options.as;
2181 - const limitedEscapedHref =
2182 - escapeSelectorAttributeValueInsideDoubleQuotes(href);
2183 - const preloadSelector = `link[rel="preload"][as="${as}"][href="${limitedEscapedHref}"]`;
2184 -
2204 + let preloadSelector = `link[rel="preload"][as="${escapeSelectorAttributeValueInsideDoubleQuotes(
2205 + as,
2206 + )}"]`;
2207 + if (as === 'image') {
2208 + const {imageSrcSet, imageSizes} = options;
2209 + if (typeof imageSrcSet === 'string' && imageSrcSet !== '') {
2210 + preloadSelector += `[imagesrcset="${escapeSelectorAttributeValueInsideDoubleQuotes(
2211 + imageSrcSet,
2212 + )}"]`;
2213 + if (typeof imageSizes === 'string') {
2214 + preloadSelector += `[imagesizes="${escapeSelectorAttributeValueInsideDoubleQuotes(
2215 + imageSizes,
2216 + )}"]`;
2217 + }
2218 + } else {
2219 + preloadSelector += `[href="${escapeSelectorAttributeValueInsideDoubleQuotes(
2220 + href,
2221 + )}"]`;
2222 + }
2223 + } else {
2224 + preloadSelector += `[href="${escapeSelectorAttributeValueInsideDoubleQuotes(
2225 + href,
2226 + )}"]`;
2227 + }
2228 // Some preloads are keyed under their selector. This happens when the preload is for
2229 // an arbitrary type. Other preloads are keyed under the resource key they represent a preload for.
2230 // Here we figure out which key to use to determine if we have a preload already.
@@ -2227,14 +2270,20 @@ function preloadPropsFromPreloadOptions(
2270 options: PreloadOptions,
2271 ): PreloadProps {
2272 return {
2230 - href,
2273 rel: 'preload',
2274 as,
2275 + // There is a bug in Safari where imageSrcSet is not respected on preload links
2276 + // so we omit the href here if we have imageSrcSet b/c safari will load the wrong image.
2277 + // This harms older browers that do not support imageSrcSet by making their preloads not work
2278 + // but this population is shrinking fast and is already small so we accept this tradeoff.
2279 + href: as === 'image' && options.imageSrcSet ? undefined : href,
2280 crossOrigin: as === 'font' ? '' : options.crossOrigin,
2281 integrity: options.integrity,
2282 type: options.type,
2283 nonce: options.nonce,
2284 fetchPriority: options.fetchPriority,
2285 + imageSrcSet: options.imageSrcSet,
2286 + imageSizes: options.imageSizes,
2287 };
2288 }
2289
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+47 -17
@@ -4819,12 +4819,12 @@ type PreconnectResource = TResource<'preconnect', null>;
4819 type PreloadAsProps = {
4820 rel: 'preload',
4821 as: string,
4822 - href: string,
4822 + href: ?string,
4823 [string]: mixed,
4824 };
4825 type PreloadModuleProps = {
4826 rel: 'modulepreload',
4827 - href: string,
4827 + href: ?string,
4828 [string]: mixed,
4829 };
4830 type PreloadProps = PreloadAsProps | PreloadModuleProps;
@@ -5063,20 +5063,25 @@ export function preload(href: string, options: PreloadOptions) {
5063 }
5064 const resources = getResources(request);
5065 if (__DEV__) {
5066 + let encountered = '';
5067 if (typeof href !== 'string' || !href) {
5068 + encountered += ` The \`href\` argument encountered was ${getValueDescriptorExpectingObjectForWarning(
5069 + href,
5070 + )}.`;
5071 + }
5072 + if (options == null || typeof options !== 'object') {
5073 + encountered += ` The \`options\` argument encountered was ${getValueDescriptorExpectingObjectForWarning(
5074 + options,
5075 + )}.`;
5076 + } else if (typeof options.as !== 'string' || !options.as) {
5077 + encountered += ` The \`as\` option encountered was ${getValueDescriptorExpectingObjectForWarning(
5078 + options.as,
5079 + )}.`;
5080 + }
5081 + if (encountered) {
5082 console.error(
5068 - 'ReactDOM.preload(): Expected the `href` argument (first) to be a non-empty string but encountered %s instead.',
5069 - getValueDescriptorExpectingObjectForWarning(href),
5070 - );
5071 - } else if (options == null || typeof options !== 'object') {
5072 - console.error(
5073 - 'ReactDOM.preload(): Expected the `options` argument (second) to be an object with an `as` property describing the type of resource to be preloaded but encountered %s instead.',
5074 - getValueDescriptorExpectingEnumForWarning(options),
5075 - );
5076 - } else if (typeof options.as !== 'string') {
5077 - console.error(
5078 - 'ReactDOM.preload(): Expected the `as` property in the `options` argument (second) to contain a string value describing the type of resource to be preloaded but encountered %s instead. Values that are valid in for the `as` attribute of a `<link rel="preload" as="..." />` tag are valid here.',
5079 - getValueDescriptorExpectingEnumForWarning(options.as),
5083 + 'ReactDOM.preload(): Expected two arguments, a non-empty `href` string and an `options` object with an `as` property valid for a `<link rel="preload" as="..." />` tag.%s',
5084 + encountered,
5085 );
5086 }
5087 }
@@ -5085,10 +5090,29 @@ export function preload(href: string, options: PreloadOptions) {
5090 href &&
5091 typeof options === 'object' &&
5092 options !== null &&
5088 - typeof options.as === 'string'
5093 + typeof options.as === 'string' &&
5094 + options.as
5095 ) {
5096 const as = options.as;
5091 - const key = getResourceKey(as, href);
5097 + let key: string;
5098 + if (as === 'image') {
5099 + // For image preloads the key contains either the imageSrcSet + imageSizes or the href but not
5100 + // both. This is to prevent identical calls with the same srcSet and sizes to be duplicated
5101 + // by varying the href. this is an edge case but it is the most correct behavior.
5102 + const {imageSrcSet, imageSizes} = options;
5103 + let uniquePart = '';
5104 + if (typeof imageSrcSet === 'string' && imageSrcSet !== '') {
5105 + uniquePart += '[' + imageSrcSet + ']';
5106 + if (typeof imageSizes === 'string') {
5107 + uniquePart += '[' + imageSizes + ']';
5108 + }
5109 + } else {
5110 + uniquePart += '[][]' + href;
5111 + }
5112 + key = getResourceKey(as, uniquePart);
5113 + } else {
5114 + key = getResourceKey(as, href);
5115 + }
5116 let resource = resources.preloadsMap.get(key);
5117 if (__DEV__) {
5118 const devResource = getAsResourceDEV(resource);
@@ -5528,12 +5552,18 @@ function preloadPropsFromPreloadOptions(
5552 return {
5553 rel: 'preload',
5554 as,
5531 - href,
5555 + // There is a bug in Safari where imageSrcSet is not respected on preload links
5556 + // so we omit the href here if we have imageSrcSet b/c safari will load the wrong image.
5557 + // This harms older browers that do not support imageSrcSet by making their preloads not work
5558 + // but this population is shrinking fast and is already small so we accept this tradeoff.
5559 + href: as === 'image' && options.imageSrcSet ? undefined : href,
5560 crossOrigin: as === 'font' ? '' : options.crossOrigin,
5561 integrity: options.integrity,
5562 type: options.type,
5563 nonce: options.nonce,
5564 fetchPriority: options.fetchPriority,
5565 + imageSrcSet: options.imageSrcSet,
5566 + imageSizes: options.imageSizes,
5567 };
5568 }
5569
packages/react-dom-bindings/src/shared/ReactDOMResourceValidation.js
-54
@@ -62,60 +62,6 @@ function propNamesListJoin(
62 }
63 }
64
65 -export function validatePreloadArguments(href: mixed, options: mixed) {
66 - if (__DEV__) {
67 - if (!href || typeof href !== 'string') {
68 - const typeOfArg = getValueDescriptorExpectingObjectForWarning(href);
69 - console.error(
70 - 'ReactDOM.preload() expected the first argument to be a string representing an href but found %s instead.',
71 - typeOfArg,
72 - );
73 - } else if (typeof options !== 'object' || options === null) {
74 - const typeOfArg = getValueDescriptorExpectingObjectForWarning(options);
75 - console.error(
76 - 'ReactDOM.preload() expected the second argument to be an options argument containing at least an "as" property' +
77 - ' specifying the Resource type. It found %s instead. The href for the preload call where this warning originated is "%s".',
78 - typeOfArg,
79 - href,
80 - );
81 - } else {
82 - const as = options.as;
83 - switch (as) {
84 - // Font specific validation of options
85 - case 'font': {
86 - if (options.crossOrigin === 'use-credentials') {
87 - console.error(
88 - 'ReactDOM.preload() was called with an "as" type of "font" and with a "crossOrigin" option of "use-credentials".' +
89 - ' Fonts preloading must use crossOrigin "anonymous" to be functional. Please update your font preload to omit' +
90 - ' the crossOrigin option or change it to any other value than "use-credentials" (Browsers default all other values' +
91 - ' to anonymous mode). The href for the preload call where this warning originated is "%s"',
92 - href,
93 - );
94 - }
95 - break;
96 - }
97 - case 'script':
98 - case 'style': {
99 - break;
100 - }
101 -
102 - // We have an invalid as type and need to warn
103 - default: {
104 - const typeOfAs = getValueDescriptorExpectingEnumForWarning(as);
105 - console.error(
106 - 'ReactDOM.preload() expected a valid "as" type in the options (second) argument but found %s instead.' +
107 - ' Please use one of the following valid values instead: %s. The href for the preload call where this' +
108 - ' warning originated is "%s".',
109 - typeOfAs,
110 - '"style", "font", or "script"',
111 - href,
112 - );
113 - }
114 - }
115 - }
116 - }
117 -}
118 -
65 export function validatePreinitArguments(href: mixed, options: mixed) {
66 if (__DEV__) {
67 if (!href || typeof href !== 'string') {
packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+145 -4
@@ -3545,6 +3545,147 @@ body {
3545 );
3546 });
3547
3548 + it('uses imageSrcSet and imageSizes when keying image preloads', async () => {
3549 + function App({isClient}) {
3550 + // Will key off href in absense of imageSrcSet
3551 + ReactDOM.preload('foo', {as: 'image'});
3552 + ReactDOM.preload('foo', {as: 'image'});
3553 +
3554 + // Will key off imageSrcSet + imageSizes
3555 + ReactDOM.preload('foo', {as: 'image', imageSrcSet: 'fooset'});
3556 + ReactDOM.preload('foo2', {as: 'image', imageSrcSet: 'fooset'});
3557 +
3558 + // Will key off imageSrcSet + imageSizes
3559 + ReactDOM.preload('foo', {
3560 + as: 'image',
3561 + imageSrcSet: 'fooset',
3562 + imageSizes: 'foosizes',
3563 + });
3564 + ReactDOM.preload('foo2', {
3565 + as: 'image',
3566 + imageSrcSet: 'fooset',
3567 + imageSizes: 'foosizes',
3568 + });
3569 +
3570 + // Will key off href in absense of imageSrcSet, imageSizes is ignored. these should match the
3571 + // first preloads not not emit a new preload tag
3572 + ReactDOM.preload('foo', {as: 'image', imageSizes: 'foosizes'});
3573 + ReactDOM.preload('foo', {as: 'image', imageSizes: 'foosizes'});
3574 +
3575 + // These preloads are for something that isn't an image
3576 + // They should all key off the href
3577 + ReactDOM.preload('bar', {as: 'somethingelse'});
3578 + ReactDOM.preload('bar', {
3579 + as: 'somethingelse',
3580 + imageSrcSet: 'makes no sense',
3581 + });
3582 + ReactDOM.preload('bar', {
3583 + as: 'somethingelse',
3584 + imageSrcSet: 'makes no sense',
3585 + imageSizes: 'makes no sense',
3586 + });
3587 +
3588 + if (isClient) {
3589 + // Will key off href in absense of imageSrcSet
3590 + ReactDOM.preload('client', {as: 'image'});
3591 + ReactDOM.preload('client', {as: 'image'});
3592 +
3593 + // Will key off imageSrcSet + imageSizes
3594 + ReactDOM.preload('client', {as: 'image', imageSrcSet: 'clientset'});
3595 + ReactDOM.preload('client2', {as: 'image', imageSrcSet: 'clientset'});
3596 +
3597 + // Will key off imageSrcSet + imageSizes
3598 + ReactDOM.preload('client', {
3599 + as: 'image',
3600 + imageSrcSet: 'clientset',
3601 + imageSizes: 'clientsizes',
3602 + });
3603 + ReactDOM.preload('client2', {
3604 + as: 'image',
3605 + imageSrcSet: 'clientset',
3606 + imageSizes: 'clientsizes',
3607 + });
3608 +
3609 + // Will key off href in absense of imageSrcSet, imageSizes is ignored. these should match the
3610 + // first preloads not not emit a new preload tag
3611 + ReactDOM.preload('client', {as: 'image', imageSizes: 'clientsizes'});
3612 + ReactDOM.preload('client', {as: 'image', imageSizes: 'clientsizes'});
3613 + }
3614 +
3615 + return (
3616 + <html>
3617 + <body>hello</body>
3618 + </html>
3619 + );
3620 + }
3621 +
3622 + await act(() => {
3623 + renderToPipeableStream(<App />).pipe(writable);
3624 + });
3625 + expect(getMeaningfulChildren(document)).toEqual(
3626 + <html>
3627 + <head>
3628 + <link rel="preload" as="image" href="foo" />
3629 + <link rel="preload" as="image" imagesrcset="fooset" />
3630 + <link
3631 + rel="preload"
3632 + as="image"
3633 + imagesrcset="fooset"
3634 + imagesizes="foosizes"
3635 + />
3636 + <link rel="preload" as="somethingelse" href="bar" />
3637 + </head>
3638 + <body>hello</body>
3639 + </html>,
3640 + );
3641 +
3642 + const root = ReactDOMClient.hydrateRoot(document, <App />);
3643 + await waitForAll([]);
3644 + expect(getMeaningfulChildren(document)).toEqual(
3645 + <html>
3646 + <head>
3647 + <link rel="preload" as="image" href="foo" />
3648 + <link rel="preload" as="image" imagesrcset="fooset" />
3649 + <link
3650 + rel="preload"
3651 + as="image"
3652 + imagesrcset="fooset"
3653 + imagesizes="foosizes"
3654 + />
3655 + <link rel="preload" as="somethingelse" href="bar" />
3656 + </head>
3657 + <body>hello</body>
3658 + </html>,
3659 + );
3660 +
3661 + root.render(<App isClient={true} />);
3662 + await waitForAll([]);
3663 + expect(getMeaningfulChildren(document)).toEqual(
3664 + <html>
3665 + <head>
3666 + <link rel="preload" as="image" href="foo" />
3667 + <link rel="preload" as="image" imagesrcset="fooset" />
3668 + <link
3669 + rel="preload"
3670 + as="image"
3671 + imagesrcset="fooset"
3672 + imagesizes="foosizes"
3673 + />
3674 + <link rel="preload" as="somethingelse" href="bar" />
3675 + <link rel="preload" as="image" href="client" />
3676 + <link rel="preload" as="image" imagesrcset="clientset" />
3677 + <link
3678 + rel="preload"
3679 + as="image"
3680 + imagesrcset="clientset"
3681 + imagesizes="clientsizes"
3682 + />
3683 + </head>
3684 + <body>hello</body>
3685 + </html>,
3686 + );
3687 + });
3688 +
3689 describe('ReactDOM.prefetchDNS(href)', () => {
3690 it('creates a dns-prefetch resource when called', async () => {
3691 function App({url}) {
@@ -3834,10 +3975,10 @@ body {
3975 renderToPipeableStream(<App />).pipe(writable);
3976 });
3977 }).toErrorDev([
3837 - 'ReactDOM.preload(): Expected the `href` argument (first) to be a non-empty string but encountered `undefined` instead.',
3838 - 'ReactDOM.preload(): Expected the `href` argument (first) to be a non-empty string but encountered an empty string instead.',
3839 - 'ReactDOM.preload(): Expected the `options` argument (second) to be an object with an `as` property describing the type of resource to be preloaded but encountered `null` instead.',
3840 - 'ReactDOM.preload(): Expected the `as` property in the `options` argument (second) to contain a string value describing the type of resource to be preloaded but encountered `undefined` instead. Values that are valid in for the `as` attribute of a `<link rel="preload" as="..." />` tag are valid here.',
3978 + 'ReactDOM.preload(): Expected two arguments, a non-empty `href` string and an `options` object with an `as` property valid for a `<link rel="preload" as="..." />` tag. The `href` argument encountered was `undefined`. The `options` argument encountered was `undefined`.',
3979 + 'ReactDOM.preload(): Expected two arguments, a non-empty `href` string and an `options` object with an `as` property valid for a `<link rel="preload" as="..." />` tag. The `href` argument encountered was an empty string. The `options` argument encountered was `undefined`.',
3980 + 'ReactDOM.preload(): Expected two arguments, a non-empty `href` string and an `options` object with an `as` property valid for a `<link rel="preload" as="..." />` tag. The `options` argument encountered was `null`.',
3981 + 'ReactDOM.preload(): Expected two arguments, a non-empty `href` string and an `options` object with an `as` property valid for a `<link rel="preload" as="..." />` tag. The `as` option encountered was `undefined`.',
3982 ]);
3983 });
3984
packages/react-dom/src/shared/ReactDOMTypes.js
+2
@@ -16,6 +16,8 @@ export type PreloadOptions = {
16 type?: string,
17 nonce?: string,
18 fetchPriority?: 'high' | 'low' | 'auto',
19 + imageSrcSet?: string,
20 + imageSizes?: string,
21 };
22 export type PreinitOptions = {
23 as: string,
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js
+2 -2
@@ -1190,8 +1190,8 @@ describe('ReactFlightDOM', () => {
1190 root.render(<App />);
1191 });
1192 expect(document.head.innerHTML).toBe(
1193 - '<link href="before" rel="preload" as="style">' +
1194 - '<link href="after" rel="preload" as="style">',
1193 + '<link rel="preload" as="style" href="before">' +
1194 + '<link rel="preload" as="style" href="after">',
1195 );
1196 expect(container.innerHTML).toBe('<p>hello world</p>');
1197 });
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js
+1 -1
@@ -1101,7 +1101,7 @@ describe('ReactFlightDOMBrowser', () => {
1101 root.render(<App />);
1102 });
1103 expect(document.head.innerHTML).toBe(
1104 - '<link href="before" rel="preload" as="style">',
1104 + '<link rel="preload" as="style" href="before">',
1105 );
1106 expect(container.innerHTML).toBe('<p>hello world</p>');
1107 });