Skip to content

Skip layer invalidation when an iOS View only changes opacity - #59170

Open
hryhoriiK97 wants to merge 1 commit into
react:mainfrom
hryhoriiK97:ios-view-skip-opacity-invalidate
Open

hryhoriiK97 wants to merge 1 commit into
react:mainfrom
hryhoriiK97:ios-view-skip-opacity-invalidate

Conversation

@hryhoriiK97

@hryhoriiK97 hryhoriiK97 commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

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 in invalidateLayer, which runs from finalizeUpdates: whenever _needsInvalidateLayer is set.

Current behavior. In updateProps:oldProps:, an opacity change sets the new value on the layer and also requests invalidateLayer. Inside invalidateLayer, the opacity is read only in the filter stage: it is re-applied to the layer and combined with filter: opacity(...) and brightness(...). 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:...] calls updateProps: and finalizeUpdates: 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 same updateProps: and finalizeUpdates:. 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 in updateProps: as before. A change to filter itself 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 stopped mixBlendMode from 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 --check on the changed file passes.
  • RNTester built with xcodebuild in Release and run on a physical iPhone 15 Pro (iOS 26.6.1).
  • In screenshots of a list of 1000 cards after a fade, with shadow props and with boxShadow, the list area is byte-identical before and after.
  • Measurement. RNTester Playground with items in a ScrollView, each an Animated.View wrapping a Text, 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, solid backgroundColor, shadowColor/Opacity/Radius/Offset), and the same card with boxShadow instead. Timed per frame: a timer around invalidateLayer, the main-thread time applying the animated props (from entering synchronouslyUpdateViewOnUIThread: to after finalizeUpdates:), 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:
Animated (useNativeDriver: true), 1000 items before this PR change
cards: frames per 1 s fade 16 62
cards: interval between frames 83.1 ms 16.7 ms −80%
cards: main-thread time per frame 60.0 ms 3.88 ms −94%
boxShadow cards: frames per 1 s fade 14 62
boxShadow cards: interval between frames 99.6 ms 16.7 ms −83%
boxShadow cards: main-thread time per frame 66.6 ms 3.99 ms −94%

invalidateLayer accounted 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):

Reanimated 4.7.1, useAnimatedStyle opacity before this PR change
1000 cards: mount phase per frame 57.6 ms 0.67 ms −99%
1000 cards: frames per 1 s fade 16 61
1000 boxShadow cards: mount phase per frame 64.8 ms 0.76 ms −99%
1000 boxShadow cards: frames per 1 s fade 12 60
20 cards (120 Hz): mount phase per frame 2.83 ms 0.07 ms −98%
20 boxShadow cards (120 Hz): mount phase per frame 2.71 ms 0.09 ms −97%

Shorter 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-in IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS and USE_ANIMATION_BACKEND modes.

🤖 Generated with Claude Code

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>
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 18, 2026
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 18, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant