[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.
This commit is contained in:
Sebastian Markbåge 2025-09-15 16:05:20 -04:00 committed by GitHub
parent e12b0bdc3b
commit e3f191803c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 77 additions and 28 deletions

View File

@ -615,7 +615,7 @@ export function suspendInstance(instance, type, props) {}
export function suspendOnActiveViewTransition(container) {} export function suspendOnActiveViewTransition(container) {}
export function waitForCommitToBeReady() { export function waitForCommitToBeReady(timeoutOffset) {
return null; return null;
} }

View File

@ -5905,7 +5905,9 @@ export function preloadResource(resource: Resource): boolean {
type SuspendedState = { type SuspendedState = {
stylesheets: null | Map<StylesheetResource, HoistableRoot>, stylesheets: null | Map<StylesheetResource, HoistableRoot>,
count: number, count: number, // suspensey css and active view transitions
imgCount: number, // suspensey images
waitingForImages: boolean, // false when we're no longer blocking on images
unsuspend: null | (() => void), unsuspend: null | (() => void),
}; };
let suspendedState: null | SuspendedState = null; let suspendedState: null | SuspendedState = null;
@ -5914,6 +5916,8 @@ export function startSuspendingCommit(): void {
suspendedState = { suspendedState = {
stylesheets: null, stylesheets: null,
count: 0, count: 0,
imgCount: 0,
waitingForImages: true,
// We use a noop function when we begin suspending because if possible we want the // 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 // waitfor step to finish synchronously. If it doesn't we'll return a function to
// provide the actual unsuspend function and that will get completed when the count // provide the actual unsuspend function and that will get completed when the count
@ -5922,6 +5926,8 @@ export function startSuspendingCommit(): void {
}; };
} }
const SUSPENSEY_STYLESHEET_TIMEOUT = 60000;
const SUSPENSEY_IMAGE_TIMEOUT = 500; const SUSPENSEY_IMAGE_TIMEOUT = 500;
export function suspendInstance( export function suspendInstance(
@ -5946,13 +5952,10 @@ export function suspendInstance(
// If this browser supports decode() API, we use it to suspend waiting on the image. // If this browser supports decode() API, we use it to suspend waiting on the image.
// The loading should have already started at this point, so it should be enough to // 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. // just call decode() which should also wait for the data to finish loading.
state.count++; state.imgCount++;
const ping = onUnsuspend.bind(state); const ping = onUnsuspendImg.bind(state);
Promise.race([ // $FlowFixMe[prop-missing]
// $FlowFixMe[prop-missing] instance.decode().then(ping, ping);
instance.decode(),
new Promise(resolve => setTimeout(resolve, SUSPENSEY_IMAGE_TIMEOUT)),
]).then(ping, ping);
} }
} }
@ -6067,7 +6070,9 @@ export function suspendOnActiveViewTransition(rootContainer: Container): void {
activeViewTransition.finished.then(ping, ping); activeViewTransition.finished.then(ping, ping);
} }
export function waitForCommitToBeReady(): null | ((() => void) => () => void) { export function waitForCommitToBeReady(
timeoutOffset: number,
): null | ((() => void) => () => void) {
if (suspendedState === null) { if (suspendedState === null) {
throw new Error( throw new Error(
'Internal React Error: suspendedState null when it was expected to exists. Please report this as a React bug.', '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) {
// We need to check the count again because the inserted stylesheets may have led to new // We need to check the count again because the inserted stylesheets may have led to new
// tasks to wait on. // tasks to wait on.
if (state.count > 0) { if (state.count > 0 || state.imgCount > 0) {
return commit => { return commit => {
// We almost never want to show content before its styles have loaded. But // We almost never want to show content before its styles have loaded. But
// eventually we will give up and allow unstyled content. So this number is // eventually we will give up and allow unstyled content. So this number is
@ -6102,37 +6107,62 @@ export function waitForCommitToBeReady(): null | ((() => void) => () => void) {
state.unsuspend = null; state.unsuspend = null;
unsuspend(); unsuspend();
} }
}, 60000); // one minute }, SUSPENSEY_STYLESHEET_TIMEOUT + timeoutOffset);
const imgTimer = setTimeout(() => {
// We're no longer blocked on images. If CSS resolves after this we can commit.
state.waitingForImages = false;
if (state.count === 0) {
if (state.stylesheets) {
insertSuspendedStylesheets(state, state.stylesheets);
}
if (state.unsuspend) {
const unsuspend = state.unsuspend;
state.unsuspend = null;
unsuspend();
}
}
}, SUSPENSEY_IMAGE_TIMEOUT + timeoutOffset);
state.unsuspend = commit; state.unsuspend = commit;
return () => { return () => {
state.unsuspend = null; state.unsuspend = null;
clearTimeout(stylesheetTimer); clearTimeout(stylesheetTimer);
clearTimeout(imgTimer);
}; };
}; };
} }
return null; return null;
} }
function onUnsuspend(this: SuspendedState) { function checkIfFullyUnsuspended(state: SuspendedState) {
this.count--; if (state.count === 0 && (state.imgCount === 0 || !state.waitingForImages)) {
if (this.count === 0) { if (state.stylesheets) {
if (this.stylesheets) {
// If we haven't actually inserted the stylesheets yet we need to do so now before starting the commit. // If we haven't actually inserted the stylesheets yet we need to do so now before starting the commit.
// The reason we do this after everything else has finished is because we want to have all the stylesheets // The reason we do this after everything else has finished is because we want to have all the stylesheets
// load synchronously right before mutating. Ideally the new styles will cause a single recalc only on the // load synchronously right before mutating. Ideally the new styles will cause a single recalc only on the
// new tree. When we filled up stylesheets we only inlcuded stylesheets with matching media attributes so we // new tree. When we filled up stylesheets we only inlcuded stylesheets with matching media attributes so we
// wait for them to load before actually continuing. We expect this to increase the count above zero // wait for them to load before actually continuing. We expect this to increase the count above zero
insertSuspendedStylesheets(this, this.stylesheets); insertSuspendedStylesheets(state, state.stylesheets);
} else if (this.unsuspend) { } else if (state.unsuspend) {
const unsuspend = this.unsuspend; const unsuspend = state.unsuspend;
this.unsuspend = null; state.unsuspend = null;
unsuspend(); unsuspend();
} }
} }
} }
function onUnsuspend(this: SuspendedState) {
this.count--;
checkIfFullyUnsuspended(this);
}
function onUnsuspendImg(this: SuspendedState) {
this.imgCount--;
checkIfFullyUnsuspended(this);
}
// We use a value that is type distinct from precedence to track which one is last. // We use a value that is type distinct from precedence to track which one is last.
// This ensures there is no collision with user defined precedences. Normally we would // This ensures there is no collision with user defined precedences. Normally we would
// just track this in module scope but since the precedences are tracked per HoistableRoot // just track this in module scope but since the precedences are tracked per HoistableRoot

View File

@ -612,7 +612,7 @@ export function suspendInstance(
export function suspendOnActiveViewTransition(container: Container): void {} export function suspendOnActiveViewTransition(container: Container): void {}
export function waitForCommitToBeReady(): null { export function waitForCommitToBeReady(timeoutOffset: number): null {
return null; return null;
} }

View File

@ -788,7 +788,7 @@ export function suspendInstance(
export function suspendOnActiveViewTransition(container: Container): void {} export function suspendOnActiveViewTransition(container: Container): void {}
export function waitForCommitToBeReady(): null { export function waitForCommitToBeReady(timeoutOffset: number): null {
return null; return null;
} }

View File

@ -361,9 +361,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
} }
} }
function waitForCommitToBeReady(): function waitForCommitToBeReady(
| ((commit: () => mixed) => () => void) timeoutOffset: number,
| null { ): ((commit: () => mixed) => () => void) | null {
const subscription = suspenseyCommitSubscription; const subscription = suspenseyCommitSubscription;
if (subscription !== null) { if (subscription !== null) {
suspenseyCommitSubscription = null; suspenseyCommitSubscription = null;

View File

@ -28,6 +28,7 @@ import {createCursor, push, pop} from './ReactFiberStack';
import { import {
getWorkInProgressRoot, getWorkInProgressRoot,
getWorkInProgressTransitions, getWorkInProgressTransitions,
markTransitionStarted,
} from './ReactFiberWorkLoop'; } from './ReactFiberWorkLoop';
import { import {
createCache, createCache,
@ -79,6 +80,7 @@ ReactSharedInternals.S = function onStartTransitionFinishForReconciler(
transition: Transition, transition: Transition,
returnValue: mixed, returnValue: mixed,
) { ) {
markTransitionStarted();
if ( if (
typeof returnValue === 'object' && typeof returnValue === 'object' &&
returnValue !== null && returnValue !== null &&

View File

@ -475,6 +475,11 @@ let didIncludeCommitPhaseUpdate: boolean = false;
// content as it streams in, to minimize jank. // content as it streams in, to minimize jank.
// TODO: Think of a better name for this variable? // TODO: Think of a better name for this variable?
let globalMostRecentFallbackTime: number = 0; let globalMostRecentFallbackTime: number = 0;
// Track the most recent time we started a new Transition. This lets us apply
// heuristics like the suspensey image timeout based on how long we've waited
// already.
let globalMostRecentTransitionTime: number = 0;
const FALLBACK_THROTTLE_MS: number = 300; const FALLBACK_THROTTLE_MS: number = 300;
// The absolute time for when we should start giving up on rendering // The absolute time for when we should start giving up on rendering
@ -1500,10 +1505,18 @@ function commitRootWhenReady(
suspendOnActiveViewTransition(root.containerInfo); suspendOnActiveViewTransition(root.containerInfo);
} }
} }
// For timeouts we use the previous fallback commit for retries and
// the start time of the transition for transitions. This offset
// represents the time already passed.
const timeoutOffset = includesOnlyRetries(lanes)
? globalMostRecentFallbackTime - now()
: includesOnlyTransitions(lanes)
? globalMostRecentTransitionTime - now()
: 0;
// At the end, ask the renderer if it's ready to commit, or if we should // At the end, ask the renderer if it's ready to commit, or if we should
// suspend. If it's not ready, it will return a callback to subscribe to // suspend. If it's not ready, it will return a callback to subscribe to
// a ready event. // a ready event.
const schedulePendingCommit = waitForCommitToBeReady(); const schedulePendingCommit = waitForCommitToBeReady(timeoutOffset);
if (schedulePendingCommit !== null) { if (schedulePendingCommit !== null) {
// NOTE: waitForCommitToBeReady returns a subscribe function so that we // NOTE: waitForCommitToBeReady returns a subscribe function so that we
// only allocate a function if the commit isn't ready yet. The other // only allocate a function if the commit isn't ready yet. The other
@ -2284,6 +2297,10 @@ export function markRenderDerivedCause(fiber: Fiber): void {
} }
} }
export function markTransitionStarted() {
globalMostRecentTransitionTime = now();
}
export function markCommitTimeOfFallback() { export function markCommitTimeOfFallback() {
globalMostRecentFallbackTime = now(); globalMostRecentFallbackTime = now();
} }

View File

@ -109,7 +109,7 @@ describe('ReactFiberHostContext', () => {
startSuspendingCommit() {}, startSuspendingCommit() {},
suspendInstance(instance, type, props) {}, suspendInstance(instance, type, props) {},
suspendOnActiveViewTransition(container) {}, suspendOnActiveViewTransition(container) {},
waitForCommitToBeReady() { waitForCommitToBeReady(timeoutOffset: number) {
return null; return null;
}, },
supportsMutation: true, supportsMutation: true,

View File

@ -571,7 +571,7 @@ export function suspendInstance(
export function suspendOnActiveViewTransition(container: Container): void {} export function suspendOnActiveViewTransition(container: Container): void {}
export function waitForCommitToBeReady(): null { export function waitForCommitToBeReady(timeoutOffset: number): null {
return null; return null;
} }