@samitouri / QOS-React-1 / commits / c0d151ce7e

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.

Sebastian Markbåge committed Jul 2, 2025 at 16:09 UTC c0d151ce7ed9ebdcbcb7ea69c31d68b03b852d37
1 file changed +107 -1
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+107 -1
@@ -2133,6 +2133,84 @@ export function startViewTransition(
2133 });
2134 // $FlowFixMe[prop-missing]
2135 ownerDocument.__reactViewTransition = transition;
2136 +
2137 + const readyCallback = () => {
2138 + const documentElement: Element = (ownerDocument.documentElement: any);
2139 + // Loop through all View Transition Animations.
2140 + const animations = documentElement.getAnimations({subtree: true});
2141 + for (let i = 0; i < animations.length; i++) {
2142 + const animation = animations[i];
2143 + const effect: KeyframeEffect = (animation.effect: any);
2144 + // $FlowFixMe
2145 + const pseudoElement: ?string = effect.pseudoElement;
2146 + if (
2147 + pseudoElement != null &&
2148 + pseudoElement.startsWith('::view-transition')
2149 + ) {
2150 + const keyframes = effect.getKeyframes();
2151 + // Next, we're going to try to optimize this animation in case the auto-generated
2152 + // width/height keyframes are unnecessary.
2153 + let width;
2154 + let height;
2155 + let unchangedDimensions = true;
2156 + for (let j = 0; j < keyframes.length; j++) {
2157 + const keyframe = keyframes[j];
2158 + const w = keyframe.width;
2159 + if (width === undefined) {
2160 + width = w;
2161 + } else if (width !== w) {
2162 + unchangedDimensions = false;
2163 + break;
2164 + }
2165 + const h = keyframe.height;
2166 + if (height === undefined) {
2167 + height = h;
2168 + } else if (height !== h) {
2169 + unchangedDimensions = false;
2170 + break;
2171 + }
2172 + // We're clearing the keyframes in case we are going to apply the optimization.
2173 + delete keyframe.width;
2174 + delete keyframe.height;
2175 + if (keyframe.transform === 'none') {
2176 + delete keyframe.transform;
2177 + }
2178 + }
2179 + if (
2180 + unchangedDimensions &&
2181 + width !== undefined &&
2182 + height !== undefined
2183 + ) {
2184 + // Replace the keyframes with ones that don't animate the width/height.
2185 + // $FlowFixMe
2186 + effect.setKeyframes(keyframes);
2187 + // Read back the new animation to see what the underlying width/height of the pseudo-element was.
2188 + const computedStyle = getComputedStyle(
2189 + // $FlowFixMe
2190 + effect.target,
2191 + // $FlowFixMe
2192 + effect.pseudoElement,
2193 + );
2194 + if (
2195 + computedStyle.width !== width ||
2196 + computedStyle.height !== height
2197 + ) {
2198 + // Oops. Turns out that the pseudo-element had a different width/height so we need to let it
2199 + // be overridden. Add it back.
2200 + const first = keyframes[0];
2201 + first.width = width;
2202 + first.height = height;
2203 + const last = keyframes[keyframes.length - 1];
2204 + last.width = width;
2205 + last.height = height;
2206 + // $FlowFixMe
2207 + effect.setKeyframes(keyframes);
2208 + }
2209 + }
2210 + }
2211 + }
2212 + spawnedWorkCallback();
2213 + };
2214 const handleError = (error: mixed) => {
2215 try {
2216 error = customizeViewTransitionError(error, false);
@@ -2150,7 +2228,7 @@ export function startViewTransition(
2228 spawnedWorkCallback();
2229 }
2230 };
2153 - transition.ready.then(spawnedWorkCallback, handleError);
2231 + transition.ready.then(readyCallback, handleError);
2232 transition.finished.finally(() => {
2233 cancelAllViewTransitionAnimations((ownerDocument.documentElement: any));
2234 // $FlowFixMe[prop-missing]
@@ -2220,11 +2298,26 @@ function animateGesture(
2298 moveFirstFrameIntoViewport: boolean,
2299 moveAllFramesIntoViewport: boolean,
2300 ) {
2301 + let width;
2302 + let height;
2303 + let unchangedDimensions = true;
2304 for (let i = 0; i < keyframes.length; i++) {
2305 const keyframe = keyframes[i];
2306 // Delete any easing since we always apply linear easing to gestures.
2307 delete keyframe.easing;
2308 delete keyframe.computedOffset;
2309 + const w = keyframe.width;
2310 + if (width === undefined) {
2311 + width = w;
2312 + } else if (width !== w) {
2313 + unchangedDimensions = false;
2314 + }
2315 + const h = keyframe.height;
2316 + if (height === undefined) {
2317 + height = h;
2318 + } else if (height !== h) {
2319 + unchangedDimensions = false;
2320 + }
2321 // Chrome returns "auto" for width/height which is not a valid value to
2322 // animate to. Similarly, transform: "none" is actually lack of transform.
2323 if (keyframe.width === 'auto') {
@@ -2273,6 +2366,19 @@ function animateGesture(
2366 // keyframe. Otherwise it applies to every keyframe.
2367 moveOldFrameIntoViewport(keyframes[0]);
2368 }
2369 + if (unchangedDimensions && width !== undefined && height !== undefined) {
2370 + // Read the underlying width/height of the pseudo-element. The previous animation
2371 + // should have already been cancelled so we should observe the underlying element.
2372 + const computedStyle = getComputedStyle(targetElement, pseudoElement);
2373 + if (computedStyle.width === width && computedStyle.height === height) {
2374 + for (let i = 0; i < keyframes.length; i++) {
2375 + const keyframe = keyframes[i];
2376 + delete keyframe.width;
2377 + delete keyframe.height;
2378 + }
2379 + }
2380 + }
2381 +
2382 // TODO: Reverse the reverse if the original direction is reverse.
2383 const reverse = rangeStart > rangeEnd;
2384 if (timeline instanceof AnimationTimeline) {