[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 waitForCommitToBeReady() {
export function waitForCommitToBeReady(timeoutOffset) {
return null;
}

View File

@ -5905,7 +5905,9 @@ export function preloadResource(resource: Resource): boolean {
type SuspendedState = {
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),
};
let suspendedState: null | SuspendedState = null;
@ -5914,6 +5916,8 @@ export function startSuspendingCommit(): void {
suspendedState = {
stylesheets: null,
count: 0,
imgCount: 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
// 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;
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.
// 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.count++;
const ping = onUnsuspend.bind(state);
Promise.race([
// $FlowFixMe[prop-missing]
instance.decode(),
new Promise(resolve => setTimeout(resolve, SUSPENSEY_IMAGE_TIMEOUT)),
]).then(ping, ping);
state.imgCount++;
const ping = onUnsuspendImg.bind(state);
// $FlowFixMe[prop-missing]
instance.decode().then(ping, ping);
}
}
@ -6067,7 +6070,9 @@ export function suspendOnActiveViewTransition(rootContainer: Container): void {
activeViewTransition.finished.then(ping, ping);
}
export function waitForCommitToBeReady(): null | ((() => void) => () => void) {
export function waitForCommitToBeReady(
timeoutOffset: number,
): null | ((() => void) => () => void) {
if (suspendedState === null) {
throw new Error(
'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
// tasks to wait on.
if (state.count > 0) {
if (state.count > 0 || state.imgCount > 0) {
return commit => {
// 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
@ -6102,37 +6107,62 @@ export function waitForCommitToBeReady(): null | ((() => void) => () => void) {
state.unsuspend = null;
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;
return () => {
state.unsuspend = null;
clearTimeout(stylesheetTimer);
clearTimeout(imgTimer);
};
};
}
return null;
}
function onUnsuspend(this: SuspendedState) {
this.count--;
if (this.count === 0) {
if (this.stylesheets) {
function checkIfFullyUnsuspended(state: SuspendedState) {
if (state.count === 0 && (state.imgCount === 0 || !state.waitingForImages)) {
if (state.stylesheets) {
// 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
// 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
// wait for them to load before actually continuing. We expect this to increase the count above zero
insertSuspendedStylesheets(this, this.stylesheets);
} else if (this.unsuspend) {
const unsuspend = this.unsuspend;
this.unsuspend = null;
insertSuspendedStylesheets(state, state.stylesheets);
} else if (state.unsuspend) {
const unsuspend = state.unsuspend;
state.unsuspend = null;
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.
// 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

View File

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

View File

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

View File

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

View File

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

View File

@ -475,6 +475,11 @@ let didIncludeCommitPhaseUpdate: boolean = false;
// content as it streams in, to minimize jank.
// TODO: Think of a better name for this variable?
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;
// The absolute time for when we should start giving up on rendering
@ -1500,10 +1505,18 @@ function commitRootWhenReady(
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
// suspend. If it's not ready, it will return a callback to subscribe to
// a ready event.
const schedulePendingCommit = waitForCommitToBeReady();
const schedulePendingCommit = waitForCommitToBeReady(timeoutOffset);
if (schedulePendingCommit !== null) {
// NOTE: waitForCommitToBeReady returns a subscribe function so that we
// 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() {
globalMostRecentFallbackTime = now();
}

View File

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

View File

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