Skip to content

Commit ae22247

Browse files
authored
[Fiber] Don't wait on Suspensey Images if we guess that we don't load them all in time anyway (#34481)
Stacked on #34478. In general we don't like to deal with timeouts in suspense world. We've had that in the past but in general it doesn't work well because if you have a timeout and then give up you made everything wait longer for no benefit at the end. That's why the recommendation is to remove a Suspense boundary if you expect it to be fast and add one if you expect it to be slow. You have to estimate as the developer. Suspensey images suffer from this same problem. We want to apply suspensey images to as much as possible so that it's the default to avoid flashing because if just a few images flash it's still almost as bad as all of them. However, we do know that it's also very common to use images and on a slow connection or many images, it's not worth it so we have the timeout to eventually give up. However, this means that in cases that are always slow or connections that are always slow, you're always punished for no reason. Suspensey images is mainly a polish feature to make high end experiences on high end connections better but we don't want to unnecessarily punish all slow connections in the process or things like lots of images below the viewport. This PR adds an estimate for whether or not we'll likely be able to load all the images within the timeout on a high end enough connection. If not, we'll still do a short suspend (unless we've already exceeded the wait time adjusted for #34478) to allow loading from cache if available. This estimate is based on two heuristics: 1) We compute an estimated bandwidth available on the current device in mbps. This is computed from performance entries that have loaded static resources already on the site. E.g. this can be other images, css, or scripts. We see how long they took. If we don't have any entries (or if they're all cross-origin in Safari) we fallback to `navigator.connection.downlink` in Chrome or a 5mbps default in Firefox/Safari. 2) To estimate how many bytes we'll have to download we use the width/height props of the img tag if available (or a 100 pixel default) times the device pixel ratio. We assume that a good img implementation downloads proper resolution image for the device and defines a width/height up front to avoid layout trash. Then we estimate that it takes about 0.25 bytes per pixel which is somewhat conservative estimate. This is somewhat conservative given that the image could've been preloaded and be better compressed. So it really only kicks in for high end connections that are known to load fast. In a follow up, we can add an additional wait for View Transitions that does the same estimate but only for the images that turn out to be in viewport.
1 parent e3f1918 commit ae22247

File tree

3 files changed

+150
-6
lines changed

3 files changed

+150
-6
lines changed

fixtures/view-transition/src/components/Page.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ function Component() {
5050
<p>
5151
<img
5252
src="https://react.dev/_next/image?url=%2Fimages%2Fteam%2Fsebmarkbage.jpg&w=3840&q=75"
53-
width="300"
53+
width="400"
54+
height="248"
5455
/>
5556
</p>
5657
</ViewTransition>

packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals';
143143
export {default as rendererVersion} from 'shared/ReactVersion';
144144

145145
import noop from 'shared/noop';
146+
import estimateBandwidth from './estimateBandwidth';
146147

147148
export const rendererPackageName = 'react-dom';
148149
export const extraDevToolsConfig = null;
@@ -5907,6 +5908,7 @@ type SuspendedState = {
59075908
stylesheets: null | Map<StylesheetResource, HoistableRoot>,
59085909
count: number, // suspensey css and active view transitions
59095910
imgCount: number, // suspensey images
5911+
imgBytes: number, // number of bytes we estimate needing to download
59105912
waitingForImages: boolean, // false when we're no longer blocking on images
59115913
unsuspend: null | (() => void),
59125914
};
@@ -5917,6 +5919,7 @@ export function startSuspendingCommit(): void {
59175919
stylesheets: null,
59185920
count: 0,
59195921
imgCount: 0,
5922+
imgBytes: 0,
59205923
waitingForImages: true,
59215924
// We use a noop function when we begin suspending because if possible we want the
59225925
// waitfor step to finish synchronously. If it doesn't we'll return a function to
@@ -5926,10 +5929,6 @@ export function startSuspendingCommit(): void {
59265929
};
59275930
}
59285931

5929-
const SUSPENSEY_STYLESHEET_TIMEOUT = 60000;
5930-
5931-
const SUSPENSEY_IMAGE_TIMEOUT = 500;
5932-
59335932
export function suspendInstance(
59345933
instance: Instance,
59355934
type: Type,
@@ -5953,6 +5952,18 @@ export function suspendInstance(
59535952
// The loading should have already started at this point, so it should be enough to
59545953
// just call decode() which should also wait for the data to finish loading.
59555954
state.imgCount++;
5955+
// Estimate the byte size that we're about to download based on the width/height
5956+
// specified in the props. This is best practice to know ahead of time but if it's
5957+
// unspecified we'll fallback to a guess of 100x100 pixels.
5958+
if (!(instance: any).complete) {
5959+
const width: number = (instance: any).width || 100;
5960+
const height: number = (instance: any).height || 100;
5961+
const pixelRatio: number =
5962+
typeof devicePixelRatio === 'number' ? devicePixelRatio : 1;
5963+
const pixelsToDownload = width * height * pixelRatio;
5964+
const AVERAGE_BYTE_PER_PIXEL = 0.25;
5965+
state.imgBytes += pixelsToDownload * AVERAGE_BYTE_PER_PIXEL;
5966+
}
59565967
const ping = onUnsuspendImg.bind(state);
59575968
// $FlowFixMe[prop-missing]
59585969
instance.decode().then(ping, ping);
@@ -6070,6 +6081,14 @@ export function suspendOnActiveViewTransition(rootContainer: Container): void {
60706081
activeViewTransition.finished.then(ping, ping);
60716082
}
60726083

6084+
const SUSPENSEY_STYLESHEET_TIMEOUT = 60000;
6085+
6086+
const SUSPENSEY_IMAGE_TIMEOUT = 800;
6087+
6088+
const SUSPENSEY_IMAGE_TIME_ESTIMATE = 500;
6089+
6090+
let estimatedBytesWithinLimit: number = 0;
6091+
60736092
export function waitForCommitToBeReady(
60746093
timeoutOffset: number,
60756094
): null | ((() => void) => () => void) {
@@ -6109,6 +6128,18 @@ export function waitForCommitToBeReady(
61096128
}
61106129
}, SUSPENSEY_STYLESHEET_TIMEOUT + timeoutOffset);
61116130

6131+
if (state.imgBytes > 0 && estimatedBytesWithinLimit === 0) {
6132+
// Estimate how many bytes we can download in 500ms.
6133+
const mbps = estimateBandwidth();
6134+
estimatedBytesWithinLimit = mbps * 125 * SUSPENSEY_IMAGE_TIME_ESTIMATE;
6135+
}
6136+
// If we have more images to download than we expect to fit in the timeout, then
6137+
// don't wait for images longer than 50ms. The 50ms lets us still do decoding and
6138+
// hitting caches if it turns out that they're already in the HTTP cache.
6139+
const imgTimeout =
6140+
state.imgBytes > estimatedBytesWithinLimit
6141+
? 50
6142+
: SUSPENSEY_IMAGE_TIMEOUT;
61126143
const imgTimer = setTimeout(() => {
61136144
// We're no longer blocked on images. If CSS resolves after this we can commit.
61146145
state.waitingForImages = false;
@@ -6122,7 +6153,7 @@ export function waitForCommitToBeReady(
61226153
unsuspend();
61236154
}
61246155
}
6125-
}, SUSPENSEY_IMAGE_TIMEOUT + timeoutOffset);
6156+
}, imgTimeout + timeoutOffset);
61266157

61276158
state.unsuspend = commit;
61286159

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
function isLikelyStaticResource(initiatorType: string) {
11+
switch (initiatorType) {
12+
case 'css':
13+
case 'script':
14+
case 'font':
15+
case 'img':
16+
case 'image':
17+
case 'input':
18+
case 'link':
19+
return true;
20+
default:
21+
return false;
22+
}
23+
}
24+
25+
export default function estimateBandwidth(): number {
26+
// Estimate the current bandwidth for downloading static resources given resources already
27+
// loaded.
28+
// $FlowFixMe[method-unbinding]
29+
if (typeof performance.getEntriesByType === 'function') {
30+
let count = 0;
31+
let bits = 0;
32+
const resourceEntries = performance.getEntriesByType('resource');
33+
for (let i = 0; i < resourceEntries.length; i++) {
34+
const entry = resourceEntries[i];
35+
// $FlowFixMe[prop-missing]
36+
const transferSize: number = entry.transferSize;
37+
// $FlowFixMe[prop-missing]
38+
const initiatorType: string = entry.initiatorType;
39+
const duration = entry.duration;
40+
if (
41+
!transferSize ||
42+
!duration ||
43+
!isLikelyStaticResource(initiatorType)
44+
) {
45+
// Skip cached, cross-orgin entries and resources likely to be dynamically generated.
46+
continue;
47+
}
48+
// Find any overlapping entries that were transferring at the same time since the total
49+
// bps at the time will include those bytes.
50+
let overlappingBytes = 0;
51+
// $FlowFixMe[prop-missing]
52+
const parentEndTime: number = entry.responseEnd;
53+
let j;
54+
for (j = i + 1; j < resourceEntries.length; j++) {
55+
const overlapEntry = resourceEntries[j];
56+
const overlapStartTime = overlapEntry.startTime;
57+
if (overlapStartTime > parentEndTime) {
58+
break;
59+
}
60+
// $FlowFixMe[prop-missing]
61+
const overlapTransferSize: number = overlapEntry.transferSize;
62+
// $FlowFixMe[prop-missing]
63+
const overlapInitiatorType: string = overlapEntry.initiatorType;
64+
if (
65+
!overlapTransferSize ||
66+
!isLikelyStaticResource(overlapInitiatorType)
67+
) {
68+
// Skip cached, cross-orgin entries and resources likely to be dynamically generated.
69+
continue;
70+
}
71+
// $FlowFixMe[prop-missing]
72+
const overlapEndTime: number = overlapEntry.responseEnd;
73+
const overlapFactor =
74+
overlapEndTime < parentEndTime
75+
? 1
76+
: (parentEndTime - overlapStartTime) /
77+
(overlapEndTime - overlapStartTime);
78+
overlappingBytes += overlapTransferSize * overlapFactor;
79+
}
80+
// Skip past any entries we already considered overlapping. Otherwise we'd have to go
81+
// back to consider previous entries when we then handled them.
82+
i = j - 1;
83+
84+
const bps =
85+
((transferSize + overlappingBytes) * 8) / (entry.duration / 1000);
86+
bits += bps;
87+
count++;
88+
if (count > 10) {
89+
// We have enough to get an average.
90+
break;
91+
}
92+
}
93+
if (count > 0) {
94+
return bits / count / 1e6;
95+
}
96+
}
97+
98+
// Fallback to the navigator.connection estimate if available
99+
// $FlowFixMe[prop-missing]
100+
if (navigator.connection) {
101+
// $FlowFixMe
102+
const downlink: ?number = navigator.connection.downlink;
103+
if (typeof downlink === 'number') {
104+
return downlink;
105+
}
106+
}
107+
108+
// Otherwise, use a default of 5mbps to compute heuristics.
109+
// This can happen commonly in Safari if all static resources and images are loaded
110+
// cross-orgin.
111+
return 5;
112+
}

0 commit comments

Comments
 (0)