From 961d1be1bf322dbe8ce06ebfd4374e256d09542c Mon Sep 17 00:00:00 2001
From: Pduhard <45771097+Pduhard@users.noreply.github.com>
Date: Tue, 20 Oct 2026 01:30:13 +0200
Subject: [PATCH 1/2] [fix] useResponderEvents: find the common ancestor when
it is the outermost responder node
---
.../__tests__/index-test.js | 53 +++++++++++++++++++
.../src/modules/useResponderEvents/utils.js | 4 +-
2 files changed, 55 insertions(+), 2 deletions(-)
diff --git a/packages/react-native-web/src/modules/useResponderEvents/__tests__/index-test.js b/packages/react-native-web/src/modules/useResponderEvents/__tests__/index-test.js
index 0fcf5325a9..6fc7f07ce9 100644
--- a/packages/react-native-web/src/modules/useResponderEvents/__tests__/index-test.js
+++ b/packages/react-native-web/src/modules/useResponderEvents/__tests__/index-test.js
@@ -881,6 +881,59 @@ describe('useResponderEvents', () => {
expect(getResponderNode()).toBe(null);
}
);
+
+ test('move onto a sibling grants responder to their parent when it has no registered ancestor', () => {
+ // A touch move stays on its touchstart target, so only a mouse can move onto a sibling
+ const pointerType = 'mouse';
+ const siblingRef = createRef();
+ const targetCallbacks = {
+ onStartShouldSetResponder: jest.fn(() => true)
+ };
+ const siblingCallbacks = {
+ onStartShouldSetResponder: jest.fn(() => true)
+ };
+ const parentCallbacks = {
+ onMoveShouldSetResponder: jest.fn(() => true),
+ onResponderGrant: jest.fn()
+ };
+
+ const Component = () => {
+ useResponderEvents(parentRef, parentCallbacks);
+ useResponderEvents(targetRef, targetCallbacks);
+ useResponderEvents(siblingRef, siblingCallbacks);
+ return (
+
+ );
+ };
+
+ // render
+ render();
+ const target = createEventTarget(targetRef.current);
+ const sibling = createEventTarget(siblingRef.current);
+ // gesture start
+ act(() => {
+ target.pointerdown({ pointerType });
+ });
+ expect(getResponderNode()).toBe(targetRef.current);
+ // gesture move onto the sibling
+ act(() => {
+ sibling.pointermove({ pointerType });
+ });
+ // responder set (bubble phase)
+ expect(parentCallbacks.onMoveShouldSetResponder).toHaveBeenCalledTimes(1);
+ // responder grant
+ expect(getResponderNode()).toBe(parentRef.current);
+ expect(parentCallbacks.onResponderGrant).toHaveBeenCalledTimes(1);
+ // gesture end
+ act(() => {
+ sibling.pointerup({ pointerType });
+ });
+ // no responder should be set
+ expect(getResponderNode()).toBe(null);
+ });
});
/**
diff --git a/packages/react-native-web/src/modules/useResponderEvents/utils.js b/packages/react-native-web/src/modules/useResponderEvents/utils.js
index 37d9017dda..e416a8b03b 100644
--- a/packages/react-native-web/src/modules/useResponderEvents/utils.js
+++ b/packages/react-native-web/src/modules/useResponderEvents/utils.js
@@ -121,8 +121,8 @@ export function getLowestCommonAncestor(
if (itemA === itemB) {
return itemA;
}
- itemA = pathA[indexA++];
- itemB = pathB[indexB++];
+ itemA = pathA[++indexA];
+ itemB = pathB[++indexB];
}
return null;
}
From c8a0f4f9830dfa7ec610837cf248488dfdcbf1bb Mon Sep 17 00:00:00 2001
From: Pduhard <45771097+Pduhard@users.noreply.github.com>
Date: Tue, 20 Oct 2026 10:58:43 +0200
Subject: [PATCH 2/2] Move the test to Negotiation, next to the sibling cases
Co-Authored-By: Claude Opus 5.5
---
.../__tests__/index-test.js | 138 +++++++++++-------
1 file changed, 85 insertions(+), 53 deletions(-)
diff --git a/packages/react-native-web/src/modules/useResponderEvents/__tests__/index-test.js b/packages/react-native-web/src/modules/useResponderEvents/__tests__/index-test.js
index 6fc7f07ce9..0af80d2858 100644
--- a/packages/react-native-web/src/modules/useResponderEvents/__tests__/index-test.js
+++ b/packages/react-native-web/src/modules/useResponderEvents/__tests__/index-test.js
@@ -881,59 +881,6 @@ describe('useResponderEvents', () => {
expect(getResponderNode()).toBe(null);
}
);
-
- test('move onto a sibling grants responder to their parent when it has no registered ancestor', () => {
- // A touch move stays on its touchstart target, so only a mouse can move onto a sibling
- const pointerType = 'mouse';
- const siblingRef = createRef();
- const targetCallbacks = {
- onStartShouldSetResponder: jest.fn(() => true)
- };
- const siblingCallbacks = {
- onStartShouldSetResponder: jest.fn(() => true)
- };
- const parentCallbacks = {
- onMoveShouldSetResponder: jest.fn(() => true),
- onResponderGrant: jest.fn()
- };
-
- const Component = () => {
- useResponderEvents(parentRef, parentCallbacks);
- useResponderEvents(targetRef, targetCallbacks);
- useResponderEvents(siblingRef, siblingCallbacks);
- return (
-
- );
- };
-
- // render
- render();
- const target = createEventTarget(targetRef.current);
- const sibling = createEventTarget(siblingRef.current);
- // gesture start
- act(() => {
- target.pointerdown({ pointerType });
- });
- expect(getResponderNode()).toBe(targetRef.current);
- // gesture move onto the sibling
- act(() => {
- sibling.pointermove({ pointerType });
- });
- // responder set (bubble phase)
- expect(parentCallbacks.onMoveShouldSetResponder).toHaveBeenCalledTimes(1);
- // responder grant
- expect(getResponderNode()).toBe(parentRef.current);
- expect(parentCallbacks.onResponderGrant).toHaveBeenCalledTimes(1);
- // gesture end
- act(() => {
- sibling.pointerup({ pointerType });
- });
- // no responder should be set
- expect(getResponderNode()).toBe(null);
- });
});
/**
@@ -2438,6 +2385,91 @@ describe('useResponderEvents', () => {
]);
});
+ /**
+ * If a node is responder, then the negotiation with a sibling should
+ * capture to and bubble from the first common ancestor, even when that
+ * ancestor has no ancestors connected to the responder system.
+ */
+ test('negotiate from first common ancestor when it has no responder ancestors', () => {
+ const pointerType = 'mouse';
+ let eventLog = [];
+ const parentCallbacks = {
+ onMoveShouldSetResponderCapture() {
+ eventLog.push('parent: onMoveShouldSetResponderCapture');
+ return false;
+ },
+ onMoveShouldSetResponder() {
+ eventLog.push('parent: onMoveShouldSetResponder');
+ return true;
+ },
+ onResponderGrant() {
+ eventLog.push('parent: onResponderGrant');
+ }
+ };
+ const targetCallbacks = {
+ onStartShouldSetResponder() {
+ eventLog.push('target: onStartShouldSetResponder');
+ return true;
+ },
+ onResponderGrant() {
+ eventLog.push('target: onResponderGrant');
+ },
+ onResponderTerminationRequest() {
+ eventLog.push('target: onResponderTerminationRequest');
+ return true;
+ },
+ onResponderTerminate() {
+ eventLog.push('target: onResponderTerminate');
+ }
+ };
+ const siblingCallbacks = {
+ onStartShouldSetResponder() {
+ eventLog.push('sibling: onStartShouldSetResponder');
+ return true;
+ }
+ };
+
+ const Component = () => {
+ useResponderEvents(parentRef, parentCallbacks);
+ useResponderEvents(targetRef, targetCallbacks);
+ useResponderEvents(siblingRef, siblingCallbacks);
+ return (
+
+ );
+ };
+
+ // render
+ render();
+ const target = createEventTarget(targetRef.current);
+ const sibling = createEventTarget(siblingRef.current);
+ // gesture start on target
+ act(() => {
+ target.pointerdown({ pointerType });
+ });
+ expect(getResponderNode()).toBe(targetRef.current);
+ expect(eventLog).toEqual([
+ 'target: onStartShouldSetResponder',
+ 'target: onResponderGrant'
+ ]);
+ eventLog = [];
+ // gesture move on sibling
+ act(() => {
+ sibling.pointermove({ pointerType });
+ });
+ // negotiates from first common ancestor (i.e., parent) and transfers
+ expect(getResponderNode()).toBe(parentRef.current);
+ expect(eventLog).toEqual([
+ 'parent: onMoveShouldSetResponderCapture',
+ 'parent: onMoveShouldSetResponder',
+ 'target: onResponderTerminationRequest',
+ 'target: onResponderTerminate',
+ 'parent: onResponderGrant'
+ ]);
+ });
+
/**
* If siblings are connected to the responder system but have no ancestors
* connected, there should be no negotiation between siblings after one