Clear width/height from Keyframes to Optimize View Transitions (#33576)

View Transitions has this annoying quirk where it adds `width` and
`height` to keyframes automatically when generating keyframes even when
it's not needed. This causes them to deopt from running on the
compositor thread in both Chrome and Safari. @bramus has a [good article
on
it](https://www.bram.us/2025/02/07/view-transitions-applied-more-performant-view-transition-group-animations/).

In React we can automatically rewrite the keyframes when we're starting
a View Transition to drop the `width` and `height` from the keyframes
when they have the same value and the same value as the pseudo element.

To compare it against the pseudo element we first apply the new
keyframes without the width/height and then read it back to see if it
has changed. For gestures, we have already cancelled the previous
animation so we can just read out from that.
This commit is contained in:
Sebastian Markbåge
2025-07-02 16:09:26 -04:00
committed by GitHub
parent fc41c24aa6
commit c0d151ce7e
+107 -1
View File
@@ -2133,6 +2133,84 @@ export function startViewTransition(
});
// $FlowFixMe[prop-missing]
ownerDocument.__reactViewTransition = transition;
const readyCallback = () => {
const documentElement: Element = (ownerDocument.documentElement: any);
// Loop through all View Transition Animations.
const animations = documentElement.getAnimations({subtree: true});
for (let i = 0; i < animations.length; i++) {
const animation = animations[i];
const effect: KeyframeEffect = (animation.effect: any);
// $FlowFixMe
const pseudoElement: ?string = effect.pseudoElement;
if (
pseudoElement != null &&
pseudoElement.startsWith('::view-transition')
) {
const keyframes = effect.getKeyframes();
// Next, we're going to try to optimize this animation in case the auto-generated
// width/height keyframes are unnecessary.
let width;
let height;
let unchangedDimensions = true;
for (let j = 0; j < keyframes.length; j++) {
const keyframe = keyframes[j];
const w = keyframe.width;
if (width === undefined) {
width = w;
} else if (width !== w) {
unchangedDimensions = false;
break;
}
const h = keyframe.height;
if (height === undefined) {
height = h;
} else if (height !== h) {
unchangedDimensions = false;
break;
}
// We're clearing the keyframes in case we are going to apply the optimization.
delete keyframe.width;
delete keyframe.height;
if (keyframe.transform === 'none') {
delete keyframe.transform;
}
}
if (
unchangedDimensions &&
width !== undefined &&
height !== undefined
) {
// Replace the keyframes with ones that don't animate the width/height.
// $FlowFixMe
effect.setKeyframes(keyframes);
// Read back the new animation to see what the underlying width/height of the pseudo-element was.
const computedStyle = getComputedStyle(
// $FlowFixMe
effect.target,
// $FlowFixMe
effect.pseudoElement,
);
if (
computedStyle.width !== width ||
computedStyle.height !== height
) {
// Oops. Turns out that the pseudo-element had a different width/height so we need to let it
// be overridden. Add it back.
const first = keyframes[0];
first.width = width;
first.height = height;
const last = keyframes[keyframes.length - 1];
last.width = width;
last.height = height;
// $FlowFixMe
effect.setKeyframes(keyframes);
}
}
}
}
spawnedWorkCallback();
};
const handleError = (error: mixed) => {
try {
error = customizeViewTransitionError(error, false);
@@ -2150,7 +2228,7 @@ export function startViewTransition(
spawnedWorkCallback();
}
};
transition.ready.then(spawnedWorkCallback, handleError);
transition.ready.then(readyCallback, handleError);
transition.finished.finally(() => {
cancelAllViewTransitionAnimations((ownerDocument.documentElement: any));
// $FlowFixMe[prop-missing]
@@ -2220,11 +2298,26 @@ function animateGesture(
moveFirstFrameIntoViewport: boolean,
moveAllFramesIntoViewport: boolean,
) {
let width;
let height;
let unchangedDimensions = true;
for (let i = 0; i < keyframes.length; i++) {
const keyframe = keyframes[i];
// Delete any easing since we always apply linear easing to gestures.
delete keyframe.easing;
delete keyframe.computedOffset;
const w = keyframe.width;
if (width === undefined) {
width = w;
} else if (width !== w) {
unchangedDimensions = false;
}
const h = keyframe.height;
if (height === undefined) {
height = h;
} else if (height !== h) {
unchangedDimensions = false;
}
// Chrome returns "auto" for width/height which is not a valid value to
// animate to. Similarly, transform: "none" is actually lack of transform.
if (keyframe.width === 'auto') {
@@ -2273,6 +2366,19 @@ function animateGesture(
// keyframe. Otherwise it applies to every keyframe.
moveOldFrameIntoViewport(keyframes[0]);
}
if (unchangedDimensions && width !== undefined && height !== undefined) {
// Read the underlying width/height of the pseudo-element. The previous animation
// should have already been cancelled so we should observe the underlying element.
const computedStyle = getComputedStyle(targetElement, pseudoElement);
if (computedStyle.width === width && computedStyle.height === height) {
for (let i = 0; i < keyframes.length; i++) {
const keyframe = keyframes[i];
delete keyframe.width;
delete keyframe.height;
}
}
}
// TODO: Reverse the reverse if the original direction is reverse.
const reverse = rangeStart > rangeEnd;
if (timeline instanceof AnimationTimeline) {