@samitouri / QOS-React / commits / 348a4e2d44

[Fiber] Wait for suspensey image in the viewport before starting an animation (#34500)

Stacked on #34486. If we gave up on loading suspensey images for blocking the commit (e.g. due to #34481), we can still block the view transition from committing to allow an animation to include the image from the start. At this point we have more information about the layout so we can include only the images that are within viewport in the calculation which may end up with a different answer. This only applies when we attempt to run an animation (e.g. something mutated inside a `<ViewTransition>` in a Transition). We could attempt a `startViewTransition` if we gave up on the suspensey images just so that we could block it even if no animation would be running. However, this point the screen is frozen and you can no longer have sync updates interrupt so ideally we would have already blocked the commit from happening in the first place. The reason to have two points where we block is that ideally we leave the UI responsive while blocking, which blocking the commit does. In the simple case of all images or a single image being within the viewport, that's favorable. By combining the techniques we only end up freezing the screen in the special case that we had a lot of images added outside the viewport and started an animation with some image inside the viewport (which presumably is about to finish anyway).

Sebastian Markbåge committed Sep 15, 2025 at 18:11 UTC 348a4e2d44fed940313d0657ee180083d12ffc04
2 files changed +71 -22
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+69 -22
@@ -2010,7 +2010,8 @@ function cancelAllViewTransitionAnimations(scope: Element) {
2010 // an issue when it's a new load and slow, yet long enough that you have a chance to load
2011 // it. Otherwise we wait for no reason. The assumption here is that you likely have
2012 // either cached the font or preloaded it earlier.
2013 -const SUSPENSEY_FONT_TIMEOUT = 500;
2013 +// This timeout is also used for Suspensey Images when they're blocking a View Transition.
2014 +const SUSPENSEY_FONT_AND_IMAGE_TIMEOUT = 500;
2015
2016 function customizeViewTransitionError(
2017 error: Object,
@@ -2080,6 +2081,13 @@ function forceLayout(ownerDocument: Document) {
2081 return (ownerDocument.documentElement: any).clientHeight;
2082 }
2083
2084 +function waitForImageToLoad(this: HTMLImageElement, resolve: () => void) {
2085 + // TODO: Use decode() instead of the load event here once the fix in
2086 + // https://issues.chromium.org/issues/420748301 has propagated fully.
2087 + this.addEventListener('load', resolve);
2088 + this.addEventListener('error', resolve);
2089 +}
2090 +
2091 export function startViewTransition(
2092 suspendedState: null | SuspendedState,
2093 rootContainer: Container,
@@ -2108,6 +2116,7 @@ export function startViewTransition(
2116 // $FlowFixMe[prop-missing]
2117 const previousFontLoadingStatus = ownerDocument.fonts.status;
2118 mutationCallback();
2119 + const blockingPromises: Array<Promise<any>> = [];
2120 if (previousFontLoadingStatus === 'loaded') {
2121 // Force layout calculation to trigger font loading.
2122 forceLayout(ownerDocument);
@@ -2119,19 +2128,51 @@ export function startViewTransition(
2128 // This avoids waiting for potentially unrelated fonts that were already loading before.
2129 // Either in an earlier transition or as part of a sync optimistic state. This doesn't
2130 // include preloads that happened earlier.
2122 - const fontsReady = Promise.race([
2123 - // $FlowFixMe[prop-missing]
2124 - ownerDocument.fonts.ready,
2125 - new Promise(resolve =>
2126 - setTimeout(resolve, SUSPENSEY_FONT_TIMEOUT),
2127 - ),
2128 - ]).then(layoutCallback, layoutCallback);
2129 - const allReady = pendingNavigation
2130 - ? Promise.allSettled([pendingNavigation.finished, fontsReady])
2131 - : fontsReady;
2132 - return allReady.then(afterMutationCallback, afterMutationCallback);
2131 + blockingPromises.push(ownerDocument.fonts.ready);
2132 }
2133 }
2134 + if (suspendedState !== null) {
2135 + // Suspend on any images that still haven't loaded and are in the viewport.
2136 + const suspenseyImages = suspendedState.suspenseyImages;
2137 + const blockingIndexSnapshot = blockingPromises.length;
2138 + let imgBytes = 0;
2139 + for (let i = 0; i < suspenseyImages.length; i++) {
2140 + const suspenseyImage = suspenseyImages[i];
2141 + if (!suspenseyImage.complete) {
2142 + const rect = suspenseyImage.getBoundingClientRect();
2143 + const inViewport =
2144 + rect.bottom > 0 &&
2145 + rect.right > 0 &&
2146 + rect.top < ownerWindow.innerHeight &&
2147 + rect.left < ownerWindow.innerWidth;
2148 + if (inViewport) {
2149 + imgBytes += estimateImageBytes(suspenseyImage);
2150 + if (imgBytes > estimatedBytesWithinLimit) {
2151 + // We don't think we'll be able to download all the images within
2152 + // the timeout. Give up. Rewind to only block on fonts, if any.
2153 + blockingPromises.length = blockingIndexSnapshot;
2154 + break;
2155 + }
2156 + const loadingImage = new Promise(
2157 + waitForImageToLoad.bind(suspenseyImage),
2158 + );
2159 + blockingPromises.push(loadingImage);
2160 + }
2161 + }
2162 + }
2163 + }
2164 + if (blockingPromises.length > 0) {
2165 + const blockingReady = Promise.race([
2166 + Promise.all(blockingPromises),
2167 + new Promise(resolve =>
2168 + setTimeout(resolve, SUSPENSEY_FONT_AND_IMAGE_TIMEOUT),
2169 + ),
2170 + ]).then(layoutCallback, layoutCallback);
2171 + const allReady = pendingNavigation
2172 + ? Promise.allSettled([pendingNavigation.finished, blockingReady])
2173 + : blockingReady;
2174 + return allReady.then(afterMutationCallback, afterMutationCallback);
2175 + }
2176 layoutCallback();
2177 if (pendingNavigation) {
2178 return pendingNavigation.finished.then(
@@ -5909,8 +5950,9 @@ export function preloadResource(resource: Resource): boolean {
5950 export opaque type SuspendedState = {
5951 stylesheets: null | Map<StylesheetResource, HoistableRoot>,
5952 count: number, // suspensey css and active view transitions
5912 - imgCount: number, // suspensey images
5953 + imgCount: number, // suspensey images pending to load
5954 imgBytes: number, // number of bytes we estimate needing to download
5955 + suspenseyImages: Array<HTMLImageElement>, // instances of suspensey images (whether loaded or not)
5956 waitingForImages: boolean, // false when we're no longer blocking on images
5957 unsuspend: null | (() => void),
5958 };
@@ -5921,6 +5963,7 @@ export function startSuspendingCommit(): SuspendedState {
5963 count: 0,
5964 imgCount: 0,
5965 imgBytes: 0,
5966 + suspenseyImages: [],
5967 waitingForImages: true,
5968 // We use a noop function when we begin suspending because if possible we want the
5969 // waitfor step to finish synchronously. If it doesn't we'll return a function to
@@ -5930,6 +5973,16 @@ export function startSuspendingCommit(): SuspendedState {
5973 };
5974 }
5975
5976 +function estimateImageBytes(instance: HTMLImageElement): number {
5977 + const width: number = instance.width || 100;
5978 + const height: number = instance.height || 100;
5979 + const pixelRatio: number =
5980 + typeof devicePixelRatio === 'number' ? devicePixelRatio : 1;
5981 + const pixelsToDownload = width * height * pixelRatio;
5982 + const AVERAGE_BYTE_PER_PIXEL = 0.25;
5983 + return pixelsToDownload * AVERAGE_BYTE_PER_PIXEL;
5984 +}
5985 +
5986 export function suspendInstance(
5987 state: SuspendedState,
5988 instance: Instance,
@@ -5941,8 +5994,7 @@ export function suspendInstance(
5994 }
5995 if (
5996 // $FlowFixMe[prop-missing]
5944 - typeof instance.decode === 'function' &&
5945 - typeof setTimeout === 'function'
5997 + typeof instance.decode === 'function'
5998 ) {
5999 // If this browser supports decode() API, we use it to suspend waiting on the image.
6000 // The loading should have already started at this point, so it should be enough to
@@ -5952,13 +6004,8 @@ export function suspendInstance(
6004 // specified in the props. This is best practice to know ahead of time but if it's
6005 // unspecified we'll fallback to a guess of 100x100 pixels.
6006 if (!(instance: any).complete) {
5955 - const width: number = (instance: any).width || 100;
5956 - const height: number = (instance: any).height || 100;
5957 - const pixelRatio: number =
5958 - typeof devicePixelRatio === 'number' ? devicePixelRatio : 1;
5959 - const pixelsToDownload = width * height * pixelRatio;
5960 - const AVERAGE_BYTE_PER_PIXEL = 0.25;
5961 - state.imgBytes += pixelsToDownload * AVERAGE_BYTE_PER_PIXEL;
6007 + state.imgBytes += estimateImageBytes((instance: any));
6008 + state.suspenseyImages.push((instance: any));
6009 }
6010 const ping = onUnsuspendImg.bind(state);
6011 // $FlowFixMe[prop-missing]
packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js
+2
@@ -297,6 +297,8 @@ export function revealCompletedBoundariesWithViewTransitions(
297 rect.top < window.innerHeight &&
298 rect.left < window.innerWidth;
299 if (inViewport) {
300 + // TODO: Use decode() instead of the load event here once the fix in
301 + // https://issues.chromium.org/issues/420748301 has propagated fully.
302 const loadingImage = new Promise(resolve => {
303 suspenseyImage.addEventListener('load', resolve);
304 suspenseyImage.addEventListener('error', resolve);