[Fiber] Adjust the suspensey image/css timeout based on already elapsed time (#34478)
Currently suspensey images doesn't account for how long we've already been waiting. This means that you can for example wait for 300ms for the throttle + 500ms for the images. If a Transition takes a while to complete you can also wait that time + an additional 500ms for the images. This tracks the start time of a Transition so that we can count the timeout starting from when the user interacted or when the last fallback committed (which is where the 300ms throttle is computed from). Creating a single timeline. This also moves the timeout to a central place which I'll use in a follow up.
Sebastian Markbåge committed
Sep 15, 2025 at 16:05 UTC
e3f191803cbe53df360b32f6735b05bb969c55cf
9 files changed
+77
-28
packages/react-art/src/ReactFiberConfigART.js
+1
-1
@@ -615,7 +615,7 @@ export function suspendInstance(instance, type, props) {}
615
616
export function suspendOnActiveViewTransition(container) {}
617
618
-export function waitForCommitToBeReady() {
618
+export function waitForCommitToBeReady(timeoutOffset) {
619
return null;
620
}
621
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+49
-19
@@ -5905,7 +5905,9 @@ export function preloadResource(resource: Resource): boolean {
5905
5906
type SuspendedState = {
5907
stylesheets: null | Map<StylesheetResource, HoistableRoot>,
5908
- count: number,
5908
+ count: number, // suspensey css and active view transitions
5909
+ imgCount: number, // suspensey images
5910
+ waitingForImages: boolean, // false when we're no longer blocking on images
5911
unsuspend: null | (() => void),
5912
};
5913
let suspendedState: null | SuspendedState = null;
@@ -5914,6 +5916,8 @@ export function startSuspendingCommit(): void {
5916
suspendedState = {
5917
stylesheets: null,
5918
count: 0,
5919
+ imgCount: 0,
5920
+ waitingForImages: true,
5921
// We use a noop function when we begin suspending because if possible we want the
5922
// waitfor step to finish synchronously. If it doesn't we'll return a function to
5923
// provide the actual unsuspend function and that will get completed when the count
@@ -5922,6 +5926,8 @@ export function startSuspendingCommit(): void {
5926
};
5927
}
5928
5929
+const SUSPENSEY_STYLESHEET_TIMEOUT = 60000;
5930
+
5931
const SUSPENSEY_IMAGE_TIMEOUT = 500;
5932
5933
export function suspendInstance(
@@ -5946,13 +5952,10 @@ export function suspendInstance(
5952
// If this browser supports decode() API, we use it to suspend waiting on the image.
5953
// The loading should have already started at this point, so it should be enough to
5954
// just call decode() which should also wait for the data to finish loading.
5949
- state.count++;
5950
- const ping = onUnsuspend.bind(state);
5951
- Promise.race([
5952
- // $FlowFixMe[prop-missing]
5953
- instance.decode(),
5954
- new Promise(resolve => setTimeout(resolve, SUSPENSEY_IMAGE_TIMEOUT)),
5955
- ]).then(ping, ping);
5955
+ state.imgCount++;
5956
+ const ping = onUnsuspendImg.bind(state);
5957
+ // $FlowFixMe[prop-missing]
5958
+ instance.decode().then(ping, ping);
5959
}
5960
}
5961
@@ -6067,7 +6070,9 @@ export function suspendOnActiveViewTransition(rootContainer: Container): void {
6070
activeViewTransition.finished.then(ping, ping);
6071
}
6072
6070
-export function waitForCommitToBeReady(): null | ((() => void) => () => void) {
6073
+export function waitForCommitToBeReady(
6074
+ timeoutOffset: number,
6075
+): null | ((() => void) => () => void) {
6076
if (suspendedState === null) {
6077
throw new Error(
6078
'Internal React Error: suspendedState null when it was expected to exists. Please report this as a React bug.',
@@ -6085,7 +6090,7 @@ export function waitForCommitToBeReady(): null | ((() => void) => () => void) {
6090
6091
// We need to check the count again because the inserted stylesheets may have led to new
6092
// tasks to wait on.
6088
- if (state.count > 0) {
6093
+ if (state.count > 0 || state.imgCount > 0) {
6094
return commit => {
6095
// We almost never want to show content before its styles have loaded. But
6096
// eventually we will give up and allow unstyled content. So this number is
@@ -6102,37 +6107,62 @@ export function waitForCommitToBeReady(): null | ((() => void) => () => void) {
6107
state.unsuspend = null;
6108
unsuspend();
6109
}
6105
- }, 60000); // one minute
6110
+ }, SUSPENSEY_STYLESHEET_TIMEOUT + timeoutOffset);
6111
+
6112
+ const imgTimer = setTimeout(() => {
6113
+ // We're no longer blocked on images. If CSS resolves after this we can commit.
6114
+ state.waitingForImages = false;
6115
+ if (state.count === 0) {
6116
+ if (state.stylesheets) {
6117
+ insertSuspendedStylesheets(state, state.stylesheets);
6118
+ }
6119
+ if (state.unsuspend) {
6120
+ const unsuspend = state.unsuspend;
6121
+ state.unsuspend = null;
6122
+ unsuspend();
6123
+ }
6124
+ }
6125
+ }, SUSPENSEY_IMAGE_TIMEOUT + timeoutOffset);
6126
6127
state.unsuspend = commit;
6128
6129
return () => {
6130
state.unsuspend = null;
6131
clearTimeout(stylesheetTimer);
6132
+ clearTimeout(imgTimer);
6133
};
6134
};
6135
}
6136
return null;
6137
}
6138
6118
-function onUnsuspend(this: SuspendedState) {
6119
- this.count--;
6120
- if (this.count === 0) {
6121
- if (this.stylesheets) {
6139
+function checkIfFullyUnsuspended(state: SuspendedState) {
6140
+ if (state.count === 0 && (state.imgCount === 0 || !state.waitingForImages)) {
6141
+ if (state.stylesheets) {
6142
// If we haven't actually inserted the stylesheets yet we need to do so now before starting the commit.
6143
// The reason we do this after everything else has finished is because we want to have all the stylesheets
6144
// load synchronously right before mutating. Ideally the new styles will cause a single recalc only on the
6145
// new tree. When we filled up stylesheets we only inlcuded stylesheets with matching media attributes so we
6146
// wait for them to load before actually continuing. We expect this to increase the count above zero
6127
- insertSuspendedStylesheets(this, this.stylesheets);
6128
- } else if (this.unsuspend) {
6129
- const unsuspend = this.unsuspend;
6130
- this.unsuspend = null;
6147
+ insertSuspendedStylesheets(state, state.stylesheets);
6148
+ } else if (state.unsuspend) {
6149
+ const unsuspend = state.unsuspend;
6150
+ state.unsuspend = null;
6151
unsuspend();
6152
}
6153
}
6154
}
6155
6156
+function onUnsuspend(this: SuspendedState) {
6157
+ this.count--;
6158
+ checkIfFullyUnsuspended(this);
6159
+}
6160
+
6161
+function onUnsuspendImg(this: SuspendedState) {
6162
+ this.imgCount--;
6163
+ checkIfFullyUnsuspended(this);
6164
+}
6165
+
6166
// We use a value that is type distinct from precedence to track which one is last.
6167
// This ensures there is no collision with user defined precedences. Normally we would
6168
// just track this in module scope but since the precedences are tracked per HoistableRoot
packages/react-native-renderer/src/ReactFiberConfigFabric.js
+1
-1
@@ -612,7 +612,7 @@ export function suspendInstance(
612
613
export function suspendOnActiveViewTransition(container: Container): void {}
614
615
-export function waitForCommitToBeReady(): null {
615
+export function waitForCommitToBeReady(timeoutOffset: number): null {
616
return null;
617
}
618
packages/react-native-renderer/src/ReactFiberConfigNative.js
+1
-1
@@ -788,7 +788,7 @@ export function suspendInstance(
788
789
export function suspendOnActiveViewTransition(container: Container): void {}
790
791
-export function waitForCommitToBeReady(): null {
791
+export function waitForCommitToBeReady(timeoutOffset: number): null {
792
return null;
793
}
794
packages/react-noop-renderer/src/createReactNoop.js
+3
-3
@@ -361,9 +361,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
361
}
362
}
363
364
- function waitForCommitToBeReady():
365
- | ((commit: () => mixed) => () => void)
366
- | null {
364
+ function waitForCommitToBeReady(
365
+ timeoutOffset: number,
366
+ ): ((commit: () => mixed) => () => void) | null {
367
const subscription = suspenseyCommitSubscription;
368
if (subscription !== null) {
369
suspenseyCommitSubscription = null;
packages/react-reconciler/src/ReactFiberTransition.js
+2
@@ -28,6 +28,7 @@ import {createCursor, push, pop} from './ReactFiberStack';
28
import {
29
getWorkInProgressRoot,
30
getWorkInProgressTransitions,
31
+ markTransitionStarted,
32
} from './ReactFiberWorkLoop';
33
import {
34
createCache,
@@ -79,6 +80,7 @@ ReactSharedInternals.S = function onStartTransitionFinishForReconciler(
80
transition: Transition,
81
returnValue: mixed,
82
) {
83
+ markTransitionStarted();
84
if (
85
typeof returnValue === 'object' &&
86
returnValue !== null &&
packages/react-reconciler/src/ReactFiberWorkLoop.js
+18
-1
@@ -475,6 +475,11 @@ let didIncludeCommitPhaseUpdate: boolean = false;
475
// content as it streams in, to minimize jank.
476
// TODO: Think of a better name for this variable?
477
let globalMostRecentFallbackTime: number = 0;
478
+// Track the most recent time we started a new Transition. This lets us apply
479
+// heuristics like the suspensey image timeout based on how long we've waited
480
+// already.
481
+let globalMostRecentTransitionTime: number = 0;
482
+
483
const FALLBACK_THROTTLE_MS: number = 300;
484
485
// The absolute time for when we should start giving up on rendering
@@ -1500,10 +1505,18 @@ function commitRootWhenReady(
1505
suspendOnActiveViewTransition(root.containerInfo);
1506
}
1507
}
1508
+ // For timeouts we use the previous fallback commit for retries and
1509
+ // the start time of the transition for transitions. This offset
1510
+ // represents the time already passed.
1511
+ const timeoutOffset = includesOnlyRetries(lanes)
1512
+ ? globalMostRecentFallbackTime - now()
1513
+ : includesOnlyTransitions(lanes)
1514
+ ? globalMostRecentTransitionTime - now()
1515
+ : 0;
1516
// At the end, ask the renderer if it's ready to commit, or if we should
1517
// suspend. If it's not ready, it will return a callback to subscribe to
1518
// a ready event.
1506
- const schedulePendingCommit = waitForCommitToBeReady();
1519
+ const schedulePendingCommit = waitForCommitToBeReady(timeoutOffset);
1520
if (schedulePendingCommit !== null) {
1521
// NOTE: waitForCommitToBeReady returns a subscribe function so that we
1522
// only allocate a function if the commit isn't ready yet. The other
@@ -2284,6 +2297,10 @@ export function markRenderDerivedCause(fiber: Fiber): void {
2297
}
2298
}
2299
2300
+export function markTransitionStarted() {
2301
+ globalMostRecentTransitionTime = now();
2302
+}
2303
+
2304
export function markCommitTimeOfFallback() {
2305
globalMostRecentFallbackTime = now();
2306
}
packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js
+1
-1
@@ -109,7 +109,7 @@ describe('ReactFiberHostContext', () => {
109
startSuspendingCommit() {},
110
suspendInstance(instance, type, props) {},
111
suspendOnActiveViewTransition(container) {},
112
- waitForCommitToBeReady() {
112
+ waitForCommitToBeReady(timeoutOffset: number) {
113
return null;
114
},
115
supportsMutation: true,
packages/react-test-renderer/src/ReactFiberConfigTestHost.js
+1
-1
@@ -571,7 +571,7 @@ export function suspendInstance(
571
572
export function suspendOnActiveViewTransition(container: Container): void {}
573
574
-export function waitForCommitToBeReady(): null {
574
+export function waitForCommitToBeReady(timeoutOffset: number): null {
575
return null;
576
}
577