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

[Fizz] Preload "suspensey" images (#27191)

Eventually we will treat images without `loading="lazy"` as suspensey meaning we will coordinate the reveal of boundaries when these images have loaded and ideally decoded. As a step in that direction this change prioritizes these images for preloading to ensure the highest chance that they are loaded before boundaries reveal (or initial paint). every img rendered that is non lazy loading will emit a preload just behind fonts. This change implements a new resource queue for high priority image preloads There are a number of scenarios where we end up putting a preload in this queue 1. If you render a non-lazy image and there are fewer than 10 high priority image preloads 2. if you render a non-lazy image with fetchPriority "high" 3. if you preload as "image" with fetchPriority "high" This means that by default we won't overrsaturate this queue with every img rendered on the page but the earlier encountered ones will go first. Essentially this is React's own implementation of fetchPriority="auto". If however you specify that the fetchPriority is higher then in theory an unlimited number of images can preload in this queue. This gives users some control over queuing while still providing a good default that does not require any opting into Additionally we use fetchPriority "low" as a signal that an image does not require preloading. This may end up being pointless if not using lazy (which also opts out of preloading) because it might delay initial paint but we'll start with this hueristic and consider changes in the future when we have more information

Josh Story committed Aug 10, 2023 at 15:39 UTC f359f9b41ac1a8127f5ba505e0c04675eee0d310
2 files changed +250 -18
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+93 -18
@@ -2370,6 +2370,78 @@ function pushStyleContents(
2370 return;
2371 }
2372
2373 +function getImagePreloadKey(
2374 + href: string,
2375 + imageSrcSet: ?string,
2376 + imageSizes: ?string,
2377 +) {
2378 + let uniquePart = '';
2379 + if (typeof imageSrcSet === 'string' && imageSrcSet !== '') {
2380 + uniquePart += '[' + imageSrcSet + ']';
2381 + if (typeof imageSizes === 'string') {
2382 + uniquePart += '[' + imageSizes + ']';
2383 + }
2384 + } else {
2385 + uniquePart += '[][]' + href;
2386 + }
2387 + return getResourceKey('image', uniquePart);
2388 +}
2389 +
2390 +function pushImg(
2391 + target: Array<Chunk | PrecomputedChunk>,
2392 + props: Object,
2393 + resources: Resources,
2394 +): null {
2395 + if (
2396 + props.loading !== 'lazy' &&
2397 + typeof props.src === 'string' &&
2398 + props.fetchPriority !== 'low'
2399 + ) {
2400 + // We have a suspensey image and ought to preload it to optimize the loading of display blocking
2401 + // resources.
2402 + const {src, imageSrcSet, imageSizes} = props;
2403 + const key = getImagePreloadKey(src, imageSrcSet, imageSizes);
2404 + let resource = resources.preloadsMap.get(key);
2405 + if (!resource) {
2406 + resource = {
2407 + type: 'preload',
2408 + chunks: [],
2409 + state: NoState,
2410 + props: {
2411 + rel: 'preload',
2412 + as: 'image',
2413 + // There is a bug in Safari where imageSrcSet is not respected on preload links
2414 + // so we omit the href here if we have imageSrcSet b/c safari will load the wrong image.
2415 + // This harms older browers that do not support imageSrcSet by making their preloads not work
2416 + // but this population is shrinking fast and is already small so we accept this tradeoff.
2417 + href: imageSrcSet ? undefined : src,
2418 + imageSrcSet,
2419 + imageSizes,
2420 + crossOrigin: props.crossOrigin,
2421 + integrity: props.integrity,
2422 + type: props.type,
2423 + fetchPriority: props.fetchPriority,
2424 + referrerPolicy: props.referrerPolicy,
2425 + },
2426 + };
2427 + resources.preloadsMap.set(key, resource);
2428 + if (__DEV__) {
2429 + markAsRenderedResourceDEV(resource, props);
2430 + }
2431 + pushLinkImpl(resource.chunks, resource.props);
2432 + }
2433 + if (
2434 + props.fetchPriority === 'high' ||
2435 + resources.highImagePreloads.size < 10
2436 + ) {
2437 + resources.highImagePreloads.add(resource);
2438 + } else {
2439 + resources.bulkPreloads.add(resource);
2440 + }
2441 + }
2442 + return pushSelfClosing(target, props, 'img');
2443 +}
2444 +
2445 function pushSelfClosing(
2446 target: Array<Chunk | PrecomputedChunk>,
2447 props: Object,
@@ -3172,6 +3244,9 @@ export function pushStartInstance(
3244 case 'pre': {
3245 return pushStartPreformattedElement(target, props, type);
3246 }
3247 + case 'img': {
3248 + return pushImg(target, props, resources);
3249 + }
3250 // Omitted close tags
3251 case 'base':
3252 case 'area':
@@ -3179,7 +3254,6 @@ export function pushStartInstance(
3254 case 'col':
3255 case 'embed':
3256 case 'hr':
3182 - case 'img':
3257 case 'keygen':
3258 case 'param':
3259 case 'source':
@@ -4242,6 +4316,9 @@ export function writePreamble(
4316 resources.fontPreloads.forEach(flushResourceInPreamble, destination);
4317 resources.fontPreloads.clear();
4318
4319 + resources.highImagePreloads.forEach(flushResourceInPreamble, destination);
4320 + resources.highImagePreloads.clear();
4321 +
4322 // Flush unblocked stylesheets by precedence
4323 resources.precedences.forEach(flushAllStylesInPreamble, destination);
4324
@@ -4250,8 +4327,8 @@ export function writePreamble(
4327 resources.scripts.forEach(flushResourceInPreamble, destination);
4328 resources.scripts.clear();
4329
4253 - resources.explicitPreloads.forEach(flushResourceInPreamble, destination);
4254 - resources.explicitPreloads.clear();
4330 + resources.bulkPreloads.forEach(flushResourceInPreamble, destination);
4331 + resources.bulkPreloads.clear();
4332
4333 // Write embedding preloadChunks
4334 const preloadChunks = responseState.preloadChunks;
@@ -4308,6 +4385,9 @@ export function writeHoistables(
4385 resources.fontPreloads.forEach(flushResourceLate, destination);
4386 resources.fontPreloads.clear();
4387
4388 + resources.highImagePreloads.forEach(flushResourceInPreamble, destination);
4389 + resources.highImagePreloads.clear();
4390 +
4391 // Preload any stylesheets. these will emit in a render instruction that follows this
4392 // but we want to kick off preloading as soon as possible
4393 resources.precedences.forEach(preloadLateStyles, destination);
@@ -4318,8 +4398,8 @@ export function writeHoistables(
4398 resources.scripts.forEach(flushResourceLate, destination);
4399 resources.scripts.clear();
4400
4321 - resources.explicitPreloads.forEach(flushResourceLate, destination);
4322 - resources.explicitPreloads.clear();
4401 + resources.bulkPreloads.forEach(flushResourceLate, destination);
4402 + resources.bulkPreloads.clear();
4403
4404 // Write embedding preloadChunks
4405 const preloadChunks = responseState.preloadChunks;
@@ -4859,12 +4939,13 @@ export type Resources = {
4939 // Flushing queues for Resource dependencies
4940 preconnects: Set<PreconnectResource>,
4941 fontPreloads: Set<PreloadResource>,
4942 + highImagePreloads: Set<PreloadResource>,
4943 // usedImagePreloads: Set<PreloadResource>,
4944 precedences: Map<string, Set<StyleResource>>,
4945 stylePrecedences: Map<string, StyleTagResource>,
4946 bootstrapScripts: Set<PreloadResource>,
4947 scripts: Set<ScriptResource>,
4867 - explicitPreloads: Set<PreloadResource>,
4948 + bulkPreloads: Set<PreloadResource>,
4949
4950 // Module-global-like reference for current boundary resources
4951 boundaryResources: ?BoundaryResources,
@@ -4883,12 +4964,13 @@ export function createResources(): Resources {
4964 // cleared on flush
4965 preconnects: new Set(),
4966 fontPreloads: new Set(),
4967 + highImagePreloads: new Set(),
4968 // usedImagePreloads: new Set(),
4969 precedences: new Map(),
4970 stylePrecedences: new Map(),
4971 bootstrapScripts: new Set(),
4972 scripts: new Set(),
4891 - explicitPreloads: new Set(),
4973 + bulkPreloads: new Set(),
4974
4975 // like a module global for currently rendering boundary
4976 boundaryResources: null,
@@ -5086,16 +5168,7 @@ export function preload(href: string, options: PreloadOptions) {
5168 // both. This is to prevent identical calls with the same srcSet and sizes to be duplicated
5169 // by varying the href. this is an edge case but it is the most correct behavior.
5170 const {imageSrcSet, imageSizes} = options;
5089 - let uniquePart = '';
5090 - if (typeof imageSrcSet === 'string' && imageSrcSet !== '') {
5091 - uniquePart += '[' + imageSrcSet + ']';
5092 - if (typeof imageSizes === 'string') {
5093 - uniquePart += '[' + imageSizes + ']';
5094 - }
5095 - } else {
5096 - uniquePart += '[][]' + href;
5097 - }
5098 - key = getResourceKey(as, uniquePart);
5171 + key = getImagePreloadKey(href, imageSrcSet, imageSizes);
5172 } else {
5173 key = getResourceKey(as, href);
5174 }
@@ -5177,8 +5250,10 @@ export function preload(href: string, options: PreloadOptions) {
5250 }
5251 if (as === 'font') {
5252 resources.fontPreloads.add(resource);
5253 + } else if (as === 'image' && options.fetchPriority === 'high') {
5254 + resources.highImagePreloads.add(resource);
5255 } else {
5181 - resources.explicitPreloads.add(resource);
5256 + resources.bulkPreloads.add(resource);
5257 }
5258 flushResources(request);
5259 }
packages/react-dom/src/__tests__/ReactDOMFloat-test.js
+157
@@ -3771,6 +3771,163 @@ body {
3771 );
3772 });
3773
3774 + it('can emit preloads for non-lazy images that are rendered', async () => {
3775 + function App() {
3776 + ReactDOM.preload('script', {as: 'script'});
3777 + ReactDOM.preload('a', {as: 'image'});
3778 + ReactDOM.preload('b', {as: 'image'});
3779 + return (
3780 + <html>
3781 + <body>
3782 + <img src="a" />
3783 + <img src="b" loading="lazy" />
3784 + <img src="b2" loading="lazy" />
3785 + <img src="c" imageSrcSet="srcsetc" />
3786 + <img src="d" imageSrcSet="srcsetd" imageSizes="sizesd" />
3787 + <img src="d" imageSrcSet="srcsetd" imageSizes="sizesd2" />
3788 + </body>
3789 + </html>
3790 + );
3791 + }
3792 +
3793 + await act(() => {
3794 + renderToPipeableStream(<App />).pipe(writable);
3795 + });
3796 +
3797 + // non-lazy images are first, then arbitrary preloads like for the script and lazy images
3798 + expect(getMeaningfulChildren(document)).toEqual(
3799 + <html>
3800 + <head>
3801 + <link rel="preload" href="a" as="image" />
3802 + <link rel="preload" as="image" imagesrcset="srcsetc" />
3803 + <link
3804 + rel="preload"
3805 + as="image"
3806 + imagesrcset="srcsetd"
3807 + imagesizes="sizesd"
3808 + />
3809 + <link
3810 + rel="preload"
3811 + as="image"
3812 + imagesrcset="srcsetd"
3813 + imagesizes="sizesd2"
3814 + />
3815 + <link rel="preload" href="script" as="script" />
3816 + <link rel="preload" href="b" as="image" />
3817 + </head>
3818 + <body>
3819 + <img src="a" />
3820 + <img src="b" loading="lazy" />
3821 + <img src="b2" loading="lazy" />
3822 + <img src="c" imagesrcset="srcsetc" />
3823 + <img src="d" imagesrcset="srcsetd" imagesizes="sizesd" />
3824 + <img src="d" imagesrcset="srcsetd" imagesizes="sizesd2" />
3825 + </body>
3826 + </html>,
3827 + );
3828 + });
3829 +
3830 + it('Does not preload lazy images', async () => {
3831 + function App() {
3832 + ReactDOM.preload('a', {as: 'image'});
3833 + return (
3834 + <html>
3835 + <body>
3836 + <img src="a" fetchPriority="low" />
3837 + <img src="b" fetchPriority="low" />
3838 + </body>
3839 + </html>
3840 + );
3841 + }
3842 + await act(() => {
3843 + renderToPipeableStream(<App />).pipe(writable);
3844 + });
3845 +
3846 + expect(getMeaningfulChildren(document)).toEqual(
3847 + <html>
3848 + <head>
3849 + <link rel="preload" as="image" href="a" />
3850 + </head>
3851 + <body>
3852 + <img src="a" fetchpriority="low" />
3853 + <img src="b" fetchpriority="low" />
3854 + </body>
3855 + </html>,
3856 + );
3857 + });
3858 +
3859 + it('preloads up to 10 suspensey images as high priority when fetchPriority is not specified', async () => {
3860 + function App() {
3861 + ReactDOM.preload('1', {as: 'image', fetchPriority: 'high'});
3862 + ReactDOM.preload('auto', {as: 'image'});
3863 + ReactDOM.preload('low', {as: 'image', fetchPriority: 'low'});
3864 + ReactDOM.preload('9', {as: 'image', fetchPriority: 'high'});
3865 + ReactDOM.preload('10', {as: 'image', fetchPriority: 'high'});
3866 + return (
3867 + <html>
3868 + <body>
3869 + {/* skipping 1 */}
3870 + <img src="2" />
3871 + <img src="3" fetchPriority="auto" />
3872 + <img src="4" fetchPriority="high" />
3873 + <img src="5" />
3874 + <img src="5low" fetchPriority="low" />
3875 + <img src="6" />
3876 + <img src="7" />
3877 + <img src="8" />
3878 + <img src="9" />
3879 + {/* skipping 10 */}
3880 + <img src="11" />
3881 + <img src="12" fetchPriority="high" />
3882 + </body>
3883 + </html>
3884 + );
3885 + }
3886 + await act(() => {
3887 + renderToPipeableStream(<App />).pipe(writable);
3888 + });
3889 +
3890 + expect(getMeaningfulChildren(document)).toEqual(
3891 + <html>
3892 + <head>
3893 + {/* First we see the preloads calls that made it to the high priority image queue */}
3894 + <link rel="preload" as="image" href="1" fetchpriority="high" />
3895 + <link rel="preload" as="image" href="9" fetchpriority="high" />
3896 + <link rel="preload" as="image" href="10" fetchpriority="high" />
3897 + {/* Next we see up to 7 more images qualify for high priority image queue */}
3898 + <link rel="preload" as="image" href="2" />
3899 + <link rel="preload" as="image" href="3" fetchpriority="auto" />
3900 + <link rel="preload" as="image" href="4" fetchpriority="high" />
3901 + <link rel="preload" as="image" href="5" />
3902 + <link rel="preload" as="image" href="6" />
3903 + <link rel="preload" as="image" href="7" />
3904 + <link rel="preload" as="image" href="8" />
3905 + {/* Next we see images that are explicitly high priority and thus make it to the high priority image queue */}
3906 + <link rel="preload" as="image" href="12" fetchpriority="high" />
3907 + {/* Next we see the remaining preloads that did not make it to the high priority image queue */}
3908 + <link rel="preload" as="image" href="auto" />
3909 + <link rel="preload" as="image" href="low" fetchpriority="low" />
3910 + <link rel="preload" as="image" href="11" />
3911 + </head>
3912 + <body>
3913 + {/* skipping 1 */}
3914 + <img src="2" />
3915 + <img src="3" fetchpriority="auto" />
3916 + <img src="4" fetchpriority="high" />
3917 + <img src="5" />
3918 + <img src="5low" fetchpriority="low" />
3919 + <img src="6" />
3920 + <img src="7" />
3921 + <img src="8" />
3922 + <img src="9" />
3923 + {/* skipping 10 */}
3924 + <img src="11" />
3925 + <img src="12" fetchpriority="high" />
3926 + </body>
3927 + </html>,
3928 + );
3929 + });
3930 +
3931 describe('ReactDOM.prefetchDNS(href)', () => {
3932 it('creates a dns-prefetch resource when called', async () => {
3933 function App({url}) {