Skip to content

fix: tear down loop animations the current props no longer request - #55

Open
janicduplessis wants to merge 1 commit into
mainfrom
fix/stale-loop-animation-54
Open

fix: tear down loop animations the current props no longer request#55
janicduplessis wants to merge 1 commit into
mainfrom
fix/stale-loop-animation-54

Conversation

@janicduplessis

Copy link
Copy Markdown
Collaborator

Summary

Fixes #54.

A looping animation only ever got torn down when a property was still in animatedProperties AND its value changed — every teardown call sits behind both gates. Two shapes of props update hit neither:

  • the property leaves animate entirely, so the mask bit clears and the whole per-property block is skipped;
  • the property keeps animating but the transition stops asking for loop, so nothing changed and the block no-ops.

Either way the infinite animation keeps driving the view. On iOS it's worse than "keeps running": the loop stays in _loopAnimations, so didMoveToWindow replays it on every re-attach, and it survives a tab switch.

That's what the reporter saw as a shimmer animating a fully-loaded content card. Their write-up blames Fabric view recycling, but that part doesn't hold up — prepareForRecycle already clears _loopAnimations, and cleanup() does the same on Android. No recycling is needed to hit this: a list row that keeps the same EaseView and just swaps skeleton props for content props is the first case above.

Both platforms now filter running loops against the current props on every update, and iOS filters the saved snapshot again before replaying it on re-attach. It only ever removes a loop the current props don't ask for, so it's a no-op for a view that still declares one.

The subtle part is what value the property is left at — cancelling mid-sweep would strand a card permanently offset. The two platforms need different handling here, which is the main thing to know when reading the diff:

  • iOS sets the model layer to the animation's target before adding the loop (applyFirstMountProps:), so removing the CAAnimation already lands on the right value; only a property that left animatedProperties needs a write. Transforms are recomposed via targetTransformFromProps: rather than poked through a transform.translation.x key path, since a matrix carrying m34 perspective can't be decomposed reliably.
  • Android drives the view property directly, so cancel() strands it wherever the sweep happened to be and a write is needed either way. It's skipped only when a path below will animate the property anyway, because those read the live view value as their "from".

Colours are left alone on both: an unset colour has no identity value, and the style owns it once the mask bit clears.

Test Plan

New reproducer at example/app/issues/54 covering both cases on one screen — a shimmer band that keeps the same EaseView instance across the load, like a list row swapping skeleton for content.

Verified on an Android emulator (density 420) by measuring the band's centre across successive frames rather than eyeballing the video. At 420 dpi the 64 dp sweep is 168 px, so the correct resting positions are predictable up front: card A back at screen centre (540 px, translateX identity), card B parked at its declared target (540 + 168 = 708 px).

card A card B
before, 3 frames 420.5 → 375.5 → 663.5 416.5 → 708.0 → 659.5
after, 3 frames 540.0 → 540.0 → 540.0 708.0 → 708.0 → 708.0

Before — both bands keep sweeping over the loaded content, still moving 13 s after the press:

ease54_before.mp4

After — both stop on load and stay stopped across a tab switch out and back, which exercises the reapplyLoopAnimations path:

ease54_after.mp4

iOS is compile-verified only. EaseView.mm builds clean for arm64 and x86_64 (xcodebuild -sdk iphonesimulator), but I could not run it — CoreSimulator is wedged on this machine (simctl hangs in +[SimServiceContext sharedServiceContextForDeveloperDir:]). The iOS half is reasoned from the source, not exercised on device, so the didMoveToWindow filtering in particular is worth a look on a working simulator before merge.

A looping animation was only ever removed when a property was still in
animatedProperties AND its value changed. Props that drop a property from
`animate` entirely, or that keep animating it but stop asking for `loop`,
hit neither gate, so the infinite animation kept driving the view — and on
iOS the saved snapshot replayed it on every didMoveToWindow.

Both platforms now filter running loops against the current props on each
update, and iOS also filters the snapshot before replaying it on re-attach.

Adds example/app/issues/54 covering both cases.
@janicduplessis
janicduplessis marked this pull request as ready for review August 17, 2026 06:17
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.

Looping animation survives on a recycled/reused EaseView and never stops

1 participant