From d15f3aefc8a3b5dc7a88437a19bd31e500ccac56 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:40:55 +0000 Subject: [PATCH 1/4] Initial plan From c3dabec6edfb0889ee5fab1cf5f55338f4355be2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:43:51 +0000 Subject: [PATCH 2/4] Add tests for decreasing fillValue and animation state handling Co-authored-by: jkhusanov <25942541+jkhusanov@users.noreply.github.com> --- src/__tests__/SegmentedArc.spec.js | 71 ++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/__tests__/SegmentedArc.spec.js b/src/__tests__/SegmentedArc.spec.js index f5d517f..ea4cf9d 100644 --- a/src/__tests__/SegmentedArc.spec.js +++ b/src/__tests__/SegmentedArc.spec.js @@ -323,6 +323,77 @@ describe('SegmentedArc', () => { expect(secondCallToValue).toBeGreaterThan(firstCallToValue); }); + it('re-runs animation when fillValue decreases dynamically', () => { + Animated.timing.mockReturnValue({ start: jest.fn(cb => cb && cb()) }); + + wrapper = render(); + expect(Animated.timing).toHaveBeenCalledTimes(1); + + const firstCallToValue = Animated.timing.mock.calls[0][1].toValue; + + Animated.timing.mockClear(); + Animated.timing.mockReturnValue({ start: jest.fn(cb => cb && cb()) }); + + wrapper.rerender(); + expect(Animated.timing).toHaveBeenCalledTimes(1); + + const secondCallToValue = Animated.timing.mock.calls[0][1].toValue; + expect(secondCallToValue).not.toBe(firstCallToValue); + expect(secondCallToValue).toBeLessThan(firstCallToValue); + }); + + it('does not trigger new animation when fillValue changes while previous animation is running', () => { + let animationCallback; + Animated.timing.mockReturnValue({ + start: jest.fn(cb => { + animationCallback = cb; + }) + }); + + wrapper = render(); + expect(Animated.timing).toHaveBeenCalledTimes(1); + + Animated.timing.mockClear(); + + // Rerender with new fillValue before first animation completes + wrapper.rerender(); + + // Animation should not be triggered because previous one is still running + expect(Animated.timing).not.toHaveBeenCalled(); + }); + + it('triggers animation with latest fillValue after previous animation completes', () => { + let animationCallback; + Animated.timing.mockReturnValue({ + start: jest.fn(cb => { + animationCallback = cb; + }) + }); + + wrapper = render(); + expect(Animated.timing).toHaveBeenCalledTimes(1); + + const firstCallToValue = Animated.timing.mock.calls[0][1].toValue; + + Animated.timing.mockClear(); + + // Complete the first animation + if (animationCallback) animationCallback(); + + Animated.timing.mockReturnValue({ + start: jest.fn(cb => { + animationCallback = cb; + }) + }); + + // Now change fillValue after animation completes - this should trigger new animation + wrapper.rerender(); + expect(Animated.timing).toHaveBeenCalledTimes(1); + + const secondCallToValue = Animated.timing.mock.calls[0][1].toValue; + expect(secondCallToValue).toBeGreaterThan(firstCallToValue); + }); + it('sets the last segment for lastFilledSegment prop when fillValue is equal or greater than 100%', () => { props.fillValue = 100; wrapper = getWrapper(props); From b7af65ce314e767414da2551294ec808fca93bd6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 20:45:18 +0000 Subject: [PATCH 3/4] Refine animation state tests based on code review feedback Co-authored-by: jkhusanov <25942541+jkhusanov@users.noreply.github.com> --- src/__tests__/SegmentedArc.spec.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/__tests__/SegmentedArc.spec.js b/src/__tests__/SegmentedArc.spec.js index ea4cf9d..c7acde5 100644 --- a/src/__tests__/SegmentedArc.spec.js +++ b/src/__tests__/SegmentedArc.spec.js @@ -362,7 +362,7 @@ describe('SegmentedArc', () => { expect(Animated.timing).not.toHaveBeenCalled(); }); - it('triggers animation with latest fillValue after previous animation completes', () => { + it('triggers animation after previous animation completes', () => { let animationCallback; Animated.timing.mockReturnValue({ start: jest.fn(cb => { @@ -375,11 +375,10 @@ describe('SegmentedArc', () => { const firstCallToValue = Animated.timing.mock.calls[0][1].toValue; - Animated.timing.mockClear(); - // Complete the first animation if (animationCallback) animationCallback(); + Animated.timing.mockClear(); Animated.timing.mockReturnValue({ start: jest.fn(cb => { animationCallback = cb; From 0db82f053bacae13e139726785c7a0694e585dc1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:18:33 +0000 Subject: [PATCH 4/4] Sync with animation-fix base branch and add decreasing fillValue test Co-authored-by: jkhusanov <25942541+jkhusanov@users.noreply.github.com> --- src/SegmentedArc.js | 33 ++++++++++--- src/__tests__/SegmentedArc.spec.js | 78 ++++++++++++++---------------- 2 files changed, 61 insertions(+), 50 deletions(-) diff --git a/src/SegmentedArc.js b/src/SegmentedArc.js index a7e96f1..c9d310d 100644 --- a/src/SegmentedArc.js +++ b/src/SegmentedArc.js @@ -49,7 +49,7 @@ export const SegmentedArc = ({ } const [arcAnimatedValue] = useState(new Animated.Value(0)); - const animationRunning = useRef(false); + const currentAnimation = useRef(null); useShowSegmentedArcWarnings({ segments: segmentsProps }); const { dataErrors, segments, fillValue, filledArcWidth, emptyArcWidth, spaceBetweenSegments, arcDegree, radius } = @@ -138,19 +138,38 @@ export const SegmentedArc = ({ useEffect(() => { if (!lastFilledSegment) return; - if (animationRunning.current) return; if (!isAnimated) return; - animationRunning.current = true; - Animated.timing(arcAnimatedValue, { + + // Cancel any in-flight animation to prevent dropped updates + if (currentAnimation.current) { + currentAnimation.current.stop(); + } + + const animation = Animated.timing(arcAnimatedValue, { toValue: lastFilledSegment.filled, duration: animationDuration, delay: animationDelay, useNativeDriver: false, easing: Easing.out(Easing.ease) - }).start(() => { - animationRunning.current = false; }); - }, [lastFilledSegment.filled]); + + currentAnimation.current = animation; + + animation.start(({ finished }) => { + // Only clear if this is still the current animation AND it finished successfully (not stopped) + if (currentAnimation.current === animation && finished) { + currentAnimation.current = null; + } + }); + + // Cleanup: stop this specific animation if it's still running + return () => { + if (currentAnimation.current === animation) { + animation.stop(); + currentAnimation.current = null; + } + }; + }, [lastFilledSegment.filled, animationDuration, animationDelay, isAnimated]); if (arcs.length === 0) { return null; diff --git a/src/__tests__/SegmentedArc.spec.js b/src/__tests__/SegmentedArc.spec.js index c7acde5..da0c47b 100644 --- a/src/__tests__/SegmentedArc.spec.js +++ b/src/__tests__/SegmentedArc.spec.js @@ -42,11 +42,16 @@ describe('SegmentedArc', () => { return render(); }; + const createCompletedAnimationMock = () => ({ + start: jest.fn(cb => cb && cb({ finished: true })), + stop: jest.fn() + }); + beforeEach(() => { Animated.timing = jest.fn(); Easing.out = jest.fn(); Easing.ease = jest.fn(); - Animated.timing.mockReturnValue({ start: jest.fn() }); + Animated.timing.mockReturnValue({ start: jest.fn(), stop: jest.fn() }); jest.spyOn(console, 'warn').mockImplementation(); props = { segments, fillValue: 50 }; }); @@ -305,7 +310,7 @@ describe('SegmentedArc', () => { }); it('re-runs animation when fillValue changes dynamically', () => { - Animated.timing.mockReturnValue({ start: jest.fn(cb => cb && cb()) }); + Animated.timing.mockReturnValue(createCompletedAnimationMock()); wrapper = render(); expect(Animated.timing).toHaveBeenCalledTimes(1); @@ -313,7 +318,7 @@ describe('SegmentedArc', () => { const firstCallToValue = Animated.timing.mock.calls[0][1].toValue; Animated.timing.mockClear(); - Animated.timing.mockReturnValue({ start: jest.fn(cb => cb && cb()) }); + Animated.timing.mockReturnValue(createCompletedAnimationMock()); wrapper.rerender(); expect(Animated.timing).toHaveBeenCalledTimes(1); @@ -324,7 +329,7 @@ describe('SegmentedArc', () => { }); it('re-runs animation when fillValue decreases dynamically', () => { - Animated.timing.mockReturnValue({ start: jest.fn(cb => cb && cb()) }); + Animated.timing.mockReturnValue(createCompletedAnimationMock()); wrapper = render(); expect(Animated.timing).toHaveBeenCalledTimes(1); @@ -332,7 +337,7 @@ describe('SegmentedArc', () => { const firstCallToValue = Animated.timing.mock.calls[0][1].toValue; Animated.timing.mockClear(); - Animated.timing.mockReturnValue({ start: jest.fn(cb => cb && cb()) }); + Animated.timing.mockReturnValue(createCompletedAnimationMock()); wrapper.rerender(); expect(Animated.timing).toHaveBeenCalledTimes(1); @@ -342,55 +347,42 @@ describe('SegmentedArc', () => { expect(secondCallToValue).toBeLessThan(firstCallToValue); }); - it('does not trigger new animation when fillValue changes while previous animation is running', () => { - let animationCallback; - Animated.timing.mockReturnValue({ - start: jest.fn(cb => { - animationCallback = cb; - }) + it('cancels in-flight animation when fillValue changes before animation completes', () => { + let firstAnimationCallback; + const mockStop = jest.fn(); + const mockStart = jest.fn(cb => { + firstAnimationCallback = cb; }); + Animated.timing.mockReturnValue({ start: mockStart, stop: mockStop }); wrapper = render(); expect(Animated.timing).toHaveBeenCalledTimes(1); + expect(mockStart).toHaveBeenCalledTimes(1); + // Simulate fillValue changing before animation completes Animated.timing.mockClear(); + const newMockStop = jest.fn(); + const newMockStart = jest.fn(); + Animated.timing.mockReturnValue({ start: newMockStart, stop: newMockStop }); - // Rerender with new fillValue before first animation completes - wrapper.rerender(); - - // Animation should not be triggered because previous one is still running - expect(Animated.timing).not.toHaveBeenCalled(); - }); - - it('triggers animation after previous animation completes', () => { - let animationCallback; - Animated.timing.mockReturnValue({ - start: jest.fn(cb => { - animationCallback = cb; - }) - }); + wrapper.rerender(); - wrapper = render(); + // Verify that the previous animation was stopped + expect(mockStop).toHaveBeenCalled(); + // Verify that a new animation was started expect(Animated.timing).toHaveBeenCalledTimes(1); + expect(newMockStart).toHaveBeenCalledTimes(1); - const firstCallToValue = Animated.timing.mock.calls[0][1].toValue; - - // Complete the first animation - if (animationCallback) animationCallback(); - - Animated.timing.mockClear(); - Animated.timing.mockReturnValue({ - start: jest.fn(cb => { - animationCallback = cb; - }) - }); - - // Now change fillValue after animation completes - this should trigger new animation - wrapper.rerender(); - expect(Animated.timing).toHaveBeenCalledTimes(1); + // Simulate the stopped animation's callback executing with finished: false + // This should NOT clear currentAnimation (because finished is false) + if (firstAnimationCallback) { + firstAnimationCallback({ finished: false }); + } - const secondCallToValue = Animated.timing.mock.calls[0][1].toValue; - expect(secondCallToValue).toBeGreaterThan(firstCallToValue); + // Verify that the new animation is still active by unmounting and checking + // that its stop method is called + wrapper.unmount(); + expect(newMockStop).toHaveBeenCalled(); }); it('sets the last segment for lastFilledSegment prop when fillValue is equal or greater than 100%', () => {