Skip to content

fix(ios): loop animations leak the view via CAAnimation delegate retain cycle#50

Merged
janicduplessis merged 1 commit into
mainfrom
@janic/fix-ios-loop-delegate-leak
Jul 2, 2026
Merged

fix(ios): loop animations leak the view via CAAnimation delegate retain cycle#50
janicduplessis merged 1 commit into
mainfrom
@janic/fix-ios-loop-delegate-leak

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

EaseViews with an infinite loop animation leaked on any release path that skips view recycling (recycle pool at capacity, recycling disabled, surface teardown). CAAnimation retains its delegate, we set animation.delegate = self, and looping animations are also kept in _loopAnimations for re-adding on window re-attach — since an infinite loop never finishes, that's a permanent view → _loopAnimations → animation → view cycle that only prepareForRecycle broke. Found in a memory audit.

Two changes:

  • Animations now use a proxy delegate that holds the view weakly and forwards animationDidStop:finished: (a proxy rather than nil-ing the delegate on the stored snapshot, so reapplyLoopAnimations' _pendingAnimationCount bookkeeping and onTransitionEnd events keep working).
  • The all-'none' transition path called removeAllAnimations without clearing _loopAnimations, so a cancelled loop came back on the next didMoveToWindow. It now clears the saved snapshots too.

Adds an example-app reproducer (Issues → "Audit — Cancelled loop resurrects") that cancels a loop via transition={{ type: 'none' }} and uses tabs to exercise the re-attach path.

Test Plan

Verified on the iOS simulator, first against a broken-baseline build (fixes reverted, dealloc logging, shouldBeRecycled = NO to bypass the recycle pool), then the fixed build:

  • Leak: unmount an EaseView with an active loop. Broken: no dealloc, ever. Fixed: dealloc fires. Non-loop EaseViews dealloc'd on both builds, so the logging itself was sound.
  • Cancelled-loop resurrect: reproducer screen — spin → Cancel loop → switch tab → back. Broken: spinner resurrects. Fixed: stays stopped.
  • Regressions: issue react navigation stops loop animations #42 screen still resumes loops after tab switches (reapplyLoopAnimations), and the Interrupt demo still reports both onTransitionEnd values through the proxy — "Finished" on completion, "Interrupted!" when a second toggle lands mid-animation (600 ms double-tap), then "Finished" again when the replacement animation completes.

… loop snapshots

CAAnimation strongly retains its delegate, and the loop animation
snapshots saved in _loopAnimations never complete, so using the view as
the animation delegate created a permanent retain cycle
(view -> _loopAnimations -> animation -> view). Views released without
going through prepareForRecycle (recycle pool at capacity, recycling
disabled, surface teardown) leaked. Animations now use a proxy delegate
that holds the view weakly and forwards animationDidStop, so completion
events and the pending-animation bookkeeping behave as before.

The all-'none' transition path also removed the layer animations without
clearing _loopAnimations, so a cancelled loop resurrected the next time
the view re-attached to a window. Clear the saved snapshots there too.

Adds an example-app reproducer under Issues that cancels a loop and
switches tabs to exercise the didMoveToWindow re-apply path.
@janicduplessis janicduplessis marked this pull request as ready for review July 2, 2026 03:50
@janicduplessis janicduplessis merged commit a73b917 into main Jul 2, 2026
5 checks passed
@janicduplessis janicduplessis deleted the @janic/fix-ios-loop-delegate-leak branch July 2, 2026 03:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant