Skip to content

Commit 02ca3f9

Browse files
committed
[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. DiffTrain build for [ae22247](ae22247)
1 parent eabed3a commit 02ca3f9

24 files changed

+579
-149
lines changed

compiled-rn/VERSION_NATIVE_FB

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
19.2.0-native-fb-e3f19180-20250915
1+
19.2.0-native-fb-ae22247d-20250915

compiled-rn/facebook-fbsource/xplat/js/RKJSModules/vendor/react/react-dom/cjs/ReactDOM-dev.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @noflow
88
* @nolint
99
* @preventMunge
10-
* @generated SignedSource<<7843507958dab8de6d8cd79d77778079>>
10+
* @generated SignedSource<<23cc05fee84e9c81ab6a7406c14940e4>>
1111
*/
1212

1313
"use strict";
@@ -404,5 +404,5 @@ __DEV__ &&
404404
exports.useFormStatus = function () {
405405
return resolveDispatcher().useHostTransitionStatus();
406406
};
407-
exports.version = "19.2.0-native-fb-e3f19180-20250915";
407+
exports.version = "19.2.0-native-fb-ae22247d-20250915";
408408
})();

compiled-rn/facebook-fbsource/xplat/js/RKJSModules/vendor/react/react-dom/cjs/ReactDOM-prod.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @noflow
88
* @nolint
99
* @preventMunge
10-
* @generated SignedSource<<3c9853534e2eba8cfe4d1792a871aad8>>
10+
* @generated SignedSource<<f1a85f07f5eb7f943a9570b9d401499f>>
1111
*/
1212

1313
"use strict";
@@ -203,4 +203,4 @@ exports.useFormState = function (action, initialState, permalink) {
203203
exports.useFormStatus = function () {
204204
return ReactSharedInternals.H.useHostTransitionStatus();
205205
};
206-
exports.version = "19.2.0-native-fb-e3f19180-20250915";
206+
exports.version = "19.2.0-native-fb-ae22247d-20250915";

compiled-rn/facebook-fbsource/xplat/js/RKJSModules/vendor/react/react-dom/cjs/ReactDOM-profiling.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @noflow
88
* @nolint
99
* @preventMunge
10-
* @generated SignedSource<<3c9853534e2eba8cfe4d1792a871aad8>>
10+
* @generated SignedSource<<f1a85f07f5eb7f943a9570b9d401499f>>
1111
*/
1212

1313
"use strict";
@@ -203,4 +203,4 @@ exports.useFormState = function (action, initialState, permalink) {
203203
exports.useFormStatus = function () {
204204
return ReactSharedInternals.H.useHostTransitionStatus();
205205
};
206-
exports.version = "19.2.0-native-fb-e3f19180-20250915";
206+
exports.version = "19.2.0-native-fb-ae22247d-20250915";

compiled-rn/facebook-fbsource/xplat/js/RKJSModules/vendor/react/react-dom/cjs/ReactDOMClient-dev.js

Lines changed: 94 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @noflow
88
* @nolint
99
* @preventMunge
10-
* @generated SignedSource<<067121c2f01030f44e5bc3c0acd60ecf>>
10+
* @generated SignedSource<<e3366cdf976d39d2536c708f8a0a9f81>>
1111
*/
1212

1313
/*
@@ -17676,6 +17676,7 @@ __DEV__ &&
1767617676
stylesheets: null,
1767717677
count: 0,
1767817678
imgCount: 0,
17679+
imgBytes: 0,
1767917680
waitingForImages: !0,
1768017681
unsuspend: noop$1
1768117682
}),
@@ -23165,6 +23166,71 @@ __DEV__ &&
2316523166
);
2316623167
}
2316723168
}
23169+
function isLikelyStaticResource(initiatorType) {
23170+
switch (initiatorType) {
23171+
case "css":
23172+
case "script":
23173+
case "font":
23174+
case "img":
23175+
case "image":
23176+
case "input":
23177+
case "link":
23178+
return !0;
23179+
default:
23180+
return !1;
23181+
}
23182+
}
23183+
function estimateBandwidth() {
23184+
if ("function" === typeof performance.getEntriesByType) {
23185+
for (
23186+
var count = 0,
23187+
bits = 0,
23188+
resourceEntries = performance.getEntriesByType("resource"),
23189+
i = 0;
23190+
i < resourceEntries.length;
23191+
i++
23192+
) {
23193+
var entry = resourceEntries[i],
23194+
transferSize = entry.transferSize,
23195+
initiatorType = entry.initiatorType,
23196+
duration = entry.duration;
23197+
if (
23198+
transferSize &&
23199+
duration &&
23200+
isLikelyStaticResource(initiatorType)
23201+
) {
23202+
initiatorType = 0;
23203+
duration = entry.responseEnd;
23204+
for (i += 1; i < resourceEntries.length; i++) {
23205+
var overlapEntry = resourceEntries[i],
23206+
overlapStartTime = overlapEntry.startTime;
23207+
if (overlapStartTime > duration) break;
23208+
var overlapTransferSize = overlapEntry.transferSize,
23209+
overlapInitiatorType = overlapEntry.initiatorType;
23210+
overlapTransferSize &&
23211+
isLikelyStaticResource(overlapInitiatorType) &&
23212+
((overlapEntry = overlapEntry.responseEnd),
23213+
(initiatorType +=
23214+
overlapTransferSize *
23215+
(overlapEntry < duration
23216+
? 1
23217+
: (duration - overlapStartTime) /
23218+
(overlapEntry - overlapStartTime))));
23219+
}
23220+
--i;
23221+
bits +=
23222+
(8 * (transferSize + initiatorType)) / (entry.duration / 1e3);
23223+
count++;
23224+
if (10 < count) break;
23225+
}
23226+
}
23227+
if (0 < count) return bits / count / 1e6;
23228+
}
23229+
return navigator.connection &&
23230+
((count = navigator.connection.downlink), "number" === typeof count)
23231+
? count
23232+
: 5;
23233+
}
2316823234
function getOwnerDocumentFromRootContainer(rootContainerElement) {
2316923235
return 9 === rootContainerElement.nodeType
2317023236
? rootContainerElement
@@ -24639,15 +24705,20 @@ __DEV__ &&
2463924705
return 0 < state.count || 0 < state.imgCount
2464024706
? function (commit) {
2464124707
var stylesheetTimer = setTimeout(function () {
24642-
state.stylesheets &&
24643-
insertSuspendedStylesheets(state, state.stylesheets);
24644-
if (state.unsuspend) {
24645-
var unsuspend = state.unsuspend;
24646-
state.unsuspend = null;
24647-
unsuspend();
24648-
}
24649-
}, SUSPENSEY_STYLESHEET_TIMEOUT + timeoutOffset),
24650-
imgTimer = setTimeout(function () {
24708+
state.stylesheets &&
24709+
insertSuspendedStylesheets(state, state.stylesheets);
24710+
if (state.unsuspend) {
24711+
var unsuspend = state.unsuspend;
24712+
state.unsuspend = null;
24713+
unsuspend();
24714+
}
24715+
}, SUSPENSEY_STYLESHEET_TIMEOUT + timeoutOffset);
24716+
0 < state.imgBytes &&
24717+
0 === estimatedBytesWithinLimit &&
24718+
(estimatedBytesWithinLimit =
24719+
125 * estimateBandwidth() * SUSPENSEY_IMAGE_TIME_ESTIMATE);
24720+
var imgTimer = setTimeout(
24721+
function () {
2465124722
state.waitingForImages = !1;
2465224723
if (
2465324724
0 === state.count &&
@@ -24659,7 +24730,11 @@ __DEV__ &&
2465924730
state.unsuspend = null;
2466024731
unsuspend();
2466124732
}
24662-
}, SUSPENSEY_IMAGE_TIMEOUT + timeoutOffset);
24733+
},
24734+
(state.imgBytes > estimatedBytesWithinLimit
24735+
? 50
24736+
: SUSPENSEY_IMAGE_TIMEOUT) + timeoutOffset
24737+
);
2466324738
state.unsuspend = commit;
2466424739
return function () {
2466524740
state.unsuspend = null;
@@ -29488,7 +29563,9 @@ __DEV__ &&
2948829563
tagCaches = null,
2948929564
suspendedState = null,
2949029565
SUSPENSEY_STYLESHEET_TIMEOUT = 6e4,
29491-
SUSPENSEY_IMAGE_TIMEOUT = 500,
29566+
SUSPENSEY_IMAGE_TIMEOUT = 800,
29567+
SUSPENSEY_IMAGE_TIME_ESTIMATE = 500,
29568+
estimatedBytesWithinLimit = 0,
2949229569
LAST_PRECEDENCE = null,
2949329570
precedencesByRoot = null,
2949429571
NotPendingTransition = NotPending,
@@ -29654,11 +29731,11 @@ __DEV__ &&
2965429731
};
2965529732
(function () {
2965629733
var isomorphicReactPackageVersion = React.version;
29657-
if ("19.2.0-native-fb-e3f19180-20250915" !== isomorphicReactPackageVersion)
29734+
if ("19.2.0-native-fb-ae22247d-20250915" !== isomorphicReactPackageVersion)
2965829735
throw Error(
2965929736
'Incompatible React versions: The "react" and "react-dom" packages must have the exact same version. Instead got:\n - react: ' +
2966029737
(isomorphicReactPackageVersion +
29661-
"\n - react-dom: 19.2.0-native-fb-e3f19180-20250915\nLearn more: https://react.dev/warnings/version-mismatch")
29738+
"\n - react-dom: 19.2.0-native-fb-ae22247d-20250915\nLearn more: https://react.dev/warnings/version-mismatch")
2966229739
);
2966329740
})();
2966429741
("function" === typeof Map &&
@@ -29695,10 +29772,10 @@ __DEV__ &&
2969529772
!(function () {
2969629773
var internals = {
2969729774
bundleType: 1,
29698-
version: "19.2.0-native-fb-e3f19180-20250915",
29775+
version: "19.2.0-native-fb-ae22247d-20250915",
2969929776
rendererPackageName: "react-dom",
2970029777
currentDispatcherRef: ReactSharedInternals,
29701-
reconcilerVersion: "19.2.0-native-fb-e3f19180-20250915"
29778+
reconcilerVersion: "19.2.0-native-fb-ae22247d-20250915"
2970229779
};
2970329780
internals.overrideHookState = overrideHookState;
2970429781
internals.overrideHookStateDeletePath = overrideHookStateDeletePath;
@@ -29847,5 +29924,5 @@ __DEV__ &&
2984729924
listenToAllSupportedEvents(container);
2984829925
return new ReactDOMHydrationRoot(initialChildren);
2984929926
};
29850-
exports.version = "19.2.0-native-fb-e3f19180-20250915";
29927+
exports.version = "19.2.0-native-fb-ae22247d-20250915";
2985129928
})();

compiled-rn/facebook-fbsource/xplat/js/RKJSModules/vendor/react/react-dom/cjs/ReactDOMClient-prod.js

Lines changed: 85 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @noflow
88
* @nolint
99
* @preventMunge
10-
* @generated SignedSource<<ccfeb8b9b258fc79d3617b86111b456b>>
10+
* @generated SignedSource<<5612290c13f4ab16126fa19519c127ae>>
1111
*/
1212

1313
/*
@@ -11692,6 +11692,7 @@ function commitRootWhenReady(
1169211692
stylesheets: null,
1169311693
count: 0,
1169411694
imgCount: 0,
11695+
imgBytes: 0,
1169511696
waitingForImages: !0,
1169611697
unsuspend: noop$1
1169711698
}),
@@ -14950,6 +14951,66 @@ function updateProperties(domElement, tag, lastProps, nextProps) {
1495014951
(null == propKey$217 && null == propKey) ||
1495114952
setProp(domElement, tag, lastProp, propKey$217, nextProps, propKey);
1495214953
}
14954+
function isLikelyStaticResource(initiatorType) {
14955+
switch (initiatorType) {
14956+
case "css":
14957+
case "script":
14958+
case "font":
14959+
case "img":
14960+
case "image":
14961+
case "input":
14962+
case "link":
14963+
return !0;
14964+
default:
14965+
return !1;
14966+
}
14967+
}
14968+
function estimateBandwidth() {
14969+
if ("function" === typeof performance.getEntriesByType) {
14970+
for (
14971+
var count = 0,
14972+
bits = 0,
14973+
resourceEntries = performance.getEntriesByType("resource"),
14974+
i = 0;
14975+
i < resourceEntries.length;
14976+
i++
14977+
) {
14978+
var entry = resourceEntries[i],
14979+
transferSize = entry.transferSize,
14980+
initiatorType = entry.initiatorType,
14981+
duration = entry.duration;
14982+
if (transferSize && duration && isLikelyStaticResource(initiatorType)) {
14983+
initiatorType = 0;
14984+
duration = entry.responseEnd;
14985+
for (i += 1; i < resourceEntries.length; i++) {
14986+
var overlapEntry = resourceEntries[i],
14987+
overlapStartTime = overlapEntry.startTime;
14988+
if (overlapStartTime > duration) break;
14989+
var overlapTransferSize = overlapEntry.transferSize,
14990+
overlapInitiatorType = overlapEntry.initiatorType;
14991+
overlapTransferSize &&
14992+
isLikelyStaticResource(overlapInitiatorType) &&
14993+
((overlapEntry = overlapEntry.responseEnd),
14994+
(initiatorType +=
14995+
overlapTransferSize *
14996+
(overlapEntry < duration
14997+
? 1
14998+
: (duration - overlapStartTime) /
14999+
(overlapEntry - overlapStartTime))));
15000+
}
15001+
--i;
15002+
bits += (8 * (transferSize + initiatorType)) / (entry.duration / 1e3);
15003+
count++;
15004+
if (10 < count) break;
15005+
}
15006+
}
15007+
if (0 < count) return bits / count / 1e6;
15008+
}
15009+
return navigator.connection &&
15010+
((count = navigator.connection.downlink), "number" === typeof count)
15011+
? count
15012+
: 5;
15013+
}
1495315014
var eventsEnabled = null,
1495415015
selectionInformation = null;
1495515016
function getOwnerDocumentFromRootContainer(rootContainerElement) {
@@ -16496,6 +16557,7 @@ function suspendResource(hoistableRoot, resource, props) {
1649616557
hoistableRoot.addEventListener("error", resource));
1649716558
}
1649816559
}
16560+
var estimatedBytesWithinLimit = 0;
1649916561
function waitForCommitToBeReady(timeoutOffset) {
1650016562
if (null === suspendedState) throw Error(formatProdErrorMessage(475));
1650116563
var state = suspendedState;
@@ -16505,15 +16567,19 @@ function waitForCommitToBeReady(timeoutOffset) {
1650516567
return 0 < state.count || 0 < state.imgCount
1650616568
? function (commit) {
1650716569
var stylesheetTimer = setTimeout(function () {
16508-
state.stylesheets &&
16509-
insertSuspendedStylesheets(state, state.stylesheets);
16510-
if (state.unsuspend) {
16511-
var unsuspend = state.unsuspend;
16512-
state.unsuspend = null;
16513-
unsuspend();
16514-
}
16515-
}, 6e4 + timeoutOffset),
16516-
imgTimer = setTimeout(function () {
16570+
state.stylesheets &&
16571+
insertSuspendedStylesheets(state, state.stylesheets);
16572+
if (state.unsuspend) {
16573+
var unsuspend = state.unsuspend;
16574+
state.unsuspend = null;
16575+
unsuspend();
16576+
}
16577+
}, 6e4 + timeoutOffset);
16578+
0 < state.imgBytes &&
16579+
0 === estimatedBytesWithinLimit &&
16580+
(estimatedBytesWithinLimit = 62500 * estimateBandwidth());
16581+
var imgTimer = setTimeout(
16582+
function () {
1651716583
state.waitingForImages = !1;
1651816584
if (
1651916585
0 === state.count &&
@@ -16525,7 +16591,10 @@ function waitForCommitToBeReady(timeoutOffset) {
1652516591
state.unsuspend = null;
1652616592
unsuspend();
1652716593
}
16528-
}, 500 + timeoutOffset);
16594+
},
16595+
(state.imgBytes > estimatedBytesWithinLimit ? 50 : 800) +
16596+
timeoutOffset
16597+
);
1652916598
state.unsuspend = commit;
1653016599
return function () {
1653116600
state.unsuspend = null;
@@ -17416,14 +17485,14 @@ ReactDOMHydrationRoot.prototype.unstable_scheduleHydration = function (target) {
1741617485
};
1741717486
var isomorphicReactPackageVersion$jscomp$inline_2073 = React.version;
1741817487
if (
17419-
"19.2.0-native-fb-e3f19180-20250915" !==
17488+
"19.2.0-native-fb-ae22247d-20250915" !==
1742017489
isomorphicReactPackageVersion$jscomp$inline_2073
1742117490
)
1742217491
throw Error(
1742317492
formatProdErrorMessage(
1742417493
527,
1742517494
isomorphicReactPackageVersion$jscomp$inline_2073,
17426-
"19.2.0-native-fb-e3f19180-20250915"
17495+
"19.2.0-native-fb-ae22247d-20250915"
1742717496
)
1742817497
);
1742917498
ReactDOMSharedInternals.findDOMNode = function (componentOrElement) {
@@ -17445,10 +17514,10 @@ ReactDOMSharedInternals.findDOMNode = function (componentOrElement) {
1744517514
};
1744617515
var internals$jscomp$inline_2646 = {
1744717516
bundleType: 0,
17448-
version: "19.2.0-native-fb-e3f19180-20250915",
17517+
version: "19.2.0-native-fb-ae22247d-20250915",
1744917518
rendererPackageName: "react-dom",
1745017519
currentDispatcherRef: ReactSharedInternals,
17451-
reconcilerVersion: "19.2.0-native-fb-e3f19180-20250915"
17520+
reconcilerVersion: "19.2.0-native-fb-ae22247d-20250915"
1745217521
};
1745317522
if ("undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__) {
1745417523
var hook$jscomp$inline_2647 = __REACT_DEVTOOLS_GLOBAL_HOOK__;
@@ -17555,4 +17624,4 @@ exports.hydrateRoot = function (container, initialChildren, options) {
1755517624
listenToAllSupportedEvents(container);
1755617625
return new ReactDOMHydrationRoot(initialChildren);
1755717626
};
17558-
exports.version = "19.2.0-native-fb-e3f19180-20250915";
17627+
exports.version = "19.2.0-native-fb-ae22247d-20250915";

0 commit comments

Comments
 (0)