Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 348a4e2

Browse files
authored
[Fiber] Wait for suspensey image in the viewport before starting an animation (facebook#34500)
Stacked on facebook#34486. If we gave up on loading suspensey images for blocking the commit (e.g. due to facebook#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).
1 parent 5d49b2b commit 348a4e2

File tree

2 files changed

+71
-22
lines changed

2 files changed

+71
-22
lines changed

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

Lines changed: 69 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2010,7 +2010,8 @@ function cancelAllViewTransitionAnimations(scope: Element) {
20102010
// an issue when it's a new load and slow, yet long enough that you have a chance to load
20112011
// it. Otherwise we wait for no reason. The assumption here is that you likely have
20122012
// 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;
20142015

20152016
function customizeViewTransitionError(
20162017
error: Object,
@@ -2080,6 +2081,13 @@ function forceLayout(ownerDocument: Document) {
20802081
return (ownerDocument.documentElement: any).clientHeight;
20812082
}
20822083

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+
20832091
export function startViewTransition(
20842092
suspendedState: null | SuspendedState,
20852093
rootContainer: Container,
@@ -2108,6 +2116,7 @@ export function startViewTransition(
21082116
// $FlowFixMe[prop-missing]
21092117
const previousFontLoadingStatus = ownerDocument.fonts.status;
21102118
mutationCallback();
2119+
const blockingPromises: Array<Promise<any>> = [];
21112120
if (previousFontLoadingStatus === 'loaded') {
21122121
// Force layout calculation to trigger font loading.
21132122
forceLayout(ownerDocument);
@@ -2119,19 +2128,51 @@ export function startViewTransition(
21192128
// This avoids waiting for potentially unrelated fonts that were already loading before.
21202129
// Either in an earlier transition or as part of a sync optimistic state. This doesn't
21212130
// 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);
21332132
}
21342133
}
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+
}
21352176
layoutCallback();
21362177
if (pendingNavigation) {
21372178
return pendingNavigation.finished.then(
@@ -5909,8 +5950,9 @@ export function preloadResource(resource: Resource): boolean {
59095950
export opaque type SuspendedState = {
59105951
stylesheets: null | Map<StylesheetResource, HoistableRoot>,
59115952
count: number, // suspensey css and active view transitions
5912-
imgCount: number, // suspensey images
5953+
imgCount: number, // suspensey images pending to load
59135954
imgBytes: number, // number of bytes we estimate needing to download
5955+
suspenseyImages: Array<HTMLImageElement>, // instances of suspensey images (whether loaded or not)
59145956
waitingForImages: boolean, // false when we're no longer blocking on images
59155957
unsuspend: null | (() => void),
59165958
};
@@ -5921,6 +5963,7 @@ export function startSuspendingCommit(): SuspendedState {
59215963
count: 0,
59225964
imgCount: 0,
59235965
imgBytes: 0,
5966+
suspenseyImages: [],
59245967
waitingForImages: true,
59255968
// We use a noop function when we begin suspending because if possible we want the
59265969
// waitfor step to finish synchronously. If it doesn't we'll return a function to
@@ -5930,6 +5973,16 @@ export function startSuspendingCommit(): SuspendedState {
59305973
};
59315974
}
59325975

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+
59335986
export function suspendInstance(
59345987
state: SuspendedState,
59355988
instance: Instance,
@@ -5941,8 +5994,7 @@ export function suspendInstance(
59415994
}
59425995
if (
59435996
// $FlowFixMe[prop-missing]
5944-
typeof instance.decode === 'function' &&
5945-
typeof setTimeout === 'function'
5997+
typeof instance.decode === 'function'
59465998
) {
59475999
// If this browser supports decode() API, we use it to suspend waiting on the image.
59486000
// The loading should have already started at this point, so it should be enough to
@@ -5952,13 +6004,8 @@ export function suspendInstance(
59526004
// specified in the props. This is best practice to know ahead of time but if it's
59536005
// unspecified we'll fallback to a guess of 100x100 pixels.
59546006
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));
59626009
}
59636010
const ping = onUnsuspendImg.bind(state);
59646011
// $FlowFixMe[prop-missing]

packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,8 @@ export function revealCompletedBoundariesWithViewTransitions(
297297
rect.top < window.innerHeight &&
298298
rect.left < window.innerWidth;
299299
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.
300302
const loadingImage = new Promise(resolve => {
301303
suspenseyImage.addEventListener('load', resolve);
302304
suspenseyImage.addEventListener('error', resolve);

0 commit comments

Comments
 (0)