From 1f4e47b00e064e606f8a0bc2111e0d8cfc79eb67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Fri, 9 Feb 2024 12:56:10 +0100 Subject: [PATCH] Fix `ConcurrentModificationException`. (#2750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description A while ago we decided to remove limit of gesture handlers on android (#2672). We missed one thing - `awaitngHandlers` can be modified while iterating over its items (inside [this loop](https://github.com/software-mansion/react-native-gesture-handler/blob/2e5df1c9a8595929b9879dc1246a7fd78de0549a/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt#L105)). This resulted in `ConcurrentModificationException`. This PR changes logic so that now we iterate over copy of `awaitingHandlers`, avoiding modification of collection that we iterate through. Even though it looks like it changes a lot, the only difference beside adding copy is changing ```jsx for(otherHandler in currentlyAwaitingHandlers){ if (shouldHandlerWaitForOther(otherHandler, handler)) { ... } } ``` into ```jsx for(otherHandler in currentlyAwaitingHandlers){ if (!shouldHandlerWaitForOther(otherHandler, handler)) { continue } ... } ``` to remove one level of nested `ifs`. ## Test plan
Modified code from #2739 ```jsx import React from 'react'; import { ScrollView, StyleSheet, Text, View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated from 'react-native-reanimated'; const Showcase = () => { const [lastActions, setLastActions] = React.useState([]); const addAction = (action) => { setLastActions((actions) => { const date = new Date(); const time = date.toLocaleTimeString(); if (actions.length >= 3) { actions.pop(); } return [action + ': ' + time, ...actions]; }); }; const longPressGesture = Gesture.LongPress() .onStart(() => { addAction('long press'); }) .runOnJS(true); const doubleTapGesture = Gesture.Tap() .numberOfTaps(2) .onStart(() => { addAction('double tap'); }) .runOnJS(true); const gesture = Gesture.Race(doubleTapGesture, longPressGesture); return ( {lastActions.map((action, index) => ( {index} - {action} ))} DoubleTap: on all rectangles - should print "double tap" - no tap gesture should be recognized ✅ LongPress: on all rectangles - should print "long press" - no tap gesture should be recognized ✅ Tap: on red - should do nothing ✅ Tap: on blue - should print "Blue: tap" ✅ Tap: on orange - should print "Orange: tap" ⛔️ - Crashes on Android ); }; export default Showcase; const Blue = ({ onAddAction }) => { const tapGesture = Gesture.Tap() .onStart(() => { onAddAction('Blue: tap'); }) .runOnJS(true); const doubleTapGesture = Gesture.Tap() .numberOfTaps(2) .onStart(() => { onAddAction('double tap'); }) .runOnJS(true); const composedGesture = Gesture.Exclusive(doubleTapGesture, tapGesture); return ( ); }; const Orange = ({ onAddAction }) => { const tapGesture = Gesture.Tap() .onStart(() => { onAddAction('Orange: tap'); }) .runOnJS(true); const doubleTapGesture = Gesture.Tap() .numberOfTaps(2) .onStart(() => { onAddAction('double tap'); }) .runOnJS(true); return ( ); }; const Bold = ({ children }) => { return {children}; }; const styles = StyleSheet.create({ outer: { padding: 20, backgroundColor: 'red', width: 300, height: 200, }, blue: { width: '80%', height: '80%', backgroundColor: 'blue', flexDirection: 'row', flexWrap: 'wrap', }, orange: { margin: 5, width: '50%', height: '50%', backgroundColor: 'orange', }, }); ```
Smaller example ```jsx import React from 'react'; import { StyleSheet, View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; export default function App() { const t3 = Gesture.Tap() .numberOfTaps(3) .onEnd(() => console.log('t3')); const t1_1 = Gesture.Tap().onEnd(() => console.log('t1_1')); const t1_2 = Gesture.Tap().onEnd(() => console.log('t1_2')); const g = Gesture.Exclusive(t3, t1_1, t1_2); return ( ); } const styles = StyleSheet.create({ box: { width: 250, height: 250, backgroundColor: 'crimson', }, }); ```
--- .../core/GestureHandlerOrchestrator.kt | 44 +++++++++++-------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt index 8c9e11c10a..25c9e6f7e3 100644 --- a/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +++ b/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt @@ -101,27 +101,33 @@ class GestureHandlerOrchestrator( fun onHandlerStateChange(handler: GestureHandler<*>, newState: Int, prevState: Int) { handlingChangeSemaphore += 1 if (isFinished(newState)) { + // We have to loop through copy in order to avoid modifying collection + // while iterating over its elements + val currentlyAwaitingHandlers = awaitingHandlers.toList() + // if there were handlers awaiting completion of this handler, we can trigger active state - for (otherHandler in awaitingHandlers) { - if (shouldHandlerWaitForOther(otherHandler, handler)) { - if (newState == GestureHandler.STATE_END) { - // gesture has ended, we need to kill the awaiting handler - otherHandler.cancel() - if (otherHandler.state == GestureHandler.STATE_END) { - // Handle edge case, where discrete gestures end immediately after activation thus - // their state is set to END and when the gesture they are waiting for activates they - // should be cancelled, however `cancel` was never sent as gestures were already in the END state. - // Send synthetic BEGAN -> CANCELLED to properly handle JS logic - otherHandler.dispatchStateChange( - GestureHandler.STATE_CANCELLED, - GestureHandler.STATE_BEGAN - ) - } - otherHandler.isAwaiting = false - } else { - // gesture has failed recognition, we may try activating - tryActivate(otherHandler) + for (otherHandler in currentlyAwaitingHandlers) { + if (!shouldHandlerWaitForOther(otherHandler, handler)) { + continue + } + + if (newState == GestureHandler.STATE_END) { + // gesture has ended, we need to kill the awaiting handler + otherHandler.cancel() + if (otherHandler.state == GestureHandler.STATE_END) { + // Handle edge case, where discrete gestures end immediately after activation thus + // their state is set to END and when the gesture they are waiting for activates they + // should be cancelled, however `cancel` was never sent as gestures were already in the END state. + // Send synthetic BEGAN -> CANCELLED to properly handle JS logic + otherHandler.dispatchStateChange( + GestureHandler.STATE_CANCELLED, + GestureHandler.STATE_BEGAN + ) } + otherHandler.isAwaiting = false + } else { + // gesture has failed recognition, we may try activating + tryActivate(otherHandler) } } cleanupAwaitingHandlers()