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