Skip to content

[Android] Don't let an awaiting parent handler cancel the child it is waiting for - #4476

Open
j-piasecki wants to merge 1 commit into
mainfrom
push-tvrklpqukpys
Open

[Android] Don't let an awaiting parent handler cancel the child it is waiting for#4476
j-piasecki wants to merge 1 commit into
mainfrom
push-tvrklpqukpys

Conversation

@j-piasecki

@j-piasecki j-piasecki commented Sep 2, 2026

Copy link
Copy Markdown
Member

Description

Fixes #3326

Replaces state == ACTIVE check with handler.isActive to correctly account for handlers that have technically met activation criteria, but are awaiting for the failure of another handler.

Test plan

Tested on updated repro from issue
import React, { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import type {
  LegacyPanGesture,
  NativeGesture,
  PanGesture,
} from 'react-native-gesture-handler';
import {
  Gesture,
  GestureDetector,
  useNativeGesture,
  usePanGesture,
} from 'react-native-gesture-handler';
import type { PagerViewOnPageSelectedEvent } from 'react-native-pager-view';
import PagerView from 'react-native-pager-view';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
} from 'react-native-reanimated';

// Reproduction of https://github.com/software-mansion/react-native-gesture-handler/issues/3326
// Expected: dragging the yellow ScrollView never begins/activates the drawer pan.

type AnyPanGesture = PanGesture | LegacyPanGesture;
type AnyNativeGesture = NativeGesture | ReturnType<typeof Gesture.Native>;
type SetStatus = React.Dispatch<React.SetStateAction<string>>;

type Mode =
  | 'requireToFail-parent'
  | 'block-parent'
  | 'block-child'
  | 'v2'
  | 'noPager'
  | 'noPaging'
  | 'nestedPan';
const MODES: Mode[] = [
  'requireToFail-parent',
  'block-parent',
  'block-child',
  'v2',
  'noPager',
  'noPaging',
  'nestedPan',
];

export default function EmptyExample() {
  const [mode, setMode] = useState<Mode>('requireToFail-parent');

  return (
    <View style={styles.container}>
      <View style={styles.modes}>
        {MODES.map((m) => (
          <Pressable
            key={m}
            testID={`mode-${m}`}
            onPress={() => setMode(m)}
            style={[styles.modeButton, m === mode && styles.modeButtonActive]}>
            <Text style={styles.modeText}>{m}</Text>
          </Pressable>
        ))}
      </View>
      {mode === 'requireToFail-parent' && <RequireToFailInParent key={mode} />}
      {mode === 'block-parent' && <BlockInParent key={mode} />}
      {mode === 'block-child' && <BlockInChild key={mode} />}
      {mode === 'v2' && <V2BlockInChild key={mode} />}
      {mode === 'noPager' && <BlockInChild key={mode} pager={false} />}
      {mode === 'noPaging' && <BlockInChild key={mode} paging={false} />}
      {mode === 'nestedPan' && <NestedPanInScrollView key={mode} />}
    </View>
  );
}

function useDrawerPan(
  innerNative: NativeGesture | undefined,
  swipeEnabled: boolean,
  setStatus: SetStatus
) {
  const val = useSharedValue(0);

  const pan = usePanGesture({
    requireToFail: innerNative,
    activeOffsetX: swipeEnabled ? 5 : undefined,
    failOffsetX: swipeEnabled ? -1 : [0, 0],
    failOffsetY: swipeEnabled ? undefined : [0, 0],
    runOnJS: true,
    onBegin: () => {
      setStatus('pan: begin');
      val.set(0);
    },
    onActivate: () => {
      setStatus((s) => `${s} > ACTIVE`);
    },
    onUpdate: (e) => {
      val.set(e.translationX);
    },
    onDeactivate: () => {
      val.set(0);
    },
    onFinalize: (e) => {
      setStatus((s) => `${s} > finalized (canceled: ${e.canceled})`);
      val.set(0);
    },
  });

  const style = useAnimatedStyle(() => ({
    flex: 1,
    transform: [{ translateX: val.value }],
  }));

  return { pan, style };
}

function RequireToFailInParent() {
  const [status, setStatus] = useState('pan: idle');
  const [swipeEnabled, setSwipeEnabled] = useState(true);
  const innerNative = useNativeGesture({});
  const { pan, style } = useDrawerPan(innerNative, swipeEnabled, setStatus);
  const pagerNative = useNativeGesture({ requireToFail: pan });

  return (
    <Drawer pan={pan} style={style} status={status}>
      <Pager
        native={pagerNative}
        setSwipeEnabled={setSwipeEnabled}
        renderPage={(index) =>
          index === 0 ? (
            <InnerScrollView innerNative={innerNative} />
          ) : (
            <Text style={styles.status}>Page {index + 1}</Text>
          )
        }
      />
    </Drawer>
  );
}

function BlockInParent() {
  const [status, setStatus] = useState('pan: idle');
  const [swipeEnabled, setSwipeEnabled] = useState(true);
  const { pan, style } = useDrawerPan(undefined, swipeEnabled, setStatus);
  const innerNative = useNativeGesture({ block: pan });
  const pagerNative = useNativeGesture({ requireToFail: pan });

  return (
    <Drawer pan={pan} style={style} status={status}>
      <Pager
        native={pagerNative}
        setSwipeEnabled={setSwipeEnabled}
        renderPage={(index) =>
          index === 0 ? (
            <InnerScrollView innerNative={innerNative} />
          ) : (
            <Text style={styles.status}>Page {index + 1}</Text>
          )
        }
      />
    </Drawer>
  );
}

function BlockInChild({
  pager = true,
  paging = true,
}: {
  pager?: boolean;
  paging?: boolean;
}) {
  const [status, setStatus] = useState('pan: idle');
  const [swipeEnabled, setSwipeEnabled] = useState(true);
  const { pan, style } = useDrawerPan(undefined, swipeEnabled, setStatus);
  const pagerNative = useNativeGesture({ requireToFail: pan });

  return (
    <Drawer pan={pan} style={style} status={status}>
      <Pager
        native={pager ? pagerNative : undefined}
        setSwipeEnabled={setSwipeEnabled}
        renderPage={() => (
          <InnerScrollViewBlockingPan pan={pan} paging={paging} />
        )}
      />
    </Drawer>
  );
}

// Scenario from PR #3095: a pan nested in a ScrollView must not activate while the ScrollView scrolls.
function NestedPanInScrollView() {
  const [status, setStatus] = useState('pan: idle');
  const [scrollY, setScrollY] = useState(0);
  const native = useNativeGesture({});
  const pan = usePanGesture({
    runOnJS: true,
    onBegin: () => setStatus('pan: begin'),
    onActivate: () => setStatus((s) => `${s} > ACTIVE`),
    onFinalize: (e) =>
      setStatus((s) => `${s} > finalized (canceled: ${e.canceled})`),
  });

  return (
    <View style={styles.container}>
      <Text testID="status" style={styles.status}>
        {status}
      </Text>
      <Text testID="scrollY" style={styles.status}>
        scrollY: {Math.round(scrollY)}
      </Text>
      <GestureDetector gesture={native}>
        <ScrollView
          testID="outer-scroll"
          style={styles.pager}
          scrollEventThrottle={16}
          onScroll={(e) => setScrollY(e.nativeEvent.contentOffset.y)}>
          <View style={styles.spacer} />
          <GestureDetector gesture={pan}>
            <View testID="nested-box" style={styles.nestedBox} />
          </GestureDetector>
          <View style={styles.spacer} />
          <View style={styles.spacer} />
          <View style={styles.spacer} />
        </ScrollView>
      </GestureDetector>
    </View>
  );
}

function V2BlockInChild() {
  const [status, setStatus] = useState('pan: idle');
  const [swipeEnabled, setSwipeEnabled] = useState(true);
  const val = useSharedValue(0);

  let pan = Gesture.Pan()
    .runOnJS(true)
    .onBegin(() => {
      setStatus('pan: begin');
      val.set(0);
    })
    .onStart(() => {
      setStatus((s) => `${s} > ACTIVE`);
    })
    .onUpdate((e) => {
      val.set(e.translationX);
    })
    .onEnd(() => {
      val.set(0);
    })
    .onFinalize((_e, success) => {
      setStatus((s) => `${s} > finalized (canceled: ${!success})`);
      val.set(0);
    });
  pan = swipeEnabled
    ? pan.failOffsetX(-1).activeOffsetX(5)
    : pan.failOffsetX([0, 0]).failOffsetY([0, 0]);

  const style = useAnimatedStyle(() => ({
    flex: 1,
    transform: [{ translateX: val.value }],
  }));

  const pagerNative = Gesture.Native().requireExternalGestureToFail(pan);

  return (
    <Drawer pan={pan} style={style} status={status}>
      <Pager
        native={pagerNative}
        setSwipeEnabled={setSwipeEnabled}
        renderPage={() => <V2InnerScrollView pan={pan} />}
      />
    </Drawer>
  );
}

function V2InnerScrollView({ pan }: { pan: LegacyPanGesture }) {
  const innerNative = Gesture.Native().blocksExternalGesture(pan);
  return <InnerScrollView innerNative={innerNative} />;
}

function Drawer({
  pan,
  style,
  status,
  children,
}: {
  pan: AnyPanGesture;
  style: ReturnType<typeof useAnimatedStyle>;
  status: string;
  children: React.ReactNode;
}) {
  return (
    <GestureDetector gesture={pan as PanGesture}>
      <Animated.View style={style}>
        <Text testID="status" style={styles.status}>
          {status}
        </Text>
        {children}
      </Animated.View>
    </GestureDetector>
  );
}

function Pager({
  renderPage,
  native,
  setSwipeEnabled,
}: {
  renderPage: (index: number) => React.ReactNode;
  native: AnyNativeGesture | undefined;
  setSwipeEnabled: (enabled: boolean) => void;
}) {
  const [page, setPage] = useState(0);

  if (!native) {
    return <View style={styles.pager}>{renderPage(0)}</View>;
  }

  return (
    <GestureDetector gesture={native as NativeGesture}>
      <PagerView
        overdrag
        initialPage={0}
        style={styles.pager}
        onPageSelected={(e: PagerViewOnPageSelectedEvent) => {
          setSwipeEnabled(e.nativeEvent.position === 0);
          setPage(e.nativeEvent.position);
        }}>
        <View key="1">
          <Text testID="page" style={styles.status}>
            page: {page}
          </Text>
          {renderPage(0)}
        </View>
        <View key="2">{renderPage(1)}</View>
        <View key="3">{renderPage(2)}</View>
      </PagerView>
    </GestureDetector>
  );
}

function InnerScrollViewBlockingPan({
  pan,
  paging,
}: {
  pan: PanGesture;
  paging: boolean;
}) {
  const innerNative = useNativeGesture({ block: pan });
  return <InnerScrollView innerNative={innerNative} paging={paging} />;
}

function InnerScrollView({
  innerNative,
  paging = true,
}: {
  innerNative: AnyNativeGesture;
  paging?: boolean;
}) {
  const [scrollX, setScrollX] = useState(0);
  return (
    <View style={styles.scrollContainer}>
      <Text testID="scrollX" style={styles.status}>
        scrollX: {Math.round(scrollX)}
      </Text>
      <GestureDetector gesture={innerNative as NativeGesture}>
        <ScrollView
          horizontal
          pagingEnabled={paging}
          testID="inner-scroll"
          onScroll={(e) => setScrollX(e.nativeEvent.contentOffset.x)}
          scrollEventThrottle={16}
          style={styles.scroll}>
          <Text style={styles.scrollText}>
            1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
            27 28 29 30 31 32 33 34 35 36 37 38 39 40
          </Text>
        </ScrollView>
      </GestureDetector>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  modes: {
    flexDirection: 'row',
    flexWrap: 'wrap',
    gap: 4,
    padding: 4,
  },
  modeButton: {
    width: '32%',
    padding: 8,
    backgroundColor: '#ddd',
    borderRadius: 6,
  },
  modeButtonActive: {
    backgroundColor: '#8f8',
  },
  modeText: {
    fontSize: 11,
    textAlign: 'center',
  },
  status: {
    padding: 8,
    fontSize: 18,
    textAlign: 'center',
  },
  pager: {
    flex: 1,
    backgroundColor: 'green',
  },
  scrollContainer: {
    paddingTop: 150,
    alignItems: 'center',
  },
  scroll: {
    width: 300,
    height: 200,
    backgroundColor: 'yellow',
  },
  scrollText: {
    width: 1000,
  },
  spacer: {
    height: 400,
  },
  nestedBox: {
    width: 150,
    height: 150,
    alignSelf: 'center',
    backgroundColor: 'yellow',
  },
});

… waiting for

## Description

Fixes #3326

Replaces `state == ACTIVE` check with `handler.isActive` to correctly account for handlers that have technically met activation criteria, but are awaiting for the failure of another handler.

## Test plan

<details>
<summary>Tested on updated repro from issue</summary>

```jsx
import React, { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import type {
  LegacyPanGesture,
  NativeGesture,
  PanGesture,
} from 'react-native-gesture-handler';
import {
  Gesture,
  GestureDetector,
  useNativeGesture,
  usePanGesture,
} from 'react-native-gesture-handler';
import type { PagerViewOnPageSelectedEvent } from 'react-native-pager-view';
import PagerView from 'react-native-pager-view';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
} from 'react-native-reanimated';

// Reproduction of #3326
// Expected: dragging the yellow ScrollView never begins/activates the drawer pan.

type AnyPanGesture = PanGesture | LegacyPanGesture;
type AnyNativeGesture = NativeGesture | ReturnType<typeof Gesture.Native>;
type SetStatus = React.Dispatch<React.SetStateAction<string>>;

type Mode =
  | 'requireToFail-parent'
  | 'block-parent'
  | 'block-child'
  | 'v2'
  | 'noPager'
  | 'noPaging'
  | 'nestedPan';
const MODES: Mode[] = [
  'requireToFail-parent',
  'block-parent',
  'block-child',
  'v2',
  'noPager',
  'noPaging',
  'nestedPan',
];

export default function EmptyExample() {
  const [mode, setMode] = useState<Mode>('requireToFail-parent');

  return (
    <View style={styles.container}>
      <View style={styles.modes}>
        {MODES.map((m) => (
          <Pressable
            key={m}
            testID={`mode-${m}`}
            onPress={() => setMode(m)}
            style={[styles.modeButton, m === mode && styles.modeButtonActive]}>
            <Text style={styles.modeText}>{m}</Text>
          </Pressable>
        ))}
      </View>
      {mode === 'requireToFail-parent' && <RequireToFailInParent key={mode} />}
      {mode === 'block-parent' && <BlockInParent key={mode} />}
      {mode === 'block-child' && <BlockInChild key={mode} />}
      {mode === 'v2' && <V2BlockInChild key={mode} />}
      {mode === 'noPager' && <BlockInChild key={mode} pager={false} />}
      {mode === 'noPaging' && <BlockInChild key={mode} paging={false} />}
      {mode === 'nestedPan' && <NestedPanInScrollView key={mode} />}
    </View>
  );
}

function useDrawerPan(
  innerNative: NativeGesture | undefined,
  swipeEnabled: boolean,
  setStatus: SetStatus
) {
  const val = useSharedValue(0);

  const pan = usePanGesture({
    requireToFail: innerNative,
    activeOffsetX: swipeEnabled ? 5 : undefined,
    failOffsetX: swipeEnabled ? -1 : [0, 0],
    failOffsetY: swipeEnabled ? undefined : [0, 0],
    runOnJS: true,
    onBegin: () => {
      setStatus('pan: begin');
      val.set(0);
    },
    onActivate: () => {
      setStatus((s) => `${s} > ACTIVE`);
    },
    onUpdate: (e) => {
      val.set(e.translationX);
    },
    onDeactivate: () => {
      val.set(0);
    },
    onFinalize: (e) => {
      setStatus((s) => `${s} > finalized (canceled: ${e.canceled})`);
      val.set(0);
    },
  });

  const style = useAnimatedStyle(() => ({
    flex: 1,
    transform: [{ translateX: val.value }],
  }));

  return { pan, style };
}

function RequireToFailInParent() {
  const [status, setStatus] = useState('pan: idle');
  const [swipeEnabled, setSwipeEnabled] = useState(true);
  const innerNative = useNativeGesture({});
  const { pan, style } = useDrawerPan(innerNative, swipeEnabled, setStatus);
  const pagerNative = useNativeGesture({ requireToFail: pan });

  return (
    <Drawer pan={pan} style={style} status={status}>
      <Pager
        native={pagerNative}
        setSwipeEnabled={setSwipeEnabled}
        renderPage={(index) =>
          index === 0 ? (
            <InnerScrollView innerNative={innerNative} />
          ) : (
            <Text style={styles.status}>Page {index + 1}</Text>
          )
        }
      />
    </Drawer>
  );
}

function BlockInParent() {
  const [status, setStatus] = useState('pan: idle');
  const [swipeEnabled, setSwipeEnabled] = useState(true);
  const { pan, style } = useDrawerPan(undefined, swipeEnabled, setStatus);
  const innerNative = useNativeGesture({ block: pan });
  const pagerNative = useNativeGesture({ requireToFail: pan });

  return (
    <Drawer pan={pan} style={style} status={status}>
      <Pager
        native={pagerNative}
        setSwipeEnabled={setSwipeEnabled}
        renderPage={(index) =>
          index === 0 ? (
            <InnerScrollView innerNative={innerNative} />
          ) : (
            <Text style={styles.status}>Page {index + 1}</Text>
          )
        }
      />
    </Drawer>
  );
}

function BlockInChild({
  pager = true,
  paging = true,
}: {
  pager?: boolean;
  paging?: boolean;
}) {
  const [status, setStatus] = useState('pan: idle');
  const [swipeEnabled, setSwipeEnabled] = useState(true);
  const { pan, style } = useDrawerPan(undefined, swipeEnabled, setStatus);
  const pagerNative = useNativeGesture({ requireToFail: pan });

  return (
    <Drawer pan={pan} style={style} status={status}>
      <Pager
        native={pager ? pagerNative : undefined}
        setSwipeEnabled={setSwipeEnabled}
        renderPage={() => (
          <InnerScrollViewBlockingPan pan={pan} paging={paging} />
        )}
      />
    </Drawer>
  );
}

// Scenario from PR #3095: a pan nested in a ScrollView must not activate while the ScrollView scrolls.
function NestedPanInScrollView() {
  const [status, setStatus] = useState('pan: idle');
  const [scrollY, setScrollY] = useState(0);
  const native = useNativeGesture({});
  const pan = usePanGesture({
    runOnJS: true,
    onBegin: () => setStatus('pan: begin'),
    onActivate: () => setStatus((s) => `${s} > ACTIVE`),
    onFinalize: (e) =>
      setStatus((s) => `${s} > finalized (canceled: ${e.canceled})`),
  });

  return (
    <View style={styles.container}>
      <Text testID="status" style={styles.status}>
        {status}
      </Text>
      <Text testID="scrollY" style={styles.status}>
        scrollY: {Math.round(scrollY)}
      </Text>
      <GestureDetector gesture={native}>
        <ScrollView
          testID="outer-scroll"
          style={styles.pager}
          scrollEventThrottle={16}
          onScroll={(e) => setScrollY(e.nativeEvent.contentOffset.y)}>
          <View style={styles.spacer} />
          <GestureDetector gesture={pan}>
            <View testID="nested-box" style={styles.nestedBox} />
          </GestureDetector>
          <View style={styles.spacer} />
          <View style={styles.spacer} />
          <View style={styles.spacer} />
        </ScrollView>
      </GestureDetector>
    </View>
  );
}

function V2BlockInChild() {
  const [status, setStatus] = useState('pan: idle');
  const [swipeEnabled, setSwipeEnabled] = useState(true);
  const val = useSharedValue(0);

  let pan = Gesture.Pan()
    .runOnJS(true)
    .onBegin(() => {
      setStatus('pan: begin');
      val.set(0);
    })
    .onStart(() => {
      setStatus((s) => `${s} > ACTIVE`);
    })
    .onUpdate((e) => {
      val.set(e.translationX);
    })
    .onEnd(() => {
      val.set(0);
    })
    .onFinalize((_e, success) => {
      setStatus((s) => `${s} > finalized (canceled: ${!success})`);
      val.set(0);
    });
  pan = swipeEnabled
    ? pan.failOffsetX(-1).activeOffsetX(5)
    : pan.failOffsetX([0, 0]).failOffsetY([0, 0]);

  const style = useAnimatedStyle(() => ({
    flex: 1,
    transform: [{ translateX: val.value }],
  }));

  const pagerNative = Gesture.Native().requireExternalGestureToFail(pan);

  return (
    <Drawer pan={pan} style={style} status={status}>
      <Pager
        native={pagerNative}
        setSwipeEnabled={setSwipeEnabled}
        renderPage={() => <V2InnerScrollView pan={pan} />}
      />
    </Drawer>
  );
}

function V2InnerScrollView({ pan }: { pan: LegacyPanGesture }) {
  const innerNative = Gesture.Native().blocksExternalGesture(pan);
  return <InnerScrollView innerNative={innerNative} />;
}

function Drawer({
  pan,
  style,
  status,
  children,
}: {
  pan: AnyPanGesture;
  style: ReturnType<typeof useAnimatedStyle>;
  status: string;
  children: React.ReactNode;
}) {
  return (
    <GestureDetector gesture={pan as PanGesture}>
      <Animated.View style={style}>
        <Text testID="status" style={styles.status}>
          {status}
        </Text>
        {children}
      </Animated.View>
    </GestureDetector>
  );
}

function Pager({
  renderPage,
  native,
  setSwipeEnabled,
}: {
  renderPage: (index: number) => React.ReactNode;
  native: AnyNativeGesture | undefined;
  setSwipeEnabled: (enabled: boolean) => void;
}) {
  const [page, setPage] = useState(0);

  if (!native) {
    return <View style={styles.pager}>{renderPage(0)}</View>;
  }

  return (
    <GestureDetector gesture={native as NativeGesture}>
      <PagerView
        overdrag
        initialPage={0}
        style={styles.pager}
        onPageSelected={(e: PagerViewOnPageSelectedEvent) => {
          setSwipeEnabled(e.nativeEvent.position === 0);
          setPage(e.nativeEvent.position);
        }}>
        <View key="1">
          <Text testID="page" style={styles.status}>
            page: {page}
          </Text>
          {renderPage(0)}
        </View>
        <View key="2">{renderPage(1)}</View>
        <View key="3">{renderPage(2)}</View>
      </PagerView>
    </GestureDetector>
  );
}

function InnerScrollViewBlockingPan({
  pan,
  paging,
}: {
  pan: PanGesture;
  paging: boolean;
}) {
  const innerNative = useNativeGesture({ block: pan });
  return <InnerScrollView innerNative={innerNative} paging={paging} />;
}

function InnerScrollView({
  innerNative,
  paging = true,
}: {
  innerNative: AnyNativeGesture;
  paging?: boolean;
}) {
  const [scrollX, setScrollX] = useState(0);
  return (
    <View style={styles.scrollContainer}>
      <Text testID="scrollX" style={styles.status}>
        scrollX: {Math.round(scrollX)}
      </Text>
      <GestureDetector gesture={innerNative as NativeGesture}>
        <ScrollView
          horizontal
          pagingEnabled={paging}
          testID="inner-scroll"
          onScroll={(e) => setScrollX(e.nativeEvent.contentOffset.x)}
          scrollEventThrottle={16}
          style={styles.scroll}>
          <Text style={styles.scrollText}>
            1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
            27 28 29 30 31 32 33 34 35 36 37 38 39 40
          </Text>
        </ScrollView>
      </GestureDetector>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  modes: {
    flexDirection: 'row',
    flexWrap: 'wrap',
    gap: 4,
    padding: 4,
  },
  modeButton: {
    width: '32%',
    padding: 8,
    backgroundColor: '#ddd',
    borderRadius: 6,
  },
  modeButtonActive: {
    backgroundColor: '#8f8',
  },
  modeText: {
    fontSize: 11,
    textAlign: 'center',
  },
  status: {
    padding: 8,
    fontSize: 18,
    textAlign: 'center',
  },
  pager: {
    flex: 1,
    backgroundColor: 'green',
  },
  scrollContainer: {
    paddingTop: 150,
    alignItems: 'center',
  },
  scroll: {
    width: 300,
    height: 200,
    backgroundColor: 'yellow',
  },
  scrollText: {
    width: 1000,
  },
  spacer: {
    height: 400,
  },
  nestedBox: {
    width: 150,
    height: 150,
    alignSelf: 'center',
    backgroundColor: 'yellow',
  },
});

```

</details>
Copilot AI lite review requested due to automatic review settings September 2, 2026 14:19
@j-piasecki j-piasecki changed the title repro [Android] Don't let an awaiting parent handler cancel the child it is waiting for Sep 2, 2026
@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Team

Run ID: 92658270-3134-4575-95f1-09a6e6cdb97c

📥 Commits

Reviewing files that changed from the base of the PR and between 6f45895 and 8b59f1c.

📒 Files selected for processing (1)
  • packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Summary

Summary by CodeRabbit

  • Bug Fixes
    • Improved gesture conflict handling by ensuring competing gestures are cancelled based on their actual active status.
    • Prevented incorrect cancellation decisions during gesture activation and state transitions.

Walkthrough

The Android gesture orchestrator now uses each handler's isActive flag when deciding whether to cancel another handler.

Changes

Gesture cancellation

Layer / File(s) Summary
Active-handler cancellation predicate
packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt
shouldBeCancelledByActiveHandler identifies competing active handlers through isActive instead of comparing the raw handler state.

Suggested reviewers: m-bert, kosmydel

Merge Risk: ⚪ Minimal · up to 8b59f

This change prevents an awaiting parent gesture from cancelling the child gesture it depends on, without changing public APIs or deployment behavior. No actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the Android behavior change: preventing an awaiting parent handler from cancelling its child handler.
Linked Issues check ✅ Passed The change directly addresses issue #3326 by using the orchestrator's isActive flag, preventing an awaiting parent handler from cancelling the child handler it waits for. This supports the required ge…
Out of Scope Changes check ✅ Passed The single Android change is directly related to the linked issue and the pull request objective. No unrelated changes are present.
Full details: Linked Issues check

Explanation

The change directly addresses issue #3326 by using the orchestrator's isActive flag, preventing an awaiting parent handler from cancelling the child handler it waits for. This supports the required gesture precedence on Android.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The reproduction harness is currently placed in (and still named as) the shared “Empty Example” screen, which is misleading in the app’s screen lists and should be moved/renamed for clarity.

Pull request overview

This PR updates Android gesture orchestration logic to rely on the orchestrator-managed “active” flag when deciding cancellations, and adds a Common App screen intended to reproduce a nested-gesture interaction issue.

Changes:

  • Android: switch shouldBeCancelledByActiveHandler to check GestureHandler.isActive instead of state == STATE_ACTIVE.
  • Common App: replace the “Empty Example” screen with a multi-mode reproduction harness (PagerView + nested ScrollView + drawer pan).
File summaries
File Description
packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt Uses the orchestrator’s isActive flag to decide whether another handler should cancel a candidate handler.
apps/common-app/src/empty/index.tsx Adds a UI harness to reproduce and compare gesture interaction behaviors across multiple configuration modes.
Review details
  • Files reviewed: 1/1 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

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.

requireExternalGestureToFail and blocksExternalGesture don't work reliably

2 participants