Skip to content

Commit 047715c

Browse files
sebmarkbagegnoff
andauthored
[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 <[email protected]>
1 parent 250f1b2 commit 047715c

File tree

3 files changed

+244
-7
lines changed

3 files changed

+244
-7
lines changed

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,11 @@ function preconnect(href: string, crossOrigin?: ?CrossOriginEnum) {
7979
}
8080
}
8181

82-
function preload(href: string, as: string, options?: ?PreloadImplOptions) {
82+
export function preload(
83+
href: string,
84+
as: string,
85+
options?: ?PreloadImplOptions,
86+
) {
8387
if (typeof href === 'string') {
8488
const request = resolveRequest();
8589
if (request) {
@@ -112,7 +116,10 @@ function preload(href: string, as: string, options?: ?PreloadImplOptions) {
112116
}
113117
}
114118

115-
function preloadModule(href: string, options?: ?PreloadModuleImplOptions) {
119+
export function preloadModule(
120+
href: string,
121+
options?: ?PreloadModuleImplOptions,
122+
): void {
116123
if (typeof href === 'string') {
117124
const request = resolveRequest();
118125
if (request) {

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

Lines changed: 133 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ import type {
1717
} from 'react-dom/src/shared/ReactDOMTypes';
1818

1919
// This module registers the host dispatcher so it needs to be imported
20-
// but it does not have any exports
21-
import './ReactDOMFlightServerHostDispatcher';
20+
// even if no exports are used.
21+
import {preload, preloadModule} from './ReactDOMFlightServerHostDispatcher';
22+
23+
import {getCrossOriginString} from '../shared/crossOriginStrings';
2224

2325
// We use zero to represent the absence of an explicit precedence because it is
2426
// small, smaller than how we encode undefined, and is unambiguous. We could use
@@ -62,16 +64,142 @@ export function createHints(): Hints {
6264
return new Set();
6365
}
6466

65-
export opaque type FormatContext = null;
67+
const NO_SCOPE = /* */ 0b000000;
68+
const NOSCRIPT_SCOPE = /* */ 0b000001;
69+
const PICTURE_SCOPE = /* */ 0b000010;
70+
71+
export opaque type FormatContext = number;
6672

6773
export function createRootFormatContext(): FormatContext {
68-
return null;
74+
return NO_SCOPE;
75+
}
76+
77+
function processImg(props: Object, formatContext: FormatContext): void {
78+
// This should mirror the logic of pushImg in ReactFizzConfigDOM.
79+
const pictureOrNoScriptTagInScope =
80+
formatContext & (PICTURE_SCOPE | NOSCRIPT_SCOPE);
81+
const {src, srcSet} = props;
82+
if (
83+
props.loading !== 'lazy' &&
84+
(src || srcSet) &&
85+
(typeof src === 'string' || src == null) &&
86+
(typeof srcSet === 'string' || srcSet == null) &&
87+
props.fetchPriority !== 'low' &&
88+
!pictureOrNoScriptTagInScope &&
89+
// We exclude data URIs in src and srcSet since these should not be preloaded
90+
!(
91+
typeof src === 'string' &&
92+
src[4] === ':' &&
93+
(src[0] === 'd' || src[0] === 'D') &&
94+
(src[1] === 'a' || src[1] === 'A') &&
95+
(src[2] === 't' || src[2] === 'T') &&
96+
(src[3] === 'a' || src[3] === 'A')
97+
) &&
98+
!(
99+
typeof srcSet === 'string' &&
100+
srcSet[4] === ':' &&
101+
(srcSet[0] === 'd' || srcSet[0] === 'D') &&
102+
(srcSet[1] === 'a' || srcSet[1] === 'A') &&
103+
(srcSet[2] === 't' || srcSet[2] === 'T') &&
104+
(srcSet[3] === 'a' || srcSet[3] === 'A')
105+
)
106+
) {
107+
// We have a suspensey image and ought to preload it to optimize the loading of display blocking
108+
// resumableState.
109+
const sizes = typeof props.sizes === 'string' ? props.sizes : undefined;
110+
111+
const crossOrigin = getCrossOriginString(props.crossOrigin);
112+
113+
preload(
114+
// The preload() API requires a href but if we have an imageSrcSet then that will take precedence.
115+
// We already remove the href anyway in both Fizz and Fiber due to a Safari bug so the empty string
116+
// will never actually appear in the DOM.
117+
src || '',
118+
'image',
119+
{
120+
imageSrcSet: srcSet,
121+
imageSizes: sizes,
122+
crossOrigin: crossOrigin,
123+
integrity: props.integrity,
124+
type: props.type,
125+
fetchPriority: props.fetchPriority,
126+
referrerPolicy: props.referrerPolicy,
127+
},
128+
);
129+
}
130+
}
131+
132+
function processLink(props: Object, formatContext: FormatContext): void {
133+
const noscriptTagInScope = formatContext & NOSCRIPT_SCOPE;
134+
const rel = props.rel;
135+
const href = props.href;
136+
if (
137+
noscriptTagInScope ||
138+
props.itemProp != null ||
139+
typeof rel !== 'string' ||
140+
typeof href !== 'string' ||
141+
href === ''
142+
) {
143+
// We shouldn't preload resources that are in noscript or have no configuration.
144+
return;
145+
}
146+
147+
switch (rel) {
148+
case 'preload': {
149+
preload(href, props.as, {
150+
crossOrigin: props.crossOrigin,
151+
integrity: props.integrity,
152+
nonce: props.nonce,
153+
type: props.type,
154+
fetchPriority: props.fetchPriority,
155+
referrerPolicy: props.referrerPolicy,
156+
imageSrcSet: props.imageSrcSet,
157+
imageSizes: props.imageSizes,
158+
media: props.media,
159+
});
160+
return;
161+
}
162+
case 'modulepreload': {
163+
preloadModule(href, {
164+
as: props.as,
165+
crossOrigin: props.crossOrigin,
166+
integrity: props.integrity,
167+
nonce: props.nonce,
168+
});
169+
return;
170+
}
171+
case 'stylesheet': {
172+
preload(href, 'stylesheet', {
173+
crossOrigin: props.crossOrigin,
174+
integrity: props.integrity,
175+
nonce: props.nonce,
176+
type: props.type,
177+
fetchPriority: props.fetchPriority,
178+
referrerPolicy: props.referrerPolicy,
179+
media: props.media,
180+
});
181+
return;
182+
}
183+
}
69184
}
70185

71186
export function getChildFormatContext(
72187
parentContext: FormatContext,
73188
type: string,
74189
props: Object,
75190
): FormatContext {
76-
return parentContext;
191+
switch (type) {
192+
case 'img':
193+
processImg(props, parentContext);
194+
return parentContext;
195+
case 'link':
196+
processLink(props, parentContext);
197+
return parentContext;
198+
case 'picture':
199+
return parentContext | PICTURE_SCOPE;
200+
case 'noscript':
201+
return parentContext | NOSCRIPT_SCOPE;
202+
default:
203+
return parentContext;
204+
}
77205
}

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ describe('ReactFlightDOM', () => {
4747
// condition
4848
jest.resetModules();
4949

50+
// Some of the tests pollute the head.
51+
document.head.innerHTML = '';
52+
5053
JSDOM = require('jsdom').JSDOM;
5154

5255
patchSetImmediate();
@@ -1998,6 +2001,105 @@ describe('ReactFlightDOM', () => {
19982001
expect(hintRows.length).toEqual(6);
19992002
});
20002003

2004+
it('preloads resources without needing to render them', async () => {
2005+
function NoScriptComponent() {
2006+
return (
2007+
<p>
2008+
<img src="image-do-not-load" />
2009+
<link rel="stylesheet" href="css-do-not-load" />
2010+
</p>
2011+
);
2012+
}
2013+
2014+
function Component() {
2015+
return (
2016+
<div>
2017+
<img src="image-resource" />
2018+
<img
2019+
src="image-do-not-load"
2020+
srcSet="image-preload-src-set"
2021+
sizes="image-sizes"
2022+
/>
2023+
<img src="image-do-not-load" loading="lazy" />
2024+
<link
2025+
rel="preload"
2026+
href="video-resource"
2027+
as="video"
2028+
media="(orientation: landscape)"
2029+
/>
2030+
<link rel="modulepreload" href="module-resource" />
2031+
<picture>
2032+
<source
2033+
srcSet="image-not-yet-preloaded"
2034+
media="(orientation: portrait)"
2035+
/>
2036+
<img src="image-do-not-load" />
2037+
</picture>
2038+
<noscript>
2039+
<NoScriptComponent />
2040+
</noscript>
2041+
<link rel="stylesheet" href="css-resource" />
2042+
</div>
2043+
);
2044+
}
2045+
2046+
const {writable, readable} = getTestStream();
2047+
const {pipe} = await serverAct(() =>
2048+
ReactServerDOMServer.renderToPipeableStream(<Component />, webpackMap),
2049+
);
2050+
pipe(writable);
2051+
2052+
let response = null;
2053+
function getResponse() {
2054+
if (response === null) {
2055+
response = ReactServerDOMClient.createFromReadableStream(readable);
2056+
}
2057+
return response;
2058+
}
2059+
2060+
function App() {
2061+
// Not rendered but use for its side-effects.
2062+
getResponse();
2063+
return (
2064+
<html>
2065+
<body>
2066+
<p>hello world</p>
2067+
</body>
2068+
</html>
2069+
);
2070+
}
2071+
2072+
const root = ReactDOMClient.createRoot(document);
2073+
await act(() => {
2074+
root.render(<App />);
2075+
});
2076+
2077+
expect(getMeaningfulChildren(document)).toEqual(
2078+
<html>
2079+
<head>
2080+
<link rel="preload" as="image" href="image-resource" />
2081+
<link
2082+
rel="preload"
2083+
as="image"
2084+
imagesrcset="image-preload-src-set"
2085+
imagesizes="image-sizes"
2086+
/>
2087+
<link
2088+
rel="preload"
2089+
as="video"
2090+
href="video-resource"
2091+
media="(orientation: landscape)"
2092+
/>
2093+
<link rel="modulepreload" href="module-resource" />
2094+
<link rel="preload" as="stylesheet" href="css-resource" />
2095+
</head>
2096+
<body>
2097+
<p>hello world</p>
2098+
</body>
2099+
</html>,
2100+
);
2101+
});
2102+
20012103
it('should be able to include a client reference in printed errors', async () => {
20022104
const reportedErrors = [];
20032105

0 commit comments

Comments
 (0)