From 2acdb0281cb2d7d36ba045bac01e64ee5ff6a461 Mon Sep 17 00:00:00 2001 From: Justin Wyne <1986068+wyne@users.noreply.github.com> Date: Mon, 29 Jun 2026 01:36:21 -0400 Subject: [PATCH] Fix swipe hold timer lifecycle --- .../Interactions/Swipe/Swipe.test.tsx | 52 +++++++++++++++++-- src/components/Interactions/Swipe/Swipe.tsx | 34 +++++++----- 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/src/components/Interactions/Swipe/Swipe.test.tsx b/src/components/Interactions/Swipe/Swipe.test.tsx index a343189a..5ee71c3e 100644 --- a/src/components/Interactions/Swipe/Swipe.test.tsx +++ b/src/components/Interactions/Swipe/Swipe.test.tsx @@ -7,7 +7,7 @@ import { Provider } from 'react-redux'; import gamesReducer, { roundNext } from '../../../../redux/GamesSlice'; import playersReducer, { playerRoundScoreIncrement } from '../../../../redux/PlayersSlice'; -import settingsReducer, { initialState as settingsInitialState } from '../../../../redux/SettingsSlice'; +import settingsReducer, { initialState as settingsInitialState, setAddendOne } from '../../../../redux/SettingsSlice'; import SwipeVertical from './Swipe'; @@ -27,7 +27,7 @@ jest.mock('react-native-reanimated', () => ({ jest.mock('../../../Analytics', () => ({ logEvent: function () { } })); jest.mock('expo-haptics', () => ({ - impactAsync: function () { }, + impactAsync: jest.fn(), ImpactFeedbackStyle: { Light: 'Light', Medium: 'Medium', Heavy: 'Heavy' }, })); @@ -58,6 +58,7 @@ beforeEach(() => { (globalThis as any).__ph = {}; (globalThis as any).__react = undefined; jest.useRealTimers(); + jest.clearAllMocks(); }); const renderSwipe = ({ onRender }: { onRender?: (id: string) => void; } = {}) => { @@ -70,12 +71,12 @@ const renderSwipe = ({ onRender }: { onRender?: (id: string) => void; } = {}) => }, }); const dispatch = jest.spyOn(store, 'dispatch'); - render( + const view = render( <> ); - return { dispatch, store }; + return { dispatch, store, unmount: view.unmount }; }; const triggerReaction = (totalOffset: number, prevTotalOffset: number) => { @@ -186,6 +187,49 @@ describe('SwipeVertical', () => { ); }); + it('cancels the original hold timer after a re-render', () => { + const { dispatch, store } = renderSwipe(); + jest.useFakeTimers(); + + (globalThis as any).__ph.onBegin(); + act(() => { + store.dispatch(setAddendOne(2)); + }); + + const rerenderedPan = (globalThis as any).__ph; + rerenderedPan.onUpdate({ translationY: -2 }); + triggerReaction(2, 0); + act(() => { + jest.advanceTimersByTime(401); + }); + + rerenderedPan.onUpdate({ translationY: -100 }); + triggerReaction(100, 2); + act(() => { + rerenderedPan.onEnd({ translationY: -100 }); + rerenderedPan.onFinalize(); + }); + + expect(dispatch).toHaveBeenCalledWith( + playerRoundScoreIncrement('player-1', 0, 4) + ); + }); + + it('clears the hold timer when unmounted', () => { + const { unmount } = renderSwipe(); + const ph = (globalThis as any).__ph; + jest.useFakeTimers(); + + ph.onBegin(); + unmount(); + act(() => { + jest.advanceTimersByTime(401); + }); + + const haptics = jest.requireMock('expo-haptics'); + expect(haptics.impactAsync).not.toHaveBeenCalled(); + }); + it('does not re-render solely because the current round changes', () => { const onRender = jest.fn(); const { store } = renderSwipe({ onRender }); diff --git a/src/components/Interactions/Swipe/Swipe.tsx b/src/components/Interactions/Swipe/Swipe.tsx index 22e1cecd..7f01a780 100644 --- a/src/components/Interactions/Swipe/Swipe.tsx +++ b/src/components/Interactions/Swipe/Swipe.tsx @@ -60,16 +60,10 @@ const SwipeVertical: React.FC = ({ const secondaryHoldTime = 500; const holdDuration = useRef(new Animated.Value(0)).current; - let secondaryHoldTimer: ReturnType; + const secondaryHoldTimerRef = useRef>(); const [secondaryHold, setSecondaryHold] = useState(false); const isSecondaryHoldActive = useSharedValue(false); - useEffect(() => { - return () => { - clearTimeout(secondaryHoldTimer); - }; - }, []); - const scale = holdDuration.interpolate({ inputRange: [0, secondaryHoldTime * .2, secondaryHoldTime * .9, secondaryHoldTime], outputRange: [1, 1, 1.1, 1.05], @@ -80,14 +74,17 @@ const SwipeVertical: React.FC = ({ const animationRef = useRef( ); - const secondaryHoldStart = () => { + const secondaryHoldStart = useCallback(() => { + clearTimeout(secondaryHoldTimerRef.current); + Animated.timing(holdDuration, { toValue: secondaryHoldTime, duration: secondaryHoldTime, useNativeDriver: true, }).start(); - secondaryHoldTimer = setTimeout(() => { + secondaryHoldTimerRef.current = setTimeout(() => { + secondaryHoldTimerRef.current = undefined; Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); setSecondaryHold(true); isSecondaryHoldActive.value = true; @@ -113,9 +110,12 @@ const SwipeVertical: React.FC = ({ ); animationRef.current.start(); }, secondaryHoldTime * .8); - }; + }, [holdDuration, isSecondaryHoldActive, wiggleValue]); + + const secondaryHoldStop = useCallback(() => { + clearTimeout(secondaryHoldTimerRef.current); + secondaryHoldTimerRef.current = undefined; - const secondaryHoldStop = () => { Animated.timing(holdDuration, { toValue: 0, duration: 100, @@ -134,8 +134,14 @@ const SwipeVertical: React.FC = ({ useNativeDriver: true, }).start(); - clearTimeout(secondaryHoldTimer); - }; + }, [holdDuration, isSecondaryHoldActive, wiggleValue]); + + useEffect(() => { + return () => { + clearTimeout(secondaryHoldTimerRef.current); + animationRef.current?.stop(); + }; + }, []); //#endregion @@ -165,7 +171,7 @@ const SwipeVertical: React.FC = ({ interaction: 'swipe-vertical', }); secondaryHoldStop(); - }, [index, currentGameId, secondaryHold, addendOne, addendTwo, getCurrentRoundIndex, menuOpen]); + }, [index, currentGameId, secondaryHold, addendOne, addendTwo, getCurrentRoundIndex, menuOpen, secondaryHoldStop]); const panGesture = Gesture.Pan() .enabled(!currentGameLocked && !menuOpen)