Skip to content

Commit

Permalink
[Fizz] Preload "suspensey" images (#27191)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
gnoff authored Aug 10, 2023
1 parent a20eea2 commit f359f9b
Show file tree
Hide file tree
Showing 2 changed files with 250 additions and 18 deletions.
111 changes: 93 additions & 18 deletions packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -2370,6 +2370,78 @@ function pushStyleContents(
return;
}

function getImagePreloadKey(
href: string,
imageSrcSet: ?string,
imageSizes: ?string,
) {
let uniquePart = '';
if (typeof imageSrcSet === 'string' && imageSrcSet !== '') {
uniquePart += '[' + imageSrcSet + ']';
if (typeof imageSizes === 'string') {
uniquePart += '[' + imageSizes + ']';
}
} else {
uniquePart += '[][]' + href;
}
return getResourceKey('image', uniquePart);
}

function pushImg(
target: Array<Chunk | PrecomputedChunk>,
props: Object,
resources: Resources,
): null {
if (
props.loading !== 'lazy' &&
typeof props.src === 'string' &&
props.fetchPriority !== 'low'
) {
// We have a suspensey image and ought to preload it to optimize the loading of display blocking
// resources.
const {src, imageSrcSet, imageSizes} = props;
const key = getImagePreloadKey(src, imageSrcSet, imageSizes);
let resource = resources.preloadsMap.get(key);
if (!resource) {
resource = {
type: 'preload',
chunks: [],
state: NoState,
props: {
rel: 'preload',
as: 'image',
// There is a bug in Safari where imageSrcSet is not respected on preload links
// so we omit the href here if we have imageSrcSet b/c safari will load the wrong image.
// This harms older browers that do not support imageSrcSet by making their preloads not work
// but this population is shrinking fast and is already small so we accept this tradeoff.
href: imageSrcSet ? undefined : src,
imageSrcSet,
imageSizes,
crossOrigin: props.crossOrigin,
integrity: props.integrity,
type: props.type,
fetchPriority: props.fetchPriority,
referrerPolicy: props.referrerPolicy,
},
};
resources.preloadsMap.set(key, resource);
if (__DEV__) {
markAsRenderedResourceDEV(resource, props);
}
pushLinkImpl(resource.chunks, resource.props);
}
if (
props.fetchPriority === 'high' ||
resources.highImagePreloads.size < 10
) {
resources.highImagePreloads.add(resource);
} else {
resources.bulkPreloads.add(resource);
}
}
return pushSelfClosing(target, props, 'img');
}

function pushSelfClosing(
target: Array<Chunk | PrecomputedChunk>,
props: Object,
Expand Down Expand Up @@ -3172,14 +3244,16 @@ export function pushStartInstance(
case 'pre': {
return pushStartPreformattedElement(target, props, type);
}
case 'img': {
return pushImg(target, props, resources);
}
// Omitted close tags
case 'base':
case 'area':
case 'br':
case 'col':
case 'embed':
case 'hr':
case 'img':
case 'keygen':
case 'param':
case 'source':
Expand Down Expand Up @@ -4242,6 +4316,9 @@ export function writePreamble(
resources.fontPreloads.forEach(flushResourceInPreamble, destination);
resources.fontPreloads.clear();

resources.highImagePreloads.forEach(flushResourceInPreamble, destination);
resources.highImagePreloads.clear();

// Flush unblocked stylesheets by precedence
resources.precedences.forEach(flushAllStylesInPreamble, destination);

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

resources.explicitPreloads.forEach(flushResourceInPreamble, destination);
resources.explicitPreloads.clear();
resources.bulkPreloads.forEach(flushResourceInPreamble, destination);
resources.bulkPreloads.clear();

// Write embedding preloadChunks
const preloadChunks = responseState.preloadChunks;
Expand Down Expand Up @@ -4308,6 +4385,9 @@ export function writeHoistables(
resources.fontPreloads.forEach(flushResourceLate, destination);
resources.fontPreloads.clear();

resources.highImagePreloads.forEach(flushResourceInPreamble, destination);
resources.highImagePreloads.clear();

// Preload any stylesheets. these will emit in a render instruction that follows this
// but we want to kick off preloading as soon as possible
resources.precedences.forEach(preloadLateStyles, destination);
Expand All @@ -4318,8 +4398,8 @@ export function writeHoistables(
resources.scripts.forEach(flushResourceLate, destination);
resources.scripts.clear();

resources.explicitPreloads.forEach(flushResourceLate, destination);
resources.explicitPreloads.clear();
resources.bulkPreloads.forEach(flushResourceLate, destination);
resources.bulkPreloads.clear();

// Write embedding preloadChunks
const preloadChunks = responseState.preloadChunks;
Expand Down Expand Up @@ -4859,12 +4939,13 @@ export type Resources = {
// Flushing queues for Resource dependencies
preconnects: Set<PreconnectResource>,
fontPreloads: Set<PreloadResource>,
highImagePreloads: Set<PreloadResource>,
// usedImagePreloads: Set<PreloadResource>,
precedences: Map<string, Set<StyleResource>>,
stylePrecedences: Map<string, StyleTagResource>,
bootstrapScripts: Set<PreloadResource>,
scripts: Set<ScriptResource>,
explicitPreloads: Set<PreloadResource>,
bulkPreloads: Set<PreloadResource>,

// Module-global-like reference for current boundary resources
boundaryResources: ?BoundaryResources,
Expand All @@ -4883,12 +4964,13 @@ export function createResources(): Resources {
// cleared on flush
preconnects: new Set(),
fontPreloads: new Set(),
highImagePreloads: new Set(),
// usedImagePreloads: new Set(),
precedences: new Map(),
stylePrecedences: new Map(),
bootstrapScripts: new Set(),
scripts: new Set(),
explicitPreloads: new Set(),
bulkPreloads: new Set(),

// like a module global for currently rendering boundary
boundaryResources: null,
Expand Down Expand Up @@ -5086,16 +5168,7 @@ export function preload(href: string, options: PreloadOptions) {
// both. This is to prevent identical calls with the same srcSet and sizes to be duplicated
// by varying the href. this is an edge case but it is the most correct behavior.
const {imageSrcSet, imageSizes} = options;
let uniquePart = '';
if (typeof imageSrcSet === 'string' && imageSrcSet !== '') {
uniquePart += '[' + imageSrcSet + ']';
if (typeof imageSizes === 'string') {
uniquePart += '[' + imageSizes + ']';
}
} else {
uniquePart += '[][]' + href;
}
key = getResourceKey(as, uniquePart);
key = getImagePreloadKey(href, imageSrcSet, imageSizes);
} else {
key = getResourceKey(as, href);
}
Expand Down Expand Up @@ -5177,8 +5250,10 @@ export function preload(href: string, options: PreloadOptions) {
}
if (as === 'font') {
resources.fontPreloads.add(resource);
} else if (as === 'image' && options.fetchPriority === 'high') {
resources.highImagePreloads.add(resource);
} else {
resources.explicitPreloads.add(resource);
resources.bulkPreloads.add(resource);
}
flushResources(request);
}
Expand Down
157 changes: 157 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFloat-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3771,6 +3771,163 @@ body {
);
});

it('can emit preloads for non-lazy images that are rendered', async () => {
function App() {
ReactDOM.preload('script', {as: 'script'});
ReactDOM.preload('a', {as: 'image'});
ReactDOM.preload('b', {as: 'image'});
return (
<html>
<body>
<img src="a" />
<img src="b" loading="lazy" />
<img src="b2" loading="lazy" />
<img src="c" imageSrcSet="srcsetc" />
<img src="d" imageSrcSet="srcsetd" imageSizes="sizesd" />
<img src="d" imageSrcSet="srcsetd" imageSizes="sizesd2" />
</body>
</html>
);
}

await act(() => {
renderToPipeableStream(<App />).pipe(writable);
});

// non-lazy images are first, then arbitrary preloads like for the script and lazy images
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<link rel="preload" href="a" as="image" />
<link rel="preload" as="image" imagesrcset="srcsetc" />
<link
rel="preload"
as="image"
imagesrcset="srcsetd"
imagesizes="sizesd"
/>
<link
rel="preload"
as="image"
imagesrcset="srcsetd"
imagesizes="sizesd2"
/>
<link rel="preload" href="script" as="script" />
<link rel="preload" href="b" as="image" />
</head>
<body>
<img src="a" />
<img src="b" loading="lazy" />
<img src="b2" loading="lazy" />
<img src="c" imagesrcset="srcsetc" />
<img src="d" imagesrcset="srcsetd" imagesizes="sizesd" />
<img src="d" imagesrcset="srcsetd" imagesizes="sizesd2" />
</body>
</html>,
);
});

it('Does not preload lazy images', async () => {
function App() {
ReactDOM.preload('a', {as: 'image'});
return (
<html>
<body>
<img src="a" fetchPriority="low" />
<img src="b" fetchPriority="low" />
</body>
</html>
);
}
await act(() => {
renderToPipeableStream(<App />).pipe(writable);
});

expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<link rel="preload" as="image" href="a" />
</head>
<body>
<img src="a" fetchpriority="low" />
<img src="b" fetchpriority="low" />
</body>
</html>,
);
});

it('preloads up to 10 suspensey images as high priority when fetchPriority is not specified', async () => {
function App() {
ReactDOM.preload('1', {as: 'image', fetchPriority: 'high'});
ReactDOM.preload('auto', {as: 'image'});
ReactDOM.preload('low', {as: 'image', fetchPriority: 'low'});
ReactDOM.preload('9', {as: 'image', fetchPriority: 'high'});
ReactDOM.preload('10', {as: 'image', fetchPriority: 'high'});
return (
<html>
<body>
{/* skipping 1 */}
<img src="2" />
<img src="3" fetchPriority="auto" />
<img src="4" fetchPriority="high" />
<img src="5" />
<img src="5low" fetchPriority="low" />
<img src="6" />
<img src="7" />
<img src="8" />
<img src="9" />
{/* skipping 10 */}
<img src="11" />
<img src="12" fetchPriority="high" />
</body>
</html>
);
}
await act(() => {
renderToPipeableStream(<App />).pipe(writable);
});

expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
{/* First we see the preloads calls that made it to the high priority image queue */}
<link rel="preload" as="image" href="1" fetchpriority="high" />
<link rel="preload" as="image" href="9" fetchpriority="high" />
<link rel="preload" as="image" href="10" fetchpriority="high" />
{/* Next we see up to 7 more images qualify for high priority image queue */}
<link rel="preload" as="image" href="2" />
<link rel="preload" as="image" href="3" fetchpriority="auto" />
<link rel="preload" as="image" href="4" fetchpriority="high" />
<link rel="preload" as="image" href="5" />
<link rel="preload" as="image" href="6" />
<link rel="preload" as="image" href="7" />
<link rel="preload" as="image" href="8" />
{/* Next we see images that are explicitly high priority and thus make it to the high priority image queue */}
<link rel="preload" as="image" href="12" fetchpriority="high" />
{/* Next we see the remaining preloads that did not make it to the high priority image queue */}
<link rel="preload" as="image" href="auto" />
<link rel="preload" as="image" href="low" fetchpriority="low" />
<link rel="preload" as="image" href="11" />
</head>
<body>
{/* skipping 1 */}
<img src="2" />
<img src="3" fetchpriority="auto" />
<img src="4" fetchpriority="high" />
<img src="5" />
<img src="5low" fetchpriority="low" />
<img src="6" />
<img src="7" />
<img src="8" />
<img src="9" />
{/* skipping 10 */}
<img src="11" />
<img src="12" fetchpriority="high" />
</body>
</html>,
);
});

describe('ReactDOM.prefetchDNS(href)', () => {
it('creates a dns-prefetch resource when called', async () => {
function App({url}) {
Expand Down

0 comments on commit f359f9b

Please sign in to comment.