Skip to content

Commit 26bf677

Browse files
committed
Images that can be suspended on should load as early as possible during SSR. We now track whether a rendered img is "suspensey" and emit a corresponding preload behind fonts in priority.
1 parent a20eea2 commit 26bf677

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)