Skip to content
6 changes: 6 additions & 0 deletions fixtures/view-transition/src/components/Page.js
Original file line number Diff line number Diff line change
Expand Up @@ -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>
);
}
Expand Down
10 changes: 9 additions & 1 deletion packages/react-art/src/ReactFiberConfigART.js
Original file line number Diff line number Diff line change
Expand Up @@ -596,14 +596,22 @@ 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;
}

export function startSuspendingCommit() {}

export function suspendInstance(type, props) {}
export function suspendInstance(instance, type, props) {}

export function suspendOnActiveViewTransition(container) {}

Expand Down
81 changes: 76 additions & 5 deletions packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ import {
disableLegacyMode,
enableMoveBefore,
disableCommentsAsDOMContainers,
enableSuspenseyImages,
} from 'shared/ReactFeatureFlags';
import {
HostComponent,
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand All @@ -4985,7 +5020,13 @@ export function mayResourceSuspendCommit(resource: Resource): boolean {
}

export function preloadInstance(type: Type, props: Props): boolean {
return true;
// We don't need to preload Suspensey images because the browser will
// load them early once we set the src.
// We indicate that all images are not yet loaded and if they're able
// to hit cache we let the decode() do that. Even if we did maintain
// our own cache to know this, it's not a guarantee that the browser
// keeps it in decoded memory.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The consequence of this is that we end up showing fallbacks for a Suspense boundary in a Transition even if we've preloaded it before revealing it (which its is throttled in its reveal).

We might want to add a heuristic here in case it is synchronously available to let it commit anyway. Even though that can lead to flashes due to images not being decoded.

The Suspense optimization could help here because we could yield and see if we can eagerly solve it in a micro-task however that could force decoding a bit too early which gives opportunity for the decoding cache to get dropped before commit.

I think instead, what might be ideal is to detect if an image is already loaded and if so, only mark the commit as might suspend but not suspend the Suspense boundary. I think I'll do that.

return false;
}

export function preloadResource(resource: Resource): boolean {
Expand Down Expand Up @@ -5022,8 +5063,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(
Expand Down
21 changes: 20 additions & 1 deletion packages/react-native-renderer/src/ReactFiberConfigFabric.js
Original file line number Diff line number Diff line change
Expand Up @@ -577,13 +577,32 @@ export function maySuspendCommit(type: Type, props: Props): boolean {
return false;
}

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(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 {}

Expand Down
21 changes: 20 additions & 1 deletion packages/react-native-renderer/src/ReactFiberConfigNative.js
Original file line number Diff line number Diff line change
Expand Up @@ -735,14 +735,33 @@ export function maySuspendCommit(type: Type, props: Props): boolean {
return false;
}

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(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 {}

Expand Down
26 changes: 25 additions & 1 deletion packages/react-noop-renderer/src/createReactNoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -624,6 +628,26 @@ 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',
Expand Down
Loading
Loading