Skip to content

Commit 6028935

Browse files
gnoffAndyPengc12
authored andcommitted
[Fizz] Preload "suspensey" images (facebook#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
1 parent 2e6750f commit 6028935

File tree

2 files changed

+250
-18
lines changed

2 files changed

+250
-18
lines changed

packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js

Lines changed: 93 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2370,6 +2370,78 @@ function pushStyleContents(
23702370
return;
23712371
}
23722372

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+
23732445
function pushSelfClosing(
23742446
target: Array<Chunk | PrecomputedChunk>,
23752447
props: Object,
@@ -3172,14 +3244,16 @@ export function pushStartInstance(
31723244
case 'pre': {
31733245
return pushStartPreformattedElement(target, props, type);
31743246
}
3247+
case 'img': {
3248+
return pushImg(target, props, resources);
3249+
}
31753250
// Omitted close tags
31763251
case 'base':
31773252
case 'area':
31783253
case 'br':
31793254
case 'col':
31803255
case 'embed':
31813256
case 'hr':
3182-
case 'img':
31833257
case 'keygen':
31843258
case 'param':
31853259
case 'source':
@@ -4242,6 +4316,9 @@ export function writePreamble(
42424316
resources.fontPreloads.forEach(flushResourceInPreamble, destination);
42434317
resources.fontPreloads.clear();
42444318

4319+
resources.highImagePreloads.forEach(flushResourceInPreamble, destination);
4320+
resources.highImagePreloads.clear();
4321+
42454322
// Flush unblocked stylesheets by precedence
42464323
resources.precedences.forEach(flushAllStylesInPreamble, destination);
42474324

@@ -4250,8 +4327,8 @@ export function writePreamble(
42504327
resources.scripts.forEach(flushResourceInPreamble, destination);
42514328
resources.scripts.clear();
42524329

4253-
resources.explicitPreloads.forEach(flushResourceInPreamble, destination);
4254-
resources.explicitPreloads.clear();
4330+
resources.bulkPreloads.forEach(flushResourceInPreamble, destination);
4331+
resources.bulkPreloads.clear();
42554332

42564333
// Write embedding preloadChunks
42574334
const preloadChunks = responseState.preloadChunks;
@@ -4308,6 +4385,9 @@ export function writeHoistables(
43084385
resources.fontPreloads.forEach(flushResourceLate, destination);
43094386
resources.fontPreloads.clear();
43104387

4388+
resources.highImagePreloads.forEach(flushResourceInPreamble, destination);
4389+
resources.highImagePreloads.clear();
4390+
43114391
// Preload any stylesheets. these will emit in a render instruction that follows this
43124392
// but we want to kick off preloading as soon as possible
43134393
resources.precedences.forEach(preloadLateStyles, destination);
@@ -4318,8 +4398,8 @@ export function writeHoistables(
43184398
resources.scripts.forEach(flushResourceLate, destination);
43194399
resources.scripts.clear();
43204400

4321-
resources.explicitPreloads.forEach(flushResourceLate, destination);
4322-
resources.explicitPreloads.clear();
4401+
resources.bulkPreloads.forEach(flushResourceLate, destination);
4402+
resources.bulkPreloads.clear();
43234403

43244404
// Write embedding preloadChunks
43254405
const preloadChunks = responseState.preloadChunks;
@@ -4859,12 +4939,13 @@ export type Resources = {
48594939
// Flushing queues for Resource dependencies
48604940
preconnects: Set<PreconnectResource>,
48614941
fontPreloads: Set<PreloadResource>,
4942+
highImagePreloads: Set<PreloadResource>,
48624943
// usedImagePreloads: Set<PreloadResource>,
48634944
precedences: Map<string, Set<StyleResource>>,
48644945
stylePrecedences: Map<string, StyleTagResource>,
48654946
bootstrapScripts: Set<PreloadResource>,
48664947
scripts: Set<ScriptResource>,
4867-
explicitPreloads: Set<PreloadResource>,
4948+
bulkPreloads: Set<PreloadResource>,
48684949

48694950
// Module-global-like reference for current boundary resources
48704951
boundaryResources: ?BoundaryResources,
@@ -4883,12 +4964,13 @@ export function createResources(): Resources {
48834964
// cleared on flush
48844965
preconnects: new Set(),
48854966
fontPreloads: new Set(),
4967+
highImagePreloads: new Set(),
48864968
// usedImagePreloads: new Set(),
48874969
precedences: new Map(),
48884970
stylePrecedences: new Map(),
48894971
bootstrapScripts: new Set(),
48904972
scripts: new Set(),
4891-
explicitPreloads: new Set(),
4973+
bulkPreloads: new Set(),
48924974

48934975
// like a module global for currently rendering boundary
48944976
boundaryResources: null,
@@ -5086,16 +5168,7 @@ export function preload(href: string, options: PreloadOptions) {
50865168
// both. This is to prevent identical calls with the same srcSet and sizes to be duplicated
50875169
// by varying the href. this is an edge case but it is the most correct behavior.
50885170
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);
50995172
} else {
51005173
key = getResourceKey(as, href);
51015174
}
@@ -5177,8 +5250,10 @@ export function preload(href: string, options: PreloadOptions) {
51775250
}
51785251
if (as === 'font') {
51795252
resources.fontPreloads.add(resource);
5253+
} else if (as === 'image' && options.fetchPriority === 'high') {
5254+
resources.highImagePreloads.add(resource);
51805255
} else {
5181-
resources.explicitPreloads.add(resource);
5256+
resources.bulkPreloads.add(resource);
51825257
}
51835258
flushResources(request);
51845259
}

packages/react-dom/src/__tests__/ReactDOMFloat-test.js

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3771,6 +3771,163 @@ body {
37713771
);
37723772
});
37733773

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+
37743931
describe('ReactDOM.prefetchDNS(href)', () => {
37753932
it('creates a dns-prefetch resource when called', async () => {
37763933
function App({url}) {

0 commit comments

Comments
 (0)