fix(ios): re-apply layer.transform after super.invalidateLayer#41
Merged
janicduplessis merged 1 commit intoAppAndFlow:mainfrom May 5, 2026
Merged
Conversation
`-[RCTViewComponentView invalidateLayer]` re-applies the View's style
layer-properties to the underlying CALayer — including transform. Since
EaseView's JS shim strips `style.transform` whenever `animate` includes
any transform component, super resets `layer.transform = identity` on
every invalidateLayer call. The override here re-applied opacity,
cornerRadius, backgroundColor, border, and shadow — but missed transform.
For static or already-settled views this was invisible. Views in the
middle of a transform CAAnimation hit it as a hard-to-reproduce visual
bug:
1. updateProps' "subsequent updates" branch sets `layer.transform =
targetTransform` and queues a CABasicAnimation with
`fromValue=current, toValue=target, fillMode=removed`.
2. Mid-animation, a sibling re-render or layout settle triggers
`invalidateLayer` on this view. `[super invalidateLayer]` resets
`layer.transform` to identity from the empty style.transform.
3. The override re-applies other animated properties but leaves the
model layer's transform at identity.
4. The CAAnimation finishes. With `fillMode=removed`, presentation
reverts to model — which is now identity. The view snaps to its
un-transformed position/scale.
Backgrounding and reopening the app worked around the bug because
foregrounding triggers a window-wide displayIfNeeded after the
animation has long since completed; at that point the override's
re-apply branch executes with no in-flight animation and the model can
be corrected.
This was first surfaced in a hero ↔ list morphing card with FlashList +
LayoutAnimation, where mid-flight invalidateLayer calls during the
layout settle were the trigger.
The fix:
* Add `kMaskAnyTransform` to the early-return gate so transform-only
animated views still hit the re-apply block.
* Re-apply `self.layer.transform = [self targetTransformFromProps:...]`
unconditionally inside the existing `setDisableActions:YES`
CATransaction.
The re-apply is intentionally NOT gated on "no animation in flight". A
running CABasicAnimation interpolates the *presentation* layer between
its own fromValue/toValue and ignores the model layer for its lifetime.
With actions disabled, writing to the model does not start an implicit
animation. The model needs to be at target so that when the explicit
animation removes itself (fillMode=removed), presentation reverts to the
correct resting state — not identity.
Verified on RN 0.85.1 / Fabric / iOS 17 sim and iPhone (real device).
There was a problem hiding this comment.
Pull request overview
This PR fixes an iOS/Fabric rendering bug in EaseView where -[super invalidateLayer] resets CALayer.transform to identity mid-animation, causing transform-animated views to snap back to identity when the explicit CAAnimation completes.
Changes:
- Extend
invalidateLayer’s early-return mask gate to includekMaskAnyTransform. - Re-apply
self.layer.transformfrom the component’s animated props inside theCATransaction(with actions disabled). - Add detailed inline rationale explaining why the transform re-apply must occur even while an explicit animation is in flight.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
janicduplessis
approved these changes
May 5, 2026
Collaborator
janicduplessis
left a comment
There was a problem hiding this comment.
Thanks for the fix!
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
invalidateLayerre-applies opacity, cornerRadius, backgroundColor, border, and shadow after[super invalidateLayer]clobbers them with identity values from the View's (transform-stripped) style — but it missestransform. Result: any transform-animated view that receives aninvalidateLayercall mid-CAAnimation snaps back to identity the moment the animation completes.This PR adds the missing
transformre-apply ininvalidateLayer.The bug
The override at the top of
-[EaseView invalidateLayer]covers most layer properties that super resets, but transform is missing. Since the JS shim'scleanStylelogic stripsstyle.transformwheneveranimateincludes any transform component (translateX/scaleX/rotate/etc.), the View has no transform style for super to use — it resetslayer.transformto identity and the override never restores it.For views that aren't transform-animating, this is invisible. For transform-animated views, the bug surfaces only in the narrow window where:
updateProps' "subsequent updates" branch setslayer.transform = targetTransformand queues aCABasicAnimationwithfromValue=current, toValue=target, fillMode=removed.invalidateLayeron this view.[super invalidateLayer]resetslayer.transform = identity.fillMode=removed, the running animation is removed and presentation reverts to model, which is now identity. The view snaps to its un-transformed pose.If no
invalidateLayerhappens to fire during the animation, you never see the bug. If one does, you see it.A clean reproduction: hero ↔ list morphing cards inside a FlashList with
LayoutAnimation.configureNextdriving the row height transition. As the row heights compress, the layout settle triggersinvalidateLayeron cells whose vertical position is shifting — exactly mid-flight of the transform animation. After the morph completes, those specific cells (typically not the topmost ones) revert to identity transform. Backgrounding and reopening the app "fixes" the affected cells because foregrounding triggers a window-widedisplayIfNeededafter the animation has long since completed; with no animation in flight, the existing branches ininvalidateLayercorrect the model on whatever subset of properties they touch — and once a normalupdatePropscycle runs again, the transform path corrects too. (This is the user-facing tell that something in the post-animation pipeline isn't quite right.)The fix
Plus
kMaskAnyTransformadded to the early-return gate so transform-only animated views (e.g.animate={{ translateX, translateY }}without an animated borderRadius/opacity etc.) still reach the apply block.Why the re-apply must NOT be gated on
!isAnimatingI tried gating this on "no transform animation currently in flight" first, on the intuition that we shouldn't yank a running animation back to its target. That reasoning is wrong, and gating breaks the fix:
CABasicAnimationinterpolates the presentation layer using its ownfromValue/toValueand ignores the model layer for its lifetime.[CATransaction setDisableActions:YES], so the model write does NOT start an implicit animation.fillMode=removedreverts the presentation to model on completion. If we skip the re-apply during an in-flight animation (which is exactly when the bug needs the fix!), the model stays at identity and the snap-back is preserved.So the re-apply happens unconditionally. During an in-flight animation, the visual is unaffected (presentation is animation-driven). After the animation removes itself, the now-correct model takes over. No yank, no flicker.
Diff
19 insertions, 3 deletions, single file (
ios/EaseView.mm).Verification
Happy to adjust the comment wording or split into smaller commits if preferred.