From 205d9f25bb2247cf270cc56924e5e8df2702e347 Mon Sep 17 00:00:00 2001 From: Muhammed Besirovic Date: Tue, 5 May 2026 22:38:43 +0200 Subject: [PATCH] fix(ios): re-apply layer.transform after super.invalidateLayer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `-[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). --- ios/EaseView.mm | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/ios/EaseView.mm b/ios/EaseView.mm index 6385fb9..f1070f1 100644 --- a/ios/EaseView.mm +++ b/ios/EaseView.mm @@ -1162,20 +1162,36 @@ - (void)didMoveToWindow { - (void)invalidateLayer { [super invalidateLayer]; - // super resets layer.opacity, layer.cornerRadius, and layer.backgroundColor - // from style props. Re-apply our animated values. + // super resets layer.opacity, layer.cornerRadius, layer.backgroundColor, + // AND layer.transform from style props. Re-apply our animated values. + // + // The transform re-apply is intentionally unconditional (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. We're already inside + // setDisableActions:YES so the model write does NOT start an implicit + // animation. The model layer needs to hold the target value at animation + // completion time so that when the explicit animation removes itself + // (fillMode=removed), the presentation reverts to the correct resting + // state instead of identity. const auto &viewProps = *std::static_pointer_cast(_props); int mask = viewProps.animatedProperties; if (!(mask & (kMaskOpacity | kMaskBorderRadius | kMaskBackgroundColor | kMaskBorderWidth | kMaskBorderColor | kMaskShadowOpacity | - kMaskShadowRadius | kMaskShadowColor | kMaskShadowOffset))) { + kMaskShadowRadius | kMaskShadowColor | kMaskShadowOffset | + kMaskAnyTransform))) { return; } + BOOL hasTransform = (mask & kMaskAnyTransform) != 0; + [CATransaction begin]; [CATransaction setDisableActions:YES]; + if (hasTransform) { + self.layer.transform = [self targetTransformFromProps:viewProps]; + } if (mask & kMaskOpacity) { [self.layer removeAnimationForKey:@"opacity"]; self.layer.opacity = viewProps.animateOpacity;