Skip to content

Commit efb22d8

Browse files
authored
Add Suspensey Images behind a Flag (facebook#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.
1 parent 540cd65 commit efb22d8

21 files changed

+301
-48
lines changed

fixtures/view-transition/src/components/Page.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ function Component() {
4141
transitions['enter-slide-right'] + ' ' + transitions['exit-slide-left']
4242
}>
4343
<p className="roboto-font">Slide In from Left, Slide Out to Right</p>
44+
<p>
45+
<img
46+
src="https://react.dev/_next/image?url=%2Fimages%2Fteam%2Fsebmarkbage.jpg&w=3840&q=75"
47+
width="300"
48+
/>
49+
</p>
4450
</ViewTransition>
4551
);
4652
}

packages/react-art/src/ReactFiberConfigART.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -596,14 +596,22 @@ export function maySuspendCommit(type, props) {
596596
return false;
597597
}
598598

599+
export function maySuspendCommitOnUpdate(type, oldProps, newProps) {
600+
return false;
601+
}
602+
603+
export function maySuspendCommitInSyncRender(type, props) {
604+
return false;
605+
}
606+
599607
export function preloadInstance(type, props) {
600608
// Return true to indicate it's already loaded
601609
return true;
602610
}
603611

604612
export function startSuspendingCommit() {}
605613

606-
export function suspendInstance(type, props) {}
614+
export function suspendInstance(instance, type, props) {}
607615

608616
export function suspendOnActiveViewTransition(container) {}
609617

packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ import {
103103
disableLegacyMode,
104104
enableMoveBefore,
105105
disableCommentsAsDOMContainers,
106+
enableSuspenseyImages,
106107
} from 'shared/ReactFeatureFlags';
107108
import {
108109
HostComponent,
@@ -145,6 +146,10 @@ export type Props = {
145146
is?: string,
146147
size?: number,
147148
multiple?: boolean,
149+
src?: string,
150+
srcSet?: string,
151+
loading?: 'eager' | 'lazy',
152+
onLoad?: (event: any) => void,
148153
...
149154
};
150155
type RawProps = {
@@ -769,9 +774,9 @@ export function commitMount(
769774
// only need to assign one. And Safari just never triggers a new load event which means this technique
770775
// is already a noop regardless of which properties are assigned. We should revisit if browsers update
771776
// this heuristic in the future.
772-
if ((newProps: any).src) {
777+
if (newProps.src) {
773778
((domElement: any): HTMLImageElement).src = (newProps: any).src;
774-
} else if ((newProps: any).srcSet) {
779+
} else if (newProps.srcSet) {
775780
((domElement: any): HTMLImageElement).srcset = (newProps: any).srcSet;
776781
}
777782
return;
@@ -4974,6 +4979,36 @@ export function isHostHoistableType(
49744979
}
49754980

49764981
export function maySuspendCommit(type: Type, props: Props): boolean {
4982+
if (!enableSuspenseyImages) {
4983+
return false;
4984+
}
4985+
// Suspensey images are the default, unless you opt-out of with either
4986+
// loading="lazy" or onLoad={...} which implies you're ok waiting.
4987+
return (
4988+
type === 'img' &&
4989+
props.src != null &&
4990+
props.src !== '' &&
4991+
props.onLoad == null &&
4992+
props.loading !== 'lazy'
4993+
);
4994+
}
4995+
4996+
export function maySuspendCommitOnUpdate(
4997+
type: Type,
4998+
oldProps: Props,
4999+
newProps: Props,
5000+
): boolean {
5001+
return (
5002+
maySuspendCommit(type, newProps) &&
5003+
(newProps.src !== oldProps.src || newProps.srcSet !== oldProps.srcSet)
5004+
);
5005+
}
5006+
5007+
export function maySuspendCommitInSyncRender(
5008+
type: Type,
5009+
props: Props,
5010+
): boolean {
5011+
// TODO: Allow sync lanes to suspend too with an opt-in.
49775012
return false;
49785013
}
49795014

@@ -4984,8 +5019,17 @@ export function mayResourceSuspendCommit(resource: Resource): boolean {
49845019
);
49855020
}
49865021

4987-
export function preloadInstance(type: Type, props: Props): boolean {
4988-
return true;
5022+
export function preloadInstance(
5023+
instance: Instance,
5024+
type: Type,
5025+
props: Props,
5026+
): boolean {
5027+
// We don't need to preload Suspensey images because the browser will
5028+
// load them early once we set the src.
5029+
// If we return true here, we'll still get a suspendInstance call in the
5030+
// pre-commit phase to determine if we still need to decode the image or
5031+
// if was dropped from cache. This just avoids rendering Suspense fallback.
5032+
return !!(instance: any).complete;
49895033
}
49905034

49915035
export function preloadResource(resource: Resource): boolean {
@@ -5022,8 +5066,38 @@ export function startSuspendingCommit(): void {
50225066
};
50235067
}
50245068

5025-
export function suspendInstance(type: Type, props: Props): void {
5026-
return;
5069+
const SUSPENSEY_IMAGE_TIMEOUT = 500;
5070+
5071+
export function suspendInstance(
5072+
instance: Instance,
5073+
type: Type,
5074+
props: Props,
5075+
): void {
5076+
if (!enableSuspenseyImages) {
5077+
return;
5078+
}
5079+
if (suspendedState === null) {
5080+
throw new Error(
5081+
'Internal React Error: suspendedState null when it was expected to exists. Please report this as a React bug.',
5082+
);
5083+
}
5084+
const state = suspendedState;
5085+
if (
5086+
// $FlowFixMe[prop-missing]
5087+
typeof instance.decode === 'function' &&
5088+
typeof setTimeout === 'function'
5089+
) {
5090+
// If this browser supports decode() API, we use it to suspend waiting on the image.
5091+
// The loading should have already started at this point, so it should be enough to
5092+
// just call decode() which should also wait for the data to finish loading.
5093+
state.count++;
5094+
const ping = onUnsuspend.bind(state);
5095+
Promise.race([
5096+
// $FlowFixMe[prop-missing]
5097+
instance.decode(),
5098+
new Promise(resolve => setTimeout(resolve, SUSPENSEY_IMAGE_TIMEOUT)),
5099+
]).then(ping, ping);
5100+
}
50275101
}
50285102

50295103
export function suspendResource(

packages/react-native-renderer/src/ReactFiberConfigFabric.js

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -577,13 +577,36 @@ export function maySuspendCommit(type: Type, props: Props): boolean {
577577
return false;
578578
}
579579

580-
export function preloadInstance(type: Type, props: Props): boolean {
580+
export function maySuspendCommitOnUpdate(
581+
type: Type,
582+
oldProps: Props,
583+
newProps: Props,
584+
): boolean {
585+
return false;
586+
}
587+
588+
export function maySuspendCommitInSyncRender(
589+
type: Type,
590+
props: Props,
591+
): boolean {
592+
return false;
593+
}
594+
595+
export function preloadInstance(
596+
instance: Instance,
597+
type: Type,
598+
props: Props,
599+
): boolean {
581600
return true;
582601
}
583602

584603
export function startSuspendingCommit(): void {}
585604

586-
export function suspendInstance(type: Type, props: Props): void {}
605+
export function suspendInstance(
606+
instance: Instance,
607+
type: Type,
608+
props: Props,
609+
): void {}
587610

588611
export function suspendOnActiveViewTransition(container: Container): void {}
589612

packages/react-native-renderer/src/ReactFiberConfigNative.js

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -735,14 +735,37 @@ export function maySuspendCommit(type: Type, props: Props): boolean {
735735
return false;
736736
}
737737

738-
export function preloadInstance(type: Type, props: Props): boolean {
738+
export function maySuspendCommitOnUpdate(
739+
type: Type,
740+
oldProps: Props,
741+
newProps: Props,
742+
): boolean {
743+
return false;
744+
}
745+
746+
export function maySuspendCommitInSyncRender(
747+
type: Type,
748+
props: Props,
749+
): boolean {
750+
return false;
751+
}
752+
753+
export function preloadInstance(
754+
instance: Instance,
755+
type: Type,
756+
props: Props,
757+
): boolean {
739758
// Return false to indicate it's already loaded
740759
return true;
741760
}
742761

743762
export function startSuspendingCommit(): void {}
744763

745-
export function suspendInstance(type: Type, props: Props): void {}
764+
export function suspendInstance(
765+
instance: Instance,
766+
type: Type,
767+
props: Props,
768+
): void {}
746769

747770
export function suspendOnActiveViewTransition(container: Container): void {}
748771

packages/react-noop-renderer/src/createReactNoop.js

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
320320
suspenseyCommitSubscription = null;
321321
}
322322

323-
function suspendInstance(type: string, props: Props): void {
323+
function suspendInstance(
324+
instance: Instance,
325+
type: string,
326+
props: Props,
327+
): void {
324328
const src = props.src;
325329
if (type === 'suspensey-thing' && typeof src === 'string') {
326330
// Attach a listener to the suspensey thing and create a subscription
@@ -624,13 +628,33 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
624628
return type === 'suspensey-thing' && typeof props.src === 'string';
625629
},
626630

631+
maySuspendCommitOnUpdate(
632+
type: string,
633+
oldProps: Props,
634+
newProps: Props,
635+
): boolean {
636+
// Asks whether it's possible for this combination of type and props
637+
// to ever need to suspend. This is different from asking whether it's
638+
// currently ready because even if it's ready now, it might get purged
639+
// from the cache later.
640+
return (
641+
type === 'suspensey-thing' &&
642+
typeof newProps.src === 'string' &&
643+
newProps.src !== oldProps.src
644+
);
645+
},
646+
647+
maySuspendCommitInSyncRender(type: string, props: Props): boolean {
648+
return true;
649+
},
650+
627651
mayResourceSuspendCommit(resource: mixed): boolean {
628652
throw new Error(
629653
'Resources are not implemented for React Noop yet. This method should not be called',
630654
);
631655
},
632656

633-
preloadInstance(type: string, props: Props): boolean {
657+
preloadInstance(instance: Instance, type: string, props: Props): boolean {
634658
if (type !== 'suspensey-thing' || typeof props.src !== 'string') {
635659
throw new Error('Attempted to preload unexpected instance: ' + type);
636660
}

0 commit comments

Comments
 (0)