Sitelet https://github.com/lottie-react-native/lottie-react-native/pull/1399
Skip to content

[iOS][Fabric] Fix autoPlay, onAnimationLoaded and view-recycling bugs in New Architecture - #1399

Open
wanderbon wants to merge 4 commits into
lottie-react-native:masterfrom
wanderbon:master
Open

wanderbon wants to merge 4 commits into
lottie-react-native:masterfrom
wanderbon:master

Conversation

@wanderbon

Copy link
Copy Markdown

Summary

This PR fixes five interconnected bugs affecting LottieView on iOS with the New Architecture (Fabric) when using autoPlay={false} and/or loop={false}. The bugs cause animations to start automatically despite autoPlay={false}, prevent onAnimationLoaded from firing, and break subsequent visits to screens containing a LottieView.


Bug 1 — setSpeed unconditionally starts playback regardless of autoPlay

File: ios/LottieReactNative/ContainerView.swift

Root cause

setSpeed called animationView?.play() whenever speed was non-zero and the animation was not already playing, ignoring the autoPlay flag entirely:

// Before
if newSpeed != 0.0 {
    animationView?.animationSpeed = newSpeed
    if !(animationView?.isAnimationPlaying ?? true) {
        animationView?.play()   // fires unconditionally
    }
}

On every mount, LottieView's JS defaultProps.speed = 1 causes setSpeed(1) to be called. This started the animation immediately, even with autoPlay={false}.

Fix

Remove the play() call from setSpeed. Playback responsibility belongs exclusively to setAutoPlay and replaceAnimationView → playIfNeeded().

// After
if newSpeed != 0.0 {
    animationView?.animationSpeed = newSpeed
} else if animationView?.isAnimationPlaying ?? false {
    animationView?.pause()
}

Bug 2 — Fabric view recycling: autoPlay not yet updated when setSpeed fires

File: ios/Fabric/LottieAnimationViewComponentView.mm

Root cause

In updateProps:oldProps:, setSpeed was called on line 64, while setAutoPlay was called on line 96. When Fabric recycles a native view previously used by a component with autoPlay=true (e.g. an animation that played once), the recycled ContainerView._autoPlay is still true at the moment setSpeed is called with the new component's speed.
Even with Bug 1 fixed, any future code in setSpeed that checks autoPlay would see the stale value.
Additionally, loop had the same ordering problem.

Fix

Move setAutoPlay and setLoop to the top of updateProps:oldProps:, before setSpeed and all source setters:

// Now called first — before setSpeed and source setters
if (oldLottieProps.autoPlay != newLottieProps.autoPlay) {
    [_view setAutoPlay:newLottieProps.autoPlay];
}
if (oldLottieProps.loop != newLottieProps.loop) {
    [_view setLoop:newLottieProps.loop];
}
// ... resizeMode, progress, speed, source, etc.

Bug 3 — setSourceDotLottieURI loses the loader view via ARC

File: ios/LottieReactNative/ContainerView.swift

Root cause

// Before
_ = LottieAnimationView(
    dotLottieUrl: url,
    configuration: lottieConfiguration,
    completion: { ... }
)

Assigning the temporary LottieAnimationView to _ discards the only strong reference. ARC can deallocate the view before the async load completes, silently dropping the completion callback. As a result, replaceAnimationView is never called and onAnimationLoaded never fires.

Fix

Retain the loader in a stored property and release it inside the completion:

let loader = LottieAnimationView(
    dotLottieUrl: url,
    configuration: lottieConfiguration,
    completion: { [weak self] view, error in
        guard let self = self else { return }
        self.loadingAnimationView = nil  // release
        // ...
        self.replaceAnimationView(next: view)
    }
)
loadingAnimationView = loader

Bug 4 — animationLoaded closure assigned after the event already fired

File: ios/LottieReactNative/ContainerView.swift

Root cause

In replaceAnimationView, animationView.animationLoaded was assigned after addSubview. For animations loaded via dotLottieUrl, the LottieAnimationView passed to the completion callback already has the composition loaded. Lottie's animationLoaded property uses a Swift didSet observer that immediately calls the closure if animation != nil. By assigning after addSubview, this immediate call was missed.

// Before (animationLoaded set last — too late for already-loaded views)
addSubview(next)
applyContentMode()
applyColorProperties()
playIfNeeded()
animationView?.animationLoaded = { ... }  // ← didSet fires immediately but nobody is listening yet

Fix

Assign animationLoaded first, before any other setup:

// After
animationView?.animationLoaded = { [weak self] _, _ in
    guard let self = self else { return }
    self.loadedCallback()
}
// then: backgroundBehavior, animationSpeed, addSubview, playIfNeeded...

Bug 5 — onAnimationLoaded dropped when Fabric attaches event emitter after load completes (race condition + view pool recycling)

Files: ios/Fabric/LottieAnimationViewComponentView.mm, ios/Fabric/LottieContainerView.h, ios/LottieReactNative/ContainerView.swift

Root cause — race condition

In Fabric, updateProps:oldProps: is called before updateEventEmitter:. For local file:// .lottie assets, the async load in setSourceDotLottieURI completes very quickly (synchronously in some cases), meaning onAnimationLoaded fires while _eventEmitter is still nil. The existing guard if (!_eventEmitter) return; silently drops the event.

Root cause — view pool recycling

When a user navigates away from a screen (unmounting it) and then navigates back, Fabric recycles native views. The recycled LottieContainerView already has the animation loaded from the previous mount. Since sourceDotLottieURI hasn't changed, Fabric's prop diffing skips setSourceDotLottieURI, replaceAnimationView is never called, and onAnimationLoaded never fires for the new component instance.

Fix

Track whether the animation has been loaded in a new isAnimationLoaded: Bool property on ContainerView. Override updateEventEmitter: in the Fabric component view to emit onAnimationLoaded when _view.isAnimationLoaded is true. This method is called on mount (including recycled views) but not on re-renders, making it the correct place for this check.

// ContainerView.swift
@objc private(set) var isAnimationLoaded: Bool = false

// Set to true in loadedCallback(), reset to false in removeCurrentAnimationView()
// LottieAnimationViewComponentView.mm
- (void)updateEventEmitter:(const EventEmitter::Shared &)eventEmitter {
    [super updateEventEmitter:eventEmitter];
    // Covers both the race condition (load before emitter) and view pool recycling
    // (same source → setSourceDotLottieURI skipped → onAnimationLoaded never called)
    if (_view.isAnimationLoaded) {
        [self onAnimationLoaded];
    }
}
// LottieContainerView.h
@property (nonatomic, readonly) BOOL isAnimationLoaded;

Affected platforms

iOS only (New Architecture / Fabric). Android and Old Architecture are not affected.

Test scenario

  1. Screen A contains a LottieView with autoPlay={true}.
  2. Navigating from Screen A to Screen B, which contains a LottieView with autoPlay={false} and loop={false}.
  3. Screen B animation must not auto-start on arrival.
  4. Screen B animation must fire onAnimationLoaded.
  5. Navigating away from Screen B and back must still fire onAnimationLoaded on return.

@benjaminharringtonrose

Copy link
Copy Markdown

removeCurrentAnimationView() resets isAnimationLoaded and clears animationView, but does not nil out loadingAnimationView. If a dotLottie URL is loading asynchronously and a different source type (e.g., via setSourceURL or setSourceJSON) replaces the animation, the stale dotLottie completion handler will still fire, call replaceAnimationView(next:), and overwrite the correct animation with the old one. loadingAnimationView needs to be cleared in removeCurrentAnimationView() so ARC can deallocate the in-flight loader and prevent its completion from executing - does that sound right @wanderbon ?

@wanderbon

Copy link
Copy Markdown
Author

removeCurrentAnimationView() resets isAnimationLoaded and clears animationView, but does not nil out loadingAnimationView. If a dotLottie URL is loading asynchronously and a different source type (e.g., via setSourceURL or setSourceJSON) replaces the animation, the stale dotLottie completion handler will still fire, call replaceAnimationView(next:), and overwrite the correct animation with the old one. loadingAnimationView needs to be cleared in removeCurrentAnimationView() so ARC can deallocate the in-flight loader and prevent its completion from executing - does that sound right @wanderbon ?

Yes, thanks, that makes sense. Without clearing loadingAnimationView, an in-flight async load can still complete and override the currently set animation. Resetting it in removeCurrentAnimationView() would prevent stale completions from mutating state and let ARC clean up the loader. I’ve made the changes accordingly.

@adids1221

Copy link
Copy Markdown

Hey, just wanted to add that we're hitting the bug this PR fixes in production.
Lottie-based loader invisible on iOS cold start with New Arch.
would love to see this merged.
please update if there is any progress with this pr.

tanjoash added a commit to tanjoash/lottie-react-native that referenced this pull request Apr 30, 2026
@s-ol

s-ol commented May 15, 2026

Copy link
Copy Markdown

We're also blocked on a release here.

We need to update lottie to work with recent expo versions, which is mandatory to migrate to 16kb page sizes on android. But upgrading lottie breaks our app on iOS.

@s-ol

s-ol commented May 20, 2026

Copy link
Copy Markdown

@matinzd have you had a chance to take a look at this?
We're considering doing a build with this branch to verify if that solves our use-case.

@nicobermudez

Copy link
Copy Markdown

any update on this?

@stale

stale Bot commented Jul 25, 2026

Copy link
Copy Markdown

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

@stale stale Bot added the stale There has been a lack of activity on this issue and it may be closed soon. label Jul 25, 2026
@s-ol

s-ol commented Jul 25, 2026 •

Copy link
Copy Markdown

not an issue, not stale

@stale stale Bot removed the stale There has been a lack of activity on this issue and it may be closed soon. label Jul 25, 2026
@stale

stale Bot commented Sep 25, 2026

Copy link
Copy Markdown

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

@stale stale Bot added the stale There has been a lack of activity on this issue and it may be closed soon. label Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale There has been a lack of activity on this issue and it may be closed soon.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants