From c0f6d7ee71a8b57bf65f2da2d9baa58804e1b2b0 Mon Sep 17 00:00:00 2001 From: Prajwal T S Date: Tue, 17 Mar 2026 01:23:16 +0530 Subject: [PATCH] fix: migrate gestures and avoid render-time shared value reads Move modal gestures to GestureDetector with GestureHandlerRootView so stories work reliably on Android modals. Remove render-time shared value reads that trigger Reanimated warnings across story components. Update Jest gesture mocks and interaction tests so the new gesture flow is covered by the existing test suite. Made-with: Cursor --- jest.setup.js | 49 ++++++++- src/components/Content/index.tsx | 2 - src/components/Footer/index.tsx | 2 - src/components/Image/index.tsx | 2 - src/components/Image/video.tsx | 4 +- src/components/List/index.tsx | 19 +++- src/components/Loader/index.tsx | 6 +- src/components/Modal/index.tsx | 168 +++++++++++++++++-------------- tests/index.test.js | 20 +++- 9 files changed, 172 insertions(+), 100 deletions(-) diff --git a/jest.setup.js b/jest.setup.js index 365e936..cfd702c 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -90,18 +90,61 @@ jest.mock('react-native-reanimated', () => { jest.mock('react-native-gesture-handler', () => { const View = require('react-native').View; + const createGesture = () => { + + const gesture = { + _onStart: undefined, + _onUpdate: undefined, + _onFinalize: undefined, + onStart( cb ) { + + this._onStart = cb; + return this; + + }, + onUpdate( cb ) { + + this._onUpdate = cb; + return this; + + }, + onFinalize( cb ) { + + this._onFinalize = cb; + return this; + + }, + }; + + return gesture; + + }; return { PanGestureHandler: ({onGestureEvent, children}) => ( onGestureEvent.onStart?.( ...args )} + onResponderEnd={( ...args ) => onGestureEvent.onFinish?.( ...args )} + onResponderMove={( ...args ) => onGestureEvent.onActive?.( ...args )} + testID="gestureContainer" + > + {children} + + ), + Gesture: { + Pan: () => createGesture(), + }, + GestureDetector: ({ gesture, children }) => ( + gesture?._onStart?.( ...args )} + onResponderMove={( ...args ) => gesture?._onUpdate?.( ...args )} + onResponderEnd={( ...args ) => gesture?._onFinalize?.( ...args )} testID="gestureContainer" > {children} ), + GestureHandlerRootView: ({ children, ...props }) => {children}, gestureHandlerRootHOC: (Component) => Component, }; diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index b68d367..eba0c5f 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -26,13 +26,11 @@ const StoryContent: FC = ( { stories, active, activeStory } ) useAnimatedReaction( () => active.value, ( res, prev ) => res !== prev && onChange(), - [ active.value, onChange ], ); useAnimatedReaction( () => activeStory.value, ( res, prev ) => res !== prev && onChange(), - [ activeStory.value, onChange ], ); const content = useMemo( () => stories[storyIndex]?.renderContent?.(), [ storyIndex ] ); diff --git a/src/components/Footer/index.tsx b/src/components/Footer/index.tsx index 4b1aa97..d7287b1 100644 --- a/src/components/Footer/index.tsx +++ b/src/components/Footer/index.tsx @@ -26,13 +26,11 @@ const StoryFooter: FC = ( { stories, active, activeStory } ) useAnimatedReaction( () => active.value, ( res, prev ) => res !== prev && onChange(), - [ active.value, onChange ], ); useAnimatedReaction( () => activeStory.value, ( res, prev ) => res !== prev && onChange(), - [ activeStory.value, onChange ], ); const footer = useMemo( () => stories[storyIndex]?.renderFooter?.(), [ storyIndex ] ); diff --git a/src/components/Image/index.tsx b/src/components/Image/index.tsx index f4c07a9..e911c02 100644 --- a/src/components/Image/index.tsx +++ b/src/components/Image/index.tsx @@ -77,13 +77,11 @@ const StoryImage: FC = ( { useAnimatedReaction( () => isActive.value, ( res, prev ) => res !== prev && res && runOnJS( onImageChange )(), - [ isActive.value, onImageChange ], ); useAnimatedReaction( () => activeStory.value, ( res, prev ) => res !== prev && runOnJS( onImageChange )(), - [ activeStory.value, onImageChange ], ); const onContentLoad = ( newDuration?: number ) => { diff --git a/src/components/Image/video.tsx b/src/components/Image/video.tsx index c16db29..947592c 100644 --- a/src/components/Image/video.tsx +++ b/src/components/Image/video.tsx @@ -17,7 +17,7 @@ const StoryVideo: FC = ( { const ref = useRef( null ); - const [ pausedValue, setPausedValue ] = useState( paused.value ); + const [ pausedValue, setPausedValue ] = useState( true ); const start = () => { @@ -29,13 +29,11 @@ const StoryVideo: FC = ( { useAnimatedReaction( () => paused.value, ( res, prev ) => res !== prev && runOnJS( setPausedValue )( res ), - [ paused.value ], ); useAnimatedReaction( () => isActive.value, ( res ) => res && runOnJS( start )(), - [ isActive.value ], ); return ( diff --git a/src/components/List/index.tsx b/src/components/List/index.tsx index 9fa47d8..2f876fa 100644 --- a/src/components/List/index.tsx +++ b/src/components/List/index.tsx @@ -1,6 +1,6 @@ -import React, { FC, memo } from 'react'; +import React, { FC, memo, useState } from 'react'; import Animated, { - useAnimatedStyle, useDerivedValue, useSharedValue, withTiming, + runOnJS, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withTiming, } from 'react-native-reanimated'; import StoryAnimation from '../Animation'; import ListStyles from './List.styles'; @@ -37,10 +37,21 @@ const StoryList: FC = ( { }; - const lastSeenIndex = stories.findIndex( - ( item ) => item.id === seenStories.value[id], + const [ lastSeenId, setLastSeenId ] = useState( undefined ); + + useAnimatedReaction( + () => seenStories.value[id], + ( res, prev ) => { + if ( res !== prev ) { + runOnJS( setLastSeenId )( res ); + } + }, ); + const lastSeenIndex = lastSeenId !== undefined + ? stories.findIndex( ( item ) => item.id === lastSeenId ) + : -1; + return ( diff --git a/src/components/Loader/index.tsx b/src/components/Loader/index.tsx index 64148ff..7a7fa43 100644 --- a/src/components/Loader/index.tsx +++ b/src/components/Loader/index.tsx @@ -9,7 +9,7 @@ import { Circle, Defs, LinearGradient, Stop, Svg, } from 'react-native-svg'; import { - AVATAR_SIZE, LOADER_ID, LOADER_URL, STROKE_WIDTH, + AVATAR_SIZE, LOADER_COLORS, LOADER_ID, LOADER_URL, STROKE_WIDTH, } from '../../core/constants'; import { StoryLoaderProps } from '../../core/dto/componentsDTO'; @@ -23,7 +23,7 @@ const Loader: FC = ( { const RADIUS = useMemo( () => ( size - STROKE_WIDTH ) / 2, [ size ] ); const CIRCUMFERENCE = useMemo( () => RADIUS * 2 * Math.PI, [ RADIUS ] ); - const [ colors, setColors ] = useState( color.value ); + const [ colors, setColors ] = useState( LOADER_COLORS ); const rotation = useSharedValue( 0 ); const progress = useSharedValue( 0 ); @@ -77,12 +77,10 @@ const Loader: FC = ( { useAnimatedReaction( () => loading.value, ( res ) => ( res ? startAnimation() : stopAnimation() ), - [ loading.value ], ); useAnimatedReaction( () => color.value, ( res ) => onColorChange( res ), - [ color.value ], ); return ( diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx index a83326c..53d177c 100644 --- a/src/components/Modal/index.tsx +++ b/src/components/Modal/index.tsx @@ -2,8 +2,9 @@ import React, { forwardRef, memo, useEffect, useImperativeHandle, useState, } from 'react'; import { GestureResponderEvent, Modal, Pressable } from 'react-native'; +import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'; import Animated, { - cancelAnimation, interpolate, runOnJS, useAnimatedGestureHandler, useAnimatedReaction, + cancelAnimation, interpolate, runOnJS, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withTiming, } from 'react-native-reanimated'; @@ -11,7 +12,6 @@ import { HEIGHT, LONG_PRESS_DURATION, STORY_ANIMATION_DURATION, WIDTH, } from '../../core/constants'; import { GestureContext, StoryModalProps, StoryModalPublicMethods } from '../../core/dto/componentsDTO'; -import GestureHandler from './gesture'; import StoryList from '../List'; import ModalStyles from './Modal.styles'; @@ -56,6 +56,15 @@ const StoryModal = forwardRef( ( { backgroundColor, } ) ); + const gestureContext = useSharedValue( { + x: 0, + pressedX: 0, + pressedAt: 0, + moving: false, + vertical: false, + userId: undefined, + } ); + const onClose = () => { 'worklet'; @@ -261,36 +270,36 @@ const StoryModal = forwardRef( ( { }; - const onGestureEvent = useAnimatedGestureHandler( { - onStart: ( e, ctx: GestureContext ) => { + const onGestureEvent = Gesture.Pan() + .onStart ( _ => { - ctx.x = x.value; - ctx.userId = userId.value; + gestureContext.value.x = x.value; + gestureContext.value.userId = userId.value; paused.value = true; - }, - onActive: ( e, ctx ) => { + } ) + .onUpdate ( e => { - if ( ctx.x === x.value - && ( ctx.vertical || ( Math.abs( e.velocityX ) < Math.abs( e.velocityY ) ) ) ) { + if ( gestureContext.value.x === x.value + && ( gestureContext.value.vertical || ( Math.abs( e.velocityX ) < Math.abs( e.velocityY ) ) ) ) { - ctx.vertical = true; + gestureContext.value.vertical = true; y.value = e.translationY / 2; } else { - ctx.moving = true; + gestureContext.value.moving = true; x.value = Math.max( 0, - Math.min( ctx.x + -e.translationX, WIDTH * ( stories.length - 1 ) ), + Math.min( gestureContext.value.x + -e.translationX, WIDTH * ( stories.length - 1 ) ), ); } - }, - onFinish: ( e, ctx ) => { + } ) + .onFinalize ( e => { - if ( ctx.vertical ) { + if ( gestureContext.value.vertical ) { if ( e.translationY > 100 ) { @@ -312,14 +321,14 @@ const StoryModal = forwardRef( ( { } - } else if ( ctx.moving ) { + } else if ( gestureContext.value.moving ) { - const diff = x.value - ctx.x; + const diff = x.value - gestureContext.value.x; let newX; if ( Math.abs( diff ) < WIDTH / 4 ) { - newX = ctx.x; + newX = gestureContext.value.x; } else { @@ -332,20 +341,24 @@ const StoryModal = forwardRef( ( { const newUserId = stories[Math.round( newX / WIDTH )]?.id; if ( newUserId !== undefined ) { - scrollTo( newUserId, true, newUserId === ctx.userId, ctx.userId ); + scrollTo( + newUserId, + true, + newUserId === gestureContext.value.userId, + gestureContext.value.userId, + ); } } - ctx.moving = false; - ctx.vertical = false; - ctx.userId = undefined; + gestureContext.value.moving = false; + gestureContext.value.vertical = false; + gestureContext.value.userId = undefined; hideElements.value = false; paused.value = false; - }, - } ); + } ); const onPressIn = () => { @@ -428,7 +441,7 @@ const StoryModal = forwardRef( ( { goToPreviousStory: toPreviousStory, goToNextStory: toNextStory, goToSpecificStory: ( newUserId, index ) => scrollTo( newUserId, true, false, undefined, index ), - } ), [ userId.value, currentStory.value ] ); + } ) ); useEffect( () => { @@ -456,63 +469,62 @@ const StoryModal = forwardRef( ( { useAnimatedReaction( () => animation.value, ( res, prev ) => res !== prev && toNextStory( res === 1 ), - [ animation.value ], ); return ( - - - - - - - {stories?.map( ( story, index ) => ( - { - - onLoad?.(); - startAnimation( - undefined, - value !== undefined ? value : duration, - ); - - }} - avatarSize={storyAvatarSize} - textStyle={textStyle} - paused={paused} - videoProps={videoProps} - closeColor={closeIconColor} - hideElements={hideElements} - videoDuration={videoDuration} - loaderColor={loaderColor} - loaderBackgroundColor={loaderBackgroundColor} - key={story.id} - {...props} - /> - ) )} - - - {footerComponent && footerComponent} - - + + + + + + + {stories?.map( ( story, index ) => ( + { + + onLoad?.(); + startAnimation( + undefined, + value !== undefined ? value : duration, + ); + + }} + avatarSize={storyAvatarSize} + textStyle={textStyle} + paused={paused} + videoProps={videoProps} + closeColor={closeIconColor} + hideElements={hideElements} + videoDuration={videoDuration} + loaderColor={loaderColor} + loaderBackgroundColor={loaderBackgroundColor} + key={story.id} + {...props} + /> + ) )} + + + {footerComponent && footerComponent} + + + - ); } ); diff --git a/tests/index.test.js b/tests/index.test.js index 7061993..7e3438b 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -329,7 +329,15 @@ describe( 'Instagram Stories test', () => { fireEvent( getByTestId( 'gestureContainer' ), 'responderEnd', {}, { moving: true, x: -300 } ); await sleep(); - fireEvent( getByTestId( 'gestureContainer' ), 'responderEnd', { translationY: 200 }, { vertical: true } ); + fireEvent( getByTestId( 'gestureContainer' ), 'responderStart', { x: 0 } ); + await sleep(); + + fireEvent( getByTestId( 'gestureContainer' ), 'responderMove', { + x: 0, velocityX: 0, velocityY: 10, translationY: 200, + } ); + await sleep(); + + fireEvent( getByTestId( 'gestureContainer' ), 'responderEnd', { translationY: 200 } ); await sleep(); expect( queryByTestId( 'gestureContainer' ) ).toBeFalsy(); @@ -365,7 +373,15 @@ describe( 'Instagram Stories test', () => { fireEvent( getByTestId( '1StoryAvatar1Story' ), 'click' ); await sleep(); - fireEvent( getByTestId( 'gestureContainer' ), 'responderEnd', { translationY: 200 }, { vertical: true } ); + fireEvent( getByTestId( 'gestureContainer' ), 'responderStart', { x: 0 } ); + await sleep(); + + fireEvent( getByTestId( 'gestureContainer' ), 'responderMove', { + x: 0, velocityX: 0, velocityY: 10, translationY: 200, + } ); + await sleep(); + + fireEvent( getByTestId( 'gestureContainer' ), 'responderEnd', { translationY: 200 } ); await sleep(); expect( queryByTestId( 'gestureContainer' ) ).toBeFalsy();