Add Suspensey Images behind a Flag (#32819)

We've known we've wanted this for many years and most of the
implementation was already done for Suspensey CSS. This waits to commit
until images have decoded by default or up to 500ms timeout (same as
suspensey fonts).

It only applies to Transitions, Retries (Suspense), Gesture Transitions
(flag) and Idle (doesn't exist). Sync updates just commit immediately.

`<img loading="lazy" src="..." />` opts out since you explicitly want it
to load lazily in that case.

`<img onLoad={...} src="..." />` also opts out since that implies you're
ok with managing your own reveal.

In the future, we may add an opt in e.g. `<img blocking="render"
src="..." />` that opts into longer timeouts and re-suspends even sync
updates. Perhaps also triggering error boundaries on errors.

The rollout for this would have to go in a major and we may have to
relax the default timeout to not delay too much by default. However, we
can also make this part of `enableViewTransition` so that if you opt-in
by using View Transitions then those animations will suspend on images.
That we could ship in a minor.
This commit is contained in:
Sebastian Markbåge 2025-04-04 14:54:05 -04:00 committed by GitHub
parent 540cd65252
commit efb22d8850
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 301 additions and 48 deletions

View File

@ -41,6 +41,12 @@ function Component() {
transitions['enter-slide-right'] + ' ' + transitions['exit-slide-left']
}>
<p className="roboto-font">Slide In from Left, Slide Out to Right</p>
<p>
<img
src="https://react.dev/_next/image?url=%2Fimages%2Fteam%2Fsebmarkbage.jpg&w=3840&q=75"
width="300"
/>
</p>
</ViewTransition>
);
}

View File

@ -596,6 +596,14 @@ export function maySuspendCommit(type, props) {
return false;
}
export function maySuspendCommitOnUpdate(type, oldProps, newProps) {
return false;
}
export function maySuspendCommitInSyncRender(type, props) {
return false;
}
export function preloadInstance(type, props) {
// Return true to indicate it's already loaded
return true;
@ -603,7 +611,7 @@ export function preloadInstance(type, props) {
export function startSuspendingCommit() {}
export function suspendInstance(type, props) {}
export function suspendInstance(instance, type, props) {}
export function suspendOnActiveViewTransition(container) {}

View File

@ -103,6 +103,7 @@ import {
disableLegacyMode,
enableMoveBefore,
disableCommentsAsDOMContainers,
enableSuspenseyImages,
} from 'shared/ReactFeatureFlags';
import {
HostComponent,
@ -145,6 +146,10 @@ export type Props = {
is?: string,
size?: number,
multiple?: boolean,
src?: string,
srcSet?: string,
loading?: 'eager' | 'lazy',
onLoad?: (event: any) => void,
...
};
type RawProps = {
@ -769,9 +774,9 @@ export function commitMount(
// only need to assign one. And Safari just never triggers a new load event which means this technique
// is already a noop regardless of which properties are assigned. We should revisit if browsers update
// this heuristic in the future.
if ((newProps: any).src) {
if (newProps.src) {
((domElement: any): HTMLImageElement).src = (newProps: any).src;
} else if ((newProps: any).srcSet) {
} else if (newProps.srcSet) {
((domElement: any): HTMLImageElement).srcset = (newProps: any).srcSet;
}
return;
@ -4974,6 +4979,36 @@ export function isHostHoistableType(
}
export function maySuspendCommit(type: Type, props: Props): boolean {
if (!enableSuspenseyImages) {
return false;
}
// Suspensey images are the default, unless you opt-out of with either
// loading="lazy" or onLoad={...} which implies you're ok waiting.
return (
type === 'img' &&
props.src != null &&
props.src !== '' &&
props.onLoad == null &&
props.loading !== 'lazy'
);
}
export function maySuspendCommitOnUpdate(
type: Type,
oldProps: Props,
newProps: Props,
): boolean {
return (
maySuspendCommit(type, newProps) &&
(newProps.src !== oldProps.src || newProps.srcSet !== oldProps.srcSet)
);
}
export function maySuspendCommitInSyncRender(
type: Type,
props: Props,
): boolean {
// TODO: Allow sync lanes to suspend too with an opt-in.
return false;
}
@ -4984,8 +5019,17 @@ export function mayResourceSuspendCommit(resource: Resource): boolean {
);
}
export function preloadInstance(type: Type, props: Props): boolean {
return true;
export function preloadInstance(
instance: Instance,
type: Type,
props: Props,
): boolean {
// We don't need to preload Suspensey images because the browser will
// load them early once we set the src.
// If we return true here, we'll still get a suspendInstance call in the
// pre-commit phase to determine if we still need to decode the image or
// if was dropped from cache. This just avoids rendering Suspense fallback.
return !!(instance: any).complete;
}
export function preloadResource(resource: Resource): boolean {
@ -5022,8 +5066,38 @@ export function startSuspendingCommit(): void {
};
}
export function suspendInstance(type: Type, props: Props): void {
return;
const SUSPENSEY_IMAGE_TIMEOUT = 500;
export function suspendInstance(
instance: Instance,
type: Type,
props: Props,
): void {
if (!enableSuspenseyImages) {
return;
}
if (suspendedState === null) {
throw new Error(
'Internal React Error: suspendedState null when it was expected to exists. Please report this as a React bug.',
);
}
const state = suspendedState;
if (
// $FlowFixMe[prop-missing]
typeof instance.decode === 'function' &&
typeof setTimeout === 'function'
) {
// 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);
}
}
export function suspendResource(

View File

@ -577,13 +577,36 @@ export function maySuspendCommit(type: Type, props: Props): boolean {
return false;
}
export function preloadInstance(type: Type, props: Props): boolean {
export function maySuspendCommitOnUpdate(
type: Type,
oldProps: Props,
newProps: Props,
): boolean {
return false;
}
export function maySuspendCommitInSyncRender(
type: Type,
props: Props,
): boolean {
return false;
}
export function preloadInstance(
instance: Instance,
type: Type,
props: Props,
): boolean {
return true;
}
export function startSuspendingCommit(): void {}
export function suspendInstance(type: Type, props: Props): void {}
export function suspendInstance(
instance: Instance,
type: Type,
props: Props,
): void {}
export function suspendOnActiveViewTransition(container: Container): void {}

View File

@ -735,14 +735,37 @@ export function maySuspendCommit(type: Type, props: Props): boolean {
return false;
}
export function preloadInstance(type: Type, props: Props): boolean {
export function maySuspendCommitOnUpdate(
type: Type,
oldProps: Props,
newProps: Props,
): boolean {
return false;
}
export function maySuspendCommitInSyncRender(
type: Type,
props: Props,
): boolean {
return false;
}
export function preloadInstance(
instance: Instance,
type: Type,
props: Props,
): boolean {
// Return false to indicate it's already loaded
return true;
}
export function startSuspendingCommit(): void {}
export function suspendInstance(type: Type, props: Props): void {}
export function suspendInstance(
instance: Instance,
type: Type,
props: Props,
): void {}
export function suspendOnActiveViewTransition(container: Container): void {}

View File

@ -320,7 +320,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
suspenseyCommitSubscription = null;
}
function suspendInstance(type: string, props: Props): void {
function suspendInstance(
instance: Instance,
type: string,
props: Props,
): void {
const src = props.src;
if (type === 'suspensey-thing' && typeof src === 'string') {
// Attach a listener to the suspensey thing and create a subscription
@ -624,13 +628,33 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
return type === 'suspensey-thing' && typeof props.src === 'string';
},
maySuspendCommitOnUpdate(
type: string,
oldProps: Props,
newProps: Props,
): boolean {
// Asks whether it's possible for this combination of type and props
// to ever need to suspend. This is different from asking whether it's
// currently ready because even if it's ready now, it might get purged
// from the cache later.
return (
type === 'suspensey-thing' &&
typeof newProps.src === 'string' &&
newProps.src !== oldProps.src
);
},
maySuspendCommitInSyncRender(type: string, props: Props): boolean {
return true;
},
mayResourceSuspendCommit(resource: mixed): boolean {
throw new Error(
'Resources are not implemented for React Noop yet. This method should not be called',
);
},
preloadInstance(type: string, props: Props): boolean {
preloadInstance(instance: Instance, type: string, props: Props): boolean {
if (type !== 'suspensey-thing' || typeof props.src !== 'string') {
throw new Error('Attempted to preload unexpected instance: ' + type);
}

View File

@ -18,7 +18,10 @@ import type {
} from './ReactFiberConfig';
import type {Fiber, FiberRoot} from './ReactInternalTypes';
import type {Lanes} from './ReactFiberLane';
import {includesOnlyViewTransitionEligibleLanes} from './ReactFiberLane';
import {
includesOnlySuspenseyCommitEligibleLanes,
includesOnlyViewTransitionEligibleLanes,
} from './ReactFiberLane';
import type {SuspenseState, RetryQueue} from './ReactFiberSuspenseComponent';
import type {UpdateQueue} from './ReactFiberClassUpdateQueue';
import type {FunctionComponentUpdateQueue} from './ReactFiberHooks';
@ -160,6 +163,7 @@ import {
mountHoistable,
unmountHoistable,
prepareToCommitHoistables,
maySuspendCommitInSyncRender,
suspendInstance,
suspendResource,
resetFormInstance,
@ -4280,25 +4284,31 @@ export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
// ViewTransitions so that we know to also visit those to collect appearing
// pairs.
let suspenseyCommitFlag = ShouldSuspendCommit;
export function accumulateSuspenseyCommit(finishedWork: Fiber): void {
export function accumulateSuspenseyCommit(
finishedWork: Fiber,
committedLanes: Lanes,
): void {
resetAppearingViewTransitions();
accumulateSuspenseyCommitOnFiber(finishedWork);
accumulateSuspenseyCommitOnFiber(finishedWork, committedLanes);
}
function recursivelyAccumulateSuspenseyCommit(parentFiber: Fiber): void {
function recursivelyAccumulateSuspenseyCommit(
parentFiber: Fiber,
committedLanes: Lanes,
): void {
if (parentFiber.subtreeFlags & suspenseyCommitFlag) {
let child = parentFiber.child;
while (child !== null) {
accumulateSuspenseyCommitOnFiber(child);
accumulateSuspenseyCommitOnFiber(child, committedLanes);
child = child.sibling;
}
}
}
function accumulateSuspenseyCommitOnFiber(fiber: Fiber) {
function accumulateSuspenseyCommitOnFiber(fiber: Fiber, committedLanes: Lanes) {
switch (fiber.tag) {
case HostHoistable: {
recursivelyAccumulateSuspenseyCommit(fiber);
recursivelyAccumulateSuspenseyCommit(fiber, committedLanes);
if (fiber.flags & suspenseyCommitFlag) {
if (fiber.memoizedState !== null) {
suspendResource(
@ -4308,19 +4318,33 @@ function accumulateSuspenseyCommitOnFiber(fiber: Fiber) {
fiber.memoizedProps,
);
} else {
const instance = fiber.stateNode;
const type = fiber.type;
const props = fiber.memoizedProps;
suspendInstance(type, props);
// TODO: Allow sync lanes to suspend too with an opt-in.
if (
includesOnlySuspenseyCommitEligibleLanes(committedLanes) ||
maySuspendCommitInSyncRender(type, props)
) {
suspendInstance(instance, type, props);
}
}
}
break;
}
case HostComponent: {
recursivelyAccumulateSuspenseyCommit(fiber);
recursivelyAccumulateSuspenseyCommit(fiber, committedLanes);
if (fiber.flags & suspenseyCommitFlag) {
const instance = fiber.stateNode;
const type = fiber.type;
const props = fiber.memoizedProps;
suspendInstance(type, props);
// TODO: Allow sync lanes to suspend too with an opt-in.
if (
includesOnlySuspenseyCommitEligibleLanes(committedLanes) ||
maySuspendCommitInSyncRender(type, props)
) {
suspendInstance(instance, type, props);
}
}
break;
}
@ -4331,10 +4355,10 @@ function accumulateSuspenseyCommitOnFiber(fiber: Fiber) {
const container: Container = fiber.stateNode.containerInfo;
currentHoistableRoot = getHoistableRoot(container);
recursivelyAccumulateSuspenseyCommit(fiber);
recursivelyAccumulateSuspenseyCommit(fiber, committedLanes);
currentHoistableRoot = previousHoistableRoot;
} else {
recursivelyAccumulateSuspenseyCommit(fiber);
recursivelyAccumulateSuspenseyCommit(fiber, committedLanes);
}
break;
}
@ -4352,10 +4376,10 @@ function accumulateSuspenseyCommitOnFiber(fiber: Fiber) {
// instances, even if they're in the current tree.
const prevFlags = suspenseyCommitFlag;
suspenseyCommitFlag = MaySuspendCommit;
recursivelyAccumulateSuspenseyCommit(fiber);
recursivelyAccumulateSuspenseyCommit(fiber, committedLanes);
suspenseyCommitFlag = prevFlags;
} else {
recursivelyAccumulateSuspenseyCommit(fiber);
recursivelyAccumulateSuspenseyCommit(fiber, committedLanes);
}
}
break;
@ -4375,13 +4399,13 @@ function accumulateSuspenseyCommitOnFiber(fiber: Fiber) {
trackAppearingViewTransition(name, state);
}
}
recursivelyAccumulateSuspenseyCommit(fiber);
recursivelyAccumulateSuspenseyCommit(fiber, committedLanes);
break;
}
// Fallthrough
}
default: {
recursivelyAccumulateSuspenseyCommit(fiber);
recursivelyAccumulateSuspenseyCommit(fiber, committedLanes);
}
}
}

View File

@ -116,6 +116,8 @@ import {
preparePortalMount,
prepareScopeUpdate,
maySuspendCommit,
maySuspendCommitOnUpdate,
maySuspendCommitInSyncRender,
mayResourceSuspendCommit,
preloadInstance,
preloadResource,
@ -167,6 +169,7 @@ import {
includesSomeLane,
mergeLanes,
claimNextRetryLane,
includesOnlySuspenseyCommitEligibleLanes,
} from './ReactFiberLane';
import {resetChildFibers} from './ReactChildFiber';
import {createScopeInstance} from './ReactFiberScope';
@ -547,10 +550,16 @@ function updateHostComponent(
function preloadInstanceAndSuspendIfNeeded(
workInProgress: Fiber,
type: Type,
props: Props,
oldProps: null | Props,
newProps: Props,
renderLanes: Lanes,
) {
if (!maySuspendCommit(type, props)) {
const maySuspend =
oldProps === null
? maySuspendCommit(type, newProps)
: maySuspendCommitOnUpdate(type, oldProps, newProps);
if (!maySuspend) {
// If this flag was set previously, we can remove it. The flag
// represents whether this particular set of props might ever need to
// suspend. The safest thing to do is for maySuspendCommit to always
@ -568,15 +577,25 @@ function preloadInstanceAndSuspendIfNeeded(
// loaded yet.
workInProgress.flags |= MaySuspendCommit;
// preload the instance if necessary. Even if this is an urgent render there
// could be benefits to preloading early.
// @TODO we should probably do the preload in begin work
const isReady = preloadInstance(type, props);
if (!isReady) {
if (shouldRemainOnPreviousScreen()) {
workInProgress.flags |= ShouldSuspendCommit;
if (
includesOnlySuspenseyCommitEligibleLanes(renderLanes) ||
maySuspendCommitInSyncRender(type, newProps)
) {
// preload the instance if necessary. Even if this is an urgent render there
// could be benefits to preloading early.
// @TODO we should probably do the preload in begin work
const isReady = preloadInstance(workInProgress.stateNode, type, newProps);
if (!isReady) {
if (shouldRemainOnPreviousScreen()) {
workInProgress.flags |= ShouldSuspendCommit;
} else {
suspendCommit();
}
} else {
suspendCommit();
// Even if we're ready we suspend the commit and check again in the pre-commit
// phase if we need to suspend anyway. Such as if it's delayed on decoding or
// if it was dropped from the cache while rendering due to pressure.
workInProgress.flags |= ShouldSuspendCommit;
}
}
}
@ -1104,6 +1123,7 @@ function completeWork(
preloadInstanceAndSuspendIfNeeded(
workInProgress,
type,
null,
newProps,
renderLanes,
);
@ -1137,10 +1157,10 @@ function completeWork(
return null;
}
} else {
const oldProps = current.memoizedProps;
// This is an Instance
// We may have props to update on the Hoistable instance.
if (supportsMutation) {
const oldProps = current.memoizedProps;
if (oldProps !== newProps) {
markUpdate(workInProgress);
}
@ -1160,6 +1180,7 @@ function completeWork(
preloadInstanceAndSuspendIfNeeded(
workInProgress,
type,
oldProps,
newProps,
renderLanes,
);
@ -1323,6 +1344,7 @@ function completeWork(
preloadInstanceAndSuspendIfNeeded(
workInProgress,
workInProgress.type,
current === null ? null : current.memoizedProps,
workInProgress.pendingProps,
renderLanes,
);

View File

@ -637,6 +637,14 @@ export function includesOnlyViewTransitionEligibleLanes(lanes: Lanes): boolean {
return (lanes & (TransitionLanes | RetryLanes | IdleLane)) === lanes;
}
export function includesOnlySuspenseyCommitEligibleLanes(
lanes: Lanes,
): boolean {
return (
(lanes & (TransitionLanes | RetryLanes | IdleLane | GestureLane)) === lanes
);
}
export function includesBlockingLane(lanes: Lanes): boolean {
const SyncDefaultLanes =
InputContinuousHydrationLane |

View File

@ -645,9 +645,9 @@ export function logSuspendedCommitPhase(
reusableLaneDevToolDetails.color = 'secondary-light';
reusableLaneOptions.start = startTime;
reusableLaneOptions.end = endTime;
// TODO: Make this conditionally "Suspended on Images" or both when we add Suspensey Images.
// TODO: Include the exact reason and URLs of what resources suspended.
// TODO: This might also be Suspended while waiting on a View Transition.
performance.measure('Suspended on CSS', reusableLaneOptions);
performance.measure('Suspended on CSS or Images', reusableLaneOptions);
}
}

View File

@ -1467,7 +1467,7 @@ function commitRootWhenReady(
// transaction, so it track state in its own module scope.
// This will also track any newly added or appearing ViewTransition
// components for the purposes of forming pairs.
accumulateSuspenseyCommit(finishedWork);
accumulateSuspenseyCommit(finishedWork, lanes);
if (isViewTransitionEligible || isGestureTransition) {
// If we're stopping gestures we don't have to wait for any pending
// view transition. We'll stop it when we commit.
@ -2638,7 +2638,7 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
const props = hostFiber.pendingProps;
const isReady = resource
? preloadResource(resource)
: preloadInstance(type, props);
: preloadInstance(hostFiber.stateNode, type, props);
if (isReady) {
// The data resolved. Resume the work loop as if nothing
// suspended. Unlike when a user component suspends, we don't

View File

@ -97,11 +97,17 @@ describe('ReactFiberHostContext', () => {
maySuspendCommit(type, props) {
return false;
},
preloadInstance(type, props) {
maySuspendCommitOnUpdate(type, oldProps, newProps) {
return false;
},
maySuspendCommitInSyncRender(type, props) {
return false;
},
preloadInstance(instance, type, props) {
return true;
},
startSuspendingCommit() {},
suspendInstance(type, props) {},
suspendInstance(instance, type, props) {},
suspendOnActiveViewTransition(container) {},
waitForCommitToBeReady() {
return null;

View File

@ -88,6 +88,9 @@ export const shouldAttemptEagerTransition =
export const detachDeletedInstance = $$$config.detachDeletedInstance;
export const requestPostPaintCallback = $$$config.requestPostPaintCallback;
export const maySuspendCommit = $$$config.maySuspendCommit;
export const maySuspendCommitOnUpdate = $$$config.maySuspendCommitOnUpdate;
export const maySuspendCommitInSyncRender =
$$$config.maySuspendCommitInSyncRender;
export const preloadInstance = $$$config.preloadInstance;
export const startSuspendingCommit = $$$config.startSuspendingCommit;
export const suspendInstance = $$$config.suspendInstance;

View File

@ -537,14 +537,37 @@ export function maySuspendCommit(type: Type, props: Props): boolean {
return false;
}
export function preloadInstance(type: Type, props: Props): boolean {
export function maySuspendCommitOnUpdate(
type: Type,
oldProps: Props,
newProps: Props,
): boolean {
return false;
}
export function maySuspendCommitInSyncRender(
type: Type,
props: Props,
): boolean {
return false;
}
export function preloadInstance(
instance: Instance,
type: Type,
props: Props,
): boolean {
// Return true to indicate it's already loaded
return true;
}
export function startSuspendingCommit(): void {}
export function suspendInstance(type: Type, props: Props): void {}
export function suspendInstance(
instance: Instance,
type: Type,
props: Props,
): void {}
export function suspendOnActiveViewTransition(container: Container): void {}

View File

@ -96,6 +96,8 @@ export const enableGestureTransition = __EXPERIMENTAL__;
export const enableScrollEndPolyfill = __EXPERIMENTAL__;
export const enableSuspenseyImages = __EXPERIMENTAL__;
/**
* Switches the Fabric API from doing layout in commit work instead of complete work.
*/

View File

@ -82,6 +82,7 @@ export const enableThrottledScheduling = false;
export const enableViewTransition = false;
export const enableGestureTransition = false;
export const enableScrollEndPolyfill = true;
export const enableSuspenseyImages = false;
export const enableFragmentRefs = false;
export const ownerStackLimit = 1e4;

View File

@ -74,6 +74,7 @@ export const enableGestureTransition = false;
export const enableFastAddPropertiesInDiffing = false;
export const enableLazyPublicInstanceInFabric = false;
export const enableScrollEndPolyfill = true;
export const enableSuspenseyImages = false;
export const ownerStackLimit = 1e4;
export const enableFragmentRefs = false;

View File

@ -73,6 +73,7 @@ export const enableGestureTransition = false;
export const enableFastAddPropertiesInDiffing = true;
export const enableLazyPublicInstanceInFabric = false;
export const enableScrollEndPolyfill = true;
export const enableSuspenseyImages = false;
export const ownerStackLimit = 1e4;
export const enableFragmentRefs = false;

View File

@ -70,6 +70,7 @@ export const enableGestureTransition = false;
export const enableFastAddPropertiesInDiffing = false;
export const enableLazyPublicInstanceInFabric = false;
export const enableScrollEndPolyfill = true;
export const enableSuspenseyImages = false;
export const enableFragmentRefs = false;
export const ownerStackLimit = 1e4;

View File

@ -84,6 +84,7 @@ export const enableGestureTransition = false;
export const enableFastAddPropertiesInDiffing = false;
export const enableLazyPublicInstanceInFabric = false;
export const enableScrollEndPolyfill = true;
export const enableSuspenseyImages = false;
export const enableFragmentRefs = false;
export const ownerStackLimit = 1e4;

View File

@ -113,6 +113,8 @@ export const enableLazyPublicInstanceInFabric = false;
export const enableGestureTransition = false;
export const enableSuspenseyImages = false;
export const ownerStackLimit = 1e4;
// Flow magic to verify the exports of this file match the original version.