From 017310834050e10405669de9607a2d28a70ac34e Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 13 Sep 2025 12:46:23 -0400 Subject: [PATCH 1/2] Add bandwidth estimation algorithm --- .../src/client/estimateBandwidth.js | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 packages/react-dom-bindings/src/client/estimateBandwidth.js 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 0000000000000..4b143a5b562c3 --- /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; +} From abcb8d11b0fe578fb3de28fb53df2bddd9cdbdf1 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 13 Sep 2025 14:39:46 -0400 Subject: [PATCH 2/2] Limit suspensey image timeout to 50ms if we don't think we can download all images within 500ms Since we're more conservative about starting to wait now, we now wait longer if we have started waiting so that we didn't wait for no good reason. --- .../view-transition/src/components/Page.js | 3 +- .../src/client/ReactFiberConfigDOM.js | 41 ++++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index 587306fe9578f..658ed686293c7 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 72c061e09e565..1778d212cac49 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;