Repository navigation
Skip layer invalidation when an iOS View only changes opacity - #59170
Open
hryhoriiK97 wants to merge 1 commit into
Open
hryhoriiK97 wants to merge 1 commit into
hryhoriiK97 wants to merge 1 commit into
Conversation
An opacity change set `needsInvalidateLayer`, so `finalizeUpdates:` ran the whole `invalidateLayer`: shadow path, borders, and the background, box-shadow, outline and mask layers. `invalidateLayer` reads the opacity only to combine it with `filter`, and the new opacity is already applied to the layer in `updateProps:`, so the invalidation is now requested only when the view has a filter. Native-driver and Reanimated opacity animations update every animated view on every frame, so each styled view paid the full invalidation per frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary:
RCTViewComponentView(React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm) applies a view's borders, shadow path, background colour and image layers, box shadows, outline, filters and clipping masks ininvalidateLayer, which runs fromfinalizeUpdates:whenever_needsInvalidateLayeris set.Current behavior. In
updateProps:oldProps:, an opacity change sets the new value on the layer and also requestsinvalidateLayer. InsideinvalidateLayer, the opacity is read only in the filter stage: it is re-applied to the layer and combined withfilter: opacity(...)andbrightness(...). For a view without a filter, the invalidation therefore recomputes everything else unchanged: it resolves border metrics, recreates the shadow path, re-applies the layer properties, and removes and recreates the box-shadow, outline, background-image and mask sublayers.Opacity animations make this per-frame work. With Animated's native driver,
-[RCTMountingManager synchronouslyUpdateViewOnUIThread:...]callsupdateProps:andfinalizeUpdates:on every animated view on every frame. By default, Reanimated commits the animated props to the shadow tree on every frame, which mounts through the sameupdateProps:andfinalizeUpdates:. Every styled view in a fade, a shimmer or a staggered entrance runs the full invalidation on every frame.With this change. An opacity change requests the invalidation only when the view has a
filter. The layer's opacity is still set inupdateProps:as before. A change tofilteritself already requests the invalidation in its own branch, so views with filters behave exactly as before, and so does every other prop. This follows the same reasoning as #45620, which stoppedmixBlendModefrom invalidating the layer.Changelog:
[IOS] [FIXED] - Opacity changes no longer rebuild a View's borders, shadows and background layers, which made opacity animations over many styled Views drop frames
Test Plan:
node scripts/clang-format.js --checkon the changed file passes.xcodebuildin Release and run on a physical iPhone 15 Pro (iOS 26.6.1).boxShadow, the list area is byte-identical before and after.ScrollView, each anAnimated.Viewwrapping aText, faded together from opacity 1 to 0.3 and back over one second. Three contents: plain (no border or shadow), card (borderRadius: 8,borderWidth: 1, solidbackgroundColor,shadowColor/Opacity/Radius/Offset), and the same card withboxShadowinstead. Timed per frame: a timer aroundinvalidateLayer, the main-thread time applying the animated props (from enteringsynchronouslyUpdateViewOnUIThread:to afterfinalizeUpdates:), and the interval between frames from a run-loop observer after the CoreAnimation commit. The two arms are the same binary with the new check switched by an environment variable. Each arm ran 40 fades per content over two launches, in the order before, after, after, before, so drift over the session affects both arms equally (about 11,000 frames at 1000 items). The presses were driven by Argent on the physical iPhone, with no accessibility polling during the fades. iPhone 15 Pro, Release, medians over all frames:useNativeDriver: true), 1000 itemsboxShadowcards: frames per 1 s fadeboxShadowcards: interval between framesboxShadowcards: main-thread time per frameinvalidateLayeraccounted for 55.2 ms (cards) and 61.8 ms (boxShadow) of each frame before, and 0 after. Before the change, over 92% of frame intervals exceeded 25 ms for both card styles; after it, none did. Plain views already ran at 60 fps and change little (4.21 → 3.80 ms per frame).With 20 items, closer to a typical skeleton or shimmer, both arms hold 60 fps and the change frees main-thread time in every frame (20 fades per content): 4.81 → 0.42 ms for cards (−91%), 5.08 → 0.68 ms for
boxShadow(−87%).Reanimated. The same comparison in a separate app on the latest releases: React Native 0.88.0 built from source (its opacity code in this file is identical), Reanimated 4.7.1 and react-native-worklets 0.13.0, in Reanimated's default mode, with the same protocol. Timed from the mount phase of each frame's transaction (
TransactionTelemetry):useAnimatedStyleopacityboxShadowcards: mount phase per frameboxShadowcards: frames per 1 s fadeboxShadowcards (120 Hz): mount phase per frameShorter runs with Reanimated 4.3.0 on React Native 0.84.1 gave the same result: 56 → 0.65 ms per frame for cards and 62 → 0.74 ms for
boxShadow. Not exercised: Reanimated's opt-inIOS_SYNCHRONOUSLY_UPDATE_UI_PROPSandUSE_ANIMATION_BACKENDmodes.🤖 Generated with Claude Code