diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js
index 587306fe95..658ed68629 100644
--- a/fixtures/view-transition/src/components/Page.js
+++ b/fixtures/view-transition/src/components/Page.js
@@ -50,7 +50,8 @@ function Component() {
diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
index 72c061e09e..1778d212ca 100644
--- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
@@ -143,6 +143,7 @@ import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals';
export {default as rendererVersion} from 'shared/ReactVersion';
import noop from 'shared/noop';
+import estimateBandwidth from './estimateBandwidth';
export const rendererPackageName = 'react-dom';
export const extraDevToolsConfig = null;
@@ -5907,6 +5908,7 @@ type SuspendedState = {
stylesheets: null | Map,
count: number, // suspensey css and active view transitions
imgCount: number, // suspensey images
+ imgBytes: number, // number of bytes we estimate needing to download
waitingForImages: boolean, // false when we're no longer blocking on images
unsuspend: null | (() => void),
};
@@ -5917,6 +5919,7 @@ export function startSuspendingCommit(): void {
stylesheets: null,
count: 0,
imgCount: 0,
+ imgBytes: 0,
waitingForImages: true,
// We use a noop function when we begin suspending because if possible we want the
// waitfor step to finish synchronously. If it doesn't we'll return a function to
@@ -5926,10 +5929,6 @@ export function startSuspendingCommit(): void {
};
}
-const SUSPENSEY_STYLESHEET_TIMEOUT = 60000;
-
-const SUSPENSEY_IMAGE_TIMEOUT = 500;
-
export function suspendInstance(
instance: Instance,
type: Type,
@@ -5953,6 +5952,18 @@ export function suspendInstance(
// The loading should have already started at this point, so it should be enough to
// just call decode() which should also wait for the data to finish loading.
state.imgCount++;
+ // Estimate the byte size that we're about to download based on the width/height
+ // specified in the props. This is best practice to know ahead of time but if it's
+ // unspecified we'll fallback to a guess of 100x100 pixels.
+ if (!(instance: any).complete) {
+ const width: number = (instance: any).width || 100;
+ const height: number = (instance: any).height || 100;
+ const pixelRatio: number =
+ typeof devicePixelRatio === 'number' ? devicePixelRatio : 1;
+ const pixelsToDownload = width * height * pixelRatio;
+ const AVERAGE_BYTE_PER_PIXEL = 0.25;
+ state.imgBytes += pixelsToDownload * AVERAGE_BYTE_PER_PIXEL;
+ }
const ping = onUnsuspendImg.bind(state);
// $FlowFixMe[prop-missing]
instance.decode().then(ping, ping);
@@ -6070,6 +6081,14 @@ export function suspendOnActiveViewTransition(rootContainer: Container): void {
activeViewTransition.finished.then(ping, ping);
}
+const SUSPENSEY_STYLESHEET_TIMEOUT = 60000;
+
+const SUSPENSEY_IMAGE_TIMEOUT = 800;
+
+const SUSPENSEY_IMAGE_TIME_ESTIMATE = 500;
+
+let estimatedBytesWithinLimit: number = 0;
+
export function waitForCommitToBeReady(
timeoutOffset: number,
): null | ((() => void) => () => void) {
@@ -6109,6 +6128,18 @@ export function waitForCommitToBeReady(
}
}, SUSPENSEY_STYLESHEET_TIMEOUT + timeoutOffset);
+ if (state.imgBytes > 0 && estimatedBytesWithinLimit === 0) {
+ // Estimate how many bytes we can download in 500ms.
+ const mbps = estimateBandwidth();
+ estimatedBytesWithinLimit = mbps * 125 * SUSPENSEY_IMAGE_TIME_ESTIMATE;
+ }
+ // If we have more images to download than we expect to fit in the timeout, then
+ // don't wait for images longer than 50ms. The 50ms lets us still do decoding and
+ // hitting caches if it turns out that they're already in the HTTP cache.
+ const imgTimeout =
+ state.imgBytes > estimatedBytesWithinLimit
+ ? 50
+ : SUSPENSEY_IMAGE_TIMEOUT;
const imgTimer = setTimeout(() => {
// We're no longer blocked on images. If CSS resolves after this we can commit.
state.waitingForImages = false;
@@ -6122,7 +6153,7 @@ export function waitForCommitToBeReady(
unsuspend();
}
}
- }, SUSPENSEY_IMAGE_TIMEOUT + timeoutOffset);
+ }, imgTimeout + timeoutOffset);
state.unsuspend = commit;
diff --git a/packages/react-dom-bindings/src/client/estimateBandwidth.js b/packages/react-dom-bindings/src/client/estimateBandwidth.js
new file mode 100644
index 0000000000..4b143a5b56
--- /dev/null
+++ b/packages/react-dom-bindings/src/client/estimateBandwidth.js
@@ -0,0 +1,112 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+
+function isLikelyStaticResource(initiatorType: string) {
+ switch (initiatorType) {
+ case 'css':
+ case 'script':
+ case 'font':
+ case 'img':
+ case 'image':
+ case 'input':
+ case 'link':
+ return true;
+ default:
+ return false;
+ }
+}
+
+export default function estimateBandwidth(): number {
+ // Estimate the current bandwidth for downloading static resources given resources already
+ // loaded.
+ // $FlowFixMe[method-unbinding]
+ if (typeof performance.getEntriesByType === 'function') {
+ let count = 0;
+ let bits = 0;
+ const resourceEntries = performance.getEntriesByType('resource');
+ for (let i = 0; i < resourceEntries.length; i++) {
+ const entry = resourceEntries[i];
+ // $FlowFixMe[prop-missing]
+ const transferSize: number = entry.transferSize;
+ // $FlowFixMe[prop-missing]
+ const initiatorType: string = entry.initiatorType;
+ const duration = entry.duration;
+ if (
+ !transferSize ||
+ !duration ||
+ !isLikelyStaticResource(initiatorType)
+ ) {
+ // Skip cached, cross-orgin entries and resources likely to be dynamically generated.
+ continue;
+ }
+ // Find any overlapping entries that were transferring at the same time since the total
+ // bps at the time will include those bytes.
+ let overlappingBytes = 0;
+ // $FlowFixMe[prop-missing]
+ const parentEndTime: number = entry.responseEnd;
+ let j;
+ for (j = i + 1; j < resourceEntries.length; j++) {
+ const overlapEntry = resourceEntries[j];
+ const overlapStartTime = overlapEntry.startTime;
+ if (overlapStartTime > parentEndTime) {
+ break;
+ }
+ // $FlowFixMe[prop-missing]
+ const overlapTransferSize: number = overlapEntry.transferSize;
+ // $FlowFixMe[prop-missing]
+ const overlapInitiatorType: string = overlapEntry.initiatorType;
+ if (
+ !overlapTransferSize ||
+ !isLikelyStaticResource(overlapInitiatorType)
+ ) {
+ // Skip cached, cross-orgin entries and resources likely to be dynamically generated.
+ continue;
+ }
+ // $FlowFixMe[prop-missing]
+ const overlapEndTime: number = overlapEntry.responseEnd;
+ const overlapFactor =
+ overlapEndTime < parentEndTime
+ ? 1
+ : (parentEndTime - overlapStartTime) /
+ (overlapEndTime - overlapStartTime);
+ overlappingBytes += overlapTransferSize * overlapFactor;
+ }
+ // Skip past any entries we already considered overlapping. Otherwise we'd have to go
+ // back to consider previous entries when we then handled them.
+ i = j - 1;
+
+ const bps =
+ ((transferSize + overlappingBytes) * 8) / (entry.duration / 1000);
+ bits += bps;
+ count++;
+ if (count > 10) {
+ // We have enough to get an average.
+ break;
+ }
+ }
+ if (count > 0) {
+ return bits / count / 1e6;
+ }
+ }
+
+ // Fallback to the navigator.connection estimate if available
+ // $FlowFixMe[prop-missing]
+ if (navigator.connection) {
+ // $FlowFixMe
+ const downlink: ?number = navigator.connection.downlink;
+ if (typeof downlink === 'number') {
+ return downlink;
+ }
+ }
+
+ // Otherwise, use a default of 5mbps to compute heuristics.
+ // This can happen commonly in Safari if all static resources and images are loaded
+ // cross-orgin.
+ return 5;
+}