[Flight] Preload <img> and <link> using hints before they're rendered (#34604)

In Fizz and Fiber we emit hints for suspensey images and CSS as soon as
we discover them during render. At the beginning of the stream. This
adds a similar capability when a Host Component is known to be a Host
Component during the Flight render.

The client doesn't know that these resources are in the payload until it
parses that particular component which is lazy. So they need to be
hoisted with hints. We detect when these are rendered during Flight and
add them as hints. That allows you to consume a Flight payload to
preload prefetched content without having to render it.

`<link rel="preload">` can be hoisted more or less as is.

`<link rel="stylesheet">` we preload but we don't actually insert them
anywhere until they're rendered. We do these even for non-suspensey
stylesheets since we know that when they're rendered they're going to
start loading even if they're not immediately used. They're never lazy.

`<img src>` we only preload if they follow the suspensey image pattern
since otherwise they may be more lazy e.g. by if they're in the
viewport. We also skip if they're known to be inside `<picture>`. Same
as Fizz. Ideally this would preload the other `<source>` but it's
tricky.

The downside of this is that you might conditionally render something in
only one branch given a client component. However, in that case you're
already eagerly fetching the server component's data in that branch so
it's not too much of a stretch that you want to eagerly fetch the
corresponding resources as well. If you wanted it to be lazy, you
should've done a lazy fetch of the RSC.

We don't collect hints when any of these are wrapped in a Client
Component. In those cases you might want to add your own preload to a
wrapper Shared Component.

Everything is skipped if it's known to be inside `<noscript>`.

Note that the format context is approximate (see #34601) so it's
possible for these hints to overfetch or underfetch if you try to trick
it. E.g. by rendering Server Components inside a Client Component that
renders `<noscript>`.

---------

Co-authored-by: Josh Story <josh.c.story@gmail.com>
This commit is contained in:
Sebastian Markbåge 2025-09-25 23:44:14 -04:00 committed by GitHub
parent 250f1b20e0
commit 047715c4ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 244 additions and 7 deletions

View File

@ -79,7 +79,11 @@ function preconnect(href: string, crossOrigin?: ?CrossOriginEnum) {
}
}
function preload(href: string, as: string, options?: ?PreloadImplOptions) {
export function preload(
href: string,
as: string,
options?: ?PreloadImplOptions,
) {
if (typeof href === 'string') {
const request = resolveRequest();
if (request) {
@ -112,7 +116,10 @@ function preload(href: string, as: string, options?: ?PreloadImplOptions) {
}
}
function preloadModule(href: string, options?: ?PreloadModuleImplOptions) {
export function preloadModule(
href: string,
options?: ?PreloadModuleImplOptions,
): void {
if (typeof href === 'string') {
const request = resolveRequest();
if (request) {

View File

@ -17,8 +17,10 @@ import type {
} from 'react-dom/src/shared/ReactDOMTypes';
// This module registers the host dispatcher so it needs to be imported
// but it does not have any exports
import './ReactDOMFlightServerHostDispatcher';
// even if no exports are used.
import {preload, preloadModule} from './ReactDOMFlightServerHostDispatcher';
import {getCrossOriginString} from '../shared/crossOriginStrings';
// We use zero to represent the absence of an explicit precedence because it is
// small, smaller than how we encode undefined, and is unambiguous. We could use
@ -62,10 +64,123 @@ export function createHints(): Hints {
return new Set();
}
export opaque type FormatContext = null;
const NO_SCOPE = /* */ 0b000000;
const NOSCRIPT_SCOPE = /* */ 0b000001;
const PICTURE_SCOPE = /* */ 0b000010;
export opaque type FormatContext = number;
export function createRootFormatContext(): FormatContext {
return null;
return NO_SCOPE;
}
function processImg(props: Object, formatContext: FormatContext): void {
// This should mirror the logic of pushImg in ReactFizzConfigDOM.
const pictureOrNoScriptTagInScope =
formatContext & (PICTURE_SCOPE | NOSCRIPT_SCOPE);
const {src, srcSet} = props;
if (
props.loading !== 'lazy' &&
(src || srcSet) &&
(typeof src === 'string' || src == null) &&
(typeof srcSet === 'string' || srcSet == null) &&
props.fetchPriority !== 'low' &&
!pictureOrNoScriptTagInScope &&
// We exclude data URIs in src and srcSet since these should not be preloaded
!(
typeof src === 'string' &&
src[4] === ':' &&
(src[0] === 'd' || src[0] === 'D') &&
(src[1] === 'a' || src[1] === 'A') &&
(src[2] === 't' || src[2] === 'T') &&
(src[3] === 'a' || src[3] === 'A')
) &&
!(
typeof srcSet === 'string' &&
srcSet[4] === ':' &&
(srcSet[0] === 'd' || srcSet[0] === 'D') &&
(srcSet[1] === 'a' || srcSet[1] === 'A') &&
(srcSet[2] === 't' || srcSet[2] === 'T') &&
(srcSet[3] === 'a' || srcSet[3] === 'A')
)
) {
// We have a suspensey image and ought to preload it to optimize the loading of display blocking
// resumableState.
const sizes = typeof props.sizes === 'string' ? props.sizes : undefined;
const crossOrigin = getCrossOriginString(props.crossOrigin);
preload(
// The preload() API requires a href but if we have an imageSrcSet then that will take precedence.
// We already remove the href anyway in both Fizz and Fiber due to a Safari bug so the empty string
// will never actually appear in the DOM.
src || '',
'image',
{
imageSrcSet: srcSet,
imageSizes: sizes,
crossOrigin: crossOrigin,
integrity: props.integrity,
type: props.type,
fetchPriority: props.fetchPriority,
referrerPolicy: props.referrerPolicy,
},
);
}
}
function processLink(props: Object, formatContext: FormatContext): void {
const noscriptTagInScope = formatContext & NOSCRIPT_SCOPE;
const rel = props.rel;
const href = props.href;
if (
noscriptTagInScope ||
props.itemProp != null ||
typeof rel !== 'string' ||
typeof href !== 'string' ||
href === ''
) {
// We shouldn't preload resources that are in noscript or have no configuration.
return;
}
switch (rel) {
case 'preload': {
preload(href, props.as, {
crossOrigin: props.crossOrigin,
integrity: props.integrity,
nonce: props.nonce,
type: props.type,
fetchPriority: props.fetchPriority,
referrerPolicy: props.referrerPolicy,
imageSrcSet: props.imageSrcSet,
imageSizes: props.imageSizes,
media: props.media,
});
return;
}
case 'modulepreload': {
preloadModule(href, {
as: props.as,
crossOrigin: props.crossOrigin,
integrity: props.integrity,
nonce: props.nonce,
});
return;
}
case 'stylesheet': {
preload(href, 'stylesheet', {
crossOrigin: props.crossOrigin,
integrity: props.integrity,
nonce: props.nonce,
type: props.type,
fetchPriority: props.fetchPriority,
referrerPolicy: props.referrerPolicy,
media: props.media,
});
return;
}
}
}
export function getChildFormatContext(
@ -73,5 +188,18 @@ export function getChildFormatContext(
type: string,
props: Object,
): FormatContext {
return parentContext;
switch (type) {
case 'img':
processImg(props, parentContext);
return parentContext;
case 'link':
processLink(props, parentContext);
return parentContext;
case 'picture':
return parentContext | PICTURE_SCOPE;
case 'noscript':
return parentContext | NOSCRIPT_SCOPE;
default:
return parentContext;
}
}

View File

@ -47,6 +47,9 @@ describe('ReactFlightDOM', () => {
// condition
jest.resetModules();
// Some of the tests pollute the head.
document.head.innerHTML = '';
JSDOM = require('jsdom').JSDOM;
patchSetImmediate();
@ -1998,6 +2001,105 @@ describe('ReactFlightDOM', () => {
expect(hintRows.length).toEqual(6);
});
it('preloads resources without needing to render them', async () => {
function NoScriptComponent() {
return (
<p>
<img src="image-do-not-load" />
<link rel="stylesheet" href="css-do-not-load" />
</p>
);
}
function Component() {
return (
<div>
<img src="image-resource" />
<img
src="image-do-not-load"
srcSet="image-preload-src-set"
sizes="image-sizes"
/>
<img src="image-do-not-load" loading="lazy" />
<link
rel="preload"
href="video-resource"
as="video"
media="(orientation: landscape)"
/>
<link rel="modulepreload" href="module-resource" />
<picture>
<source
srcSet="image-not-yet-preloaded"
media="(orientation: portrait)"
/>
<img src="image-do-not-load" />
</picture>
<noscript>
<NoScriptComponent />
</noscript>
<link rel="stylesheet" href="css-resource" />
</div>
);
}
const {writable, readable} = getTestStream();
const {pipe} = await serverAct(() =>
ReactServerDOMServer.renderToPipeableStream(<Component />, webpackMap),
);
pipe(writable);
let response = null;
function getResponse() {
if (response === null) {
response = ReactServerDOMClient.createFromReadableStream(readable);
}
return response;
}
function App() {
// Not rendered but use for its side-effects.
getResponse();
return (
<html>
<body>
<p>hello world</p>
</body>
</html>
);
}
const root = ReactDOMClient.createRoot(document);
await act(() => {
root.render(<App />);
});
expect(getMeaningfulChildren(document)).toEqual(
<html>
<head>
<link rel="preload" as="image" href="image-resource" />
<link
rel="preload"
as="image"
imagesrcset="image-preload-src-set"
imagesizes="image-sizes"
/>
<link
rel="preload"
as="video"
href="video-resource"
media="(orientation: landscape)"
/>
<link rel="modulepreload" href="module-resource" />
<link rel="preload" as="stylesheet" href="css-resource" />
</head>
<body>
<p>hello world</p>
</body>
</html>,
);
});
it('should be able to include a client reference in printed errors', async () => {
const reportedErrors = [];