From 7baa911a81cb59e1302d996e52b6a41b379cc994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Fri, 14 Aug 2026 08:50:34 +0200 Subject: [PATCH 1/6] Add Scroll manager --- .../src/web/handlers/GestureHandler.ts | 4 ++ .../web/handlers/NativeViewGestureHandler.ts | 57 +++++++++++++++++-- .../src/web/tools/EventManager.ts | 4 ++ .../web/tools/GestureHandlerWebDelegate.ts | 2 + .../src/web/tools/ScrollEventManager.ts | 35 ++++++++++++ 5 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 packages/react-native-gesture-handler/src/web/tools/ScrollEventManager.ts diff --git a/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts index 4d095193ad..75cd59c47c 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts @@ -143,6 +143,7 @@ export default abstract class GestureHandler implements IGestureHandler { manager.setOnPointerMoveOver(this.onPointerMoveOver.bind(this)); manager.setOnPointerMoveOut(this.onPointerMoveOut.bind(this)); manager.setOnWheel(this.onWheel.bind(this)); + manager.setOnScroll(this.onScroll.bind(this)); // The initial config is applied before the handler is attached. Honor an // initially disabled handler here because the delegate's enabled-change @@ -398,6 +399,9 @@ export default abstract class GestureHandler implements IGestureHandler { protected onWheel(_event: AdaptedEvent): void { // Used only by pan gesture handler } + protected onScroll(_event: AdaptedEvent): void { + // Used only by native view gesture handler + } protected tryToSendMoveEvent(out: boolean, event: AdaptedEvent): void { if ((out && this.shouldCancelWhenOutside) || !this.enabled) { return; diff --git a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts index 12d23f5d2f..95368411ce 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts @@ -41,6 +41,10 @@ export default class NativeViewGestureHandler extends GestureHandler { private startY = 0; private minDistSq = DEFAULT_TOUCH_SLOP * DEFAULT_TOUCH_SLOP; + private readonly scrollActivationThresholdSq = 2 * 2; + private isScrollDriven = false; + private scrollDetected = false; + private lastActiveHandlerData: HandlerData | null = null; private hasLongPressHandler = false; @@ -144,6 +148,9 @@ export default class NativeViewGestureHandler extends GestureHandler { return; } + this.scrollDetected = false; + this.isScrollDriven = this.computeIsScrollDriven(); + this.begin(); dispatchGestureLifecycleEvent( @@ -166,11 +173,6 @@ export default class NativeViewGestureHandler extends GestureHandler { protected override onPointerMove(event: AdaptedEvent): void { this.tracker.track(event); - const lastCoords = this.tracker.getAbsoluteCoordsAverage(); - const dx = this.startX - lastCoords.x; - const dy = this.startY - lastCoords.y; - const distSq = dx * dx + dy * dy; - if ( this.role === NativeGestureRole.Switch || this.role === NativeGestureRole.Button @@ -178,7 +180,48 @@ export default class NativeViewGestureHandler extends GestureHandler { return; } - if (distSq >= this.minDistSq && this.state === State.BEGAN) { + if (this.isScrollDriven) { + this.tryScrollDrivenActivation(); + return; + } + + if ( + this.pointerTravelSq() >= this.minDistSq && + this.state === State.BEGAN + ) { + this.activate(); + } + } + + private pointerTravelSq(): number { + const lastCoords = this.tracker.getAbsoluteCoordsAverage(); + const dx = this.startX - lastCoords.x; + const dy = this.startY - lastCoords.y; + return dx * dx + dy * dy; + } + + private computeIsScrollDriven(): boolean { + return this.role === NativeGestureRole.ScrollView; + } + + protected override onScroll(_event: AdaptedEvent): void { + if (this.tracker.trackedPointersCount === 0) { + return; + } + + this.scrollDetected = true; + this.tryScrollDrivenActivation(); + } + + private tryScrollDrivenActivation(): void { + if (!this.scrollDetected || this.state !== State.BEGAN) { + return; + } + + // Require some pointer travel on top of the scroll event — momentum + // scrolling keeps emitting `scroll` events after a touch that was only + // meant to stop it, and that touch must not activate the handler. + if (this.pointerTravelSq() >= this.scrollActivationThresholdSq) { this.activate(); } } @@ -427,6 +470,8 @@ export default class NativeViewGestureHandler extends GestureHandler { this.lastActiveHandlerData = null; this.lastEventWasInside = false; this.longPressDetected = false; + this.scrollDetected = false; + this.isScrollDriven = false; } public override onDestroy(): void { diff --git a/packages/react-native-gesture-handler/src/web/tools/EventManager.ts b/packages/react-native-gesture-handler/src/web/tools/EventManager.ts index c18597f32c..33977d9f87 100644 --- a/packages/react-native-gesture-handler/src/web/tools/EventManager.ts +++ b/packages/react-native-gesture-handler/src/web/tools/EventManager.ts @@ -38,6 +38,7 @@ export default abstract class EventManager { protected onPointerMoveOver(_event: AdaptedEvent): void {} protected onPointerMoveOut(_event: AdaptedEvent): void {} protected onWheel(_event: AdaptedEvent): void {} + protected onScroll(_event: AdaptedEvent): void {} public setOnPointerDown(callback: PointerEventCallback): void { this.onPointerDown = callback; @@ -75,6 +76,9 @@ export default abstract class EventManager { public setOnWheel(callback: PointerEventCallback): void { this.onWheel = callback; } + public setOnScroll(callback: PointerEventCallback): void { + this.onScroll = callback; + } protected markAsInBounds(pointerId: number): void { if (this.pointersInBounds.indexOf(pointerId) >= 0) { diff --git a/packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts b/packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts index a2601991f7..edce77d9a8 100644 --- a/packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts +++ b/packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts @@ -19,6 +19,7 @@ import type { } from './GestureHandlerDelegate'; import KeyboardEventManager from './KeyboardEventManager'; import PointerEventManager from './PointerEventManager'; +import ScrollEventManager from './ScrollEventManager'; import WheelEventManager from './WheelEventManager'; interface DefaultViewStyles { @@ -69,6 +70,7 @@ export class GestureHandlerWebDelegate ); this.eventManagers.push(new KeyboardEventManager(this.view)); this.eventManagers.push(new WheelEventManager(this.view)); + this.eventManagers.push(new ScrollEventManager(this.view)); this.eventManagers.forEach((manager) => this.gestureHandler.attachEventManager(manager) diff --git a/packages/react-native-gesture-handler/src/web/tools/ScrollEventManager.ts b/packages/react-native-gesture-handler/src/web/tools/ScrollEventManager.ts new file mode 100644 index 0000000000..203d8f8da7 --- /dev/null +++ b/packages/react-native-gesture-handler/src/web/tools/ScrollEventManager.ts @@ -0,0 +1,35 @@ +import { PointerType } from '../../PointerType'; +import type { AdaptedEvent } from '../interfaces'; +import { EventTypes } from '../interfaces'; +import EventManager from './EventManager'; + +export default class ScrollEventManager extends EventManager { + private scrollCallback = (event: Event) => { + this.onScroll(this.mapEvent(event)); + }; + + public registerListeners(): void { + this.view.addEventListener('scroll', this.scrollCallback, { + passive: true, + }); + } + + public unregisterListeners(): void { + this.view.removeEventListener('scroll', this.scrollCallback); + } + + protected mapEvent(event: Event): AdaptedEvent { + const rect = this.view.getBoundingClientRect(); + + return { + x: rect.x - this.view.scrollLeft, + y: rect.y - this.view.scrollTop, + offsetX: -this.view.scrollLeft, + offsetY: -this.view.scrollTop, + pointerId: -1, + eventType: EventTypes.MOVE, + pointerType: PointerType.OTHER, + time: event.timeStamp, + }; + } +} From d982c5e07e42888ec8f339b00945ccd24c65fcfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Fri, 14 Aug 2026 09:01:36 +0200 Subject: [PATCH 2/6] Unit test --- .../webNativeViewGestureHandler.test.ts | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 packages/react-native-gesture-handler/src/__tests__/webNativeViewGestureHandler.test.ts diff --git a/packages/react-native-gesture-handler/src/__tests__/webNativeViewGestureHandler.test.ts b/packages/react-native-gesture-handler/src/__tests__/webNativeViewGestureHandler.test.ts new file mode 100644 index 0000000000..5f2115c962 --- /dev/null +++ b/packages/react-native-gesture-handler/src/__tests__/webNativeViewGestureHandler.test.ts @@ -0,0 +1,203 @@ +import { ActionType } from '../ActionType'; +import { PointerType } from '../PointerType'; +import { State } from '../State'; +import { NATIVE_GESTURE_ROLE_ATTRIBUTE } from '../web/constants'; +import type IGestureHandler from '../web/handlers/IGestureHandler'; +import NativeViewGestureHandler from '../web/handlers/NativeViewGestureHandler'; +import type { AdaptedEvent } from '../web/interfaces'; +import { EventTypes, NativeGestureRole } from '../web/interfaces'; +import type { GestureHandlerDelegate } from '../web/tools/GestureHandlerDelegate'; +import GestureHandlerOrchestrator from '../web/tools/GestureHandlerOrchestrator'; +import ScrollEventManager from '../web/tools/ScrollEventManager'; + +// The Jest environment is node — provide the minimal DOM surface the handler +// touches (canUseDOM, instanceof HTMLElement, getComputedStyle). +class FakeHTMLElement { + public style: Record = {}; + public scrollLeft = 0; + public scrollTop = 0; + private attributes = new Map(); + private listeners = new Map void>>(); + + public getBoundingClientRect() { + return { x: 0, y: 0, width: 0, height: 0 }; + } + + public setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } + public getAttribute(name: string): string | null { + return this.attributes.get(name) ?? null; + } + public hasAttribute(name: string): boolean { + return this.attributes.has(name); + } + public addEventListener(type: string, listener: (event: unknown) => void) { + const listeners = this.listeners.get(type) ?? new Set(); + listeners.add(listener); + this.listeners.set(type, listeners); + } + public removeEventListener(type: string, listener: (event: unknown) => void) { + this.listeners.get(type)?.delete(listener); + } + public dispatchEvent(type: string): void { + this.listeners.get(type)?.forEach((listener) => listener({ type })); + } +} + +beforeAll(() => { + const globals = globalThis as Record; + globals.HTMLElement = FakeHTMLElement; + globals.SVGElement = FakeHTMLElement; + globals.window = { document: { createElement: () => new FakeHTMLElement() } }; +}); + +afterAll(() => { + const globals = globalThis as Record; + delete globals.HTMLElement; + delete globals.SVGElement; + delete globals.window; +}); + +class TestNativeViewGestureHandler extends NativeViewGestureHandler { + public pointerDown(event: AdaptedEvent): void { + this.onPointerDown(event); + } + + public pointerMove(event: AdaptedEvent): void { + this.onPointerMove(event); + } +} + +function touchEvent(x: number, y: number, eventType: EventTypes): AdaptedEvent { + return { + x, + y, + offsetX: x, + offsetY: y, + pointerId: 0, + eventType, + pointerType: PointerType.TOUCH, + time: 0, + }; +} + +function createHandler(view: FakeHTMLElement) { + const delegate = { + view, + init: jest.fn(), + detach: jest.fn(), + reset: jest.fn(), + onActivate: jest.fn(), + onFail: jest.fn(), + onCancel: jest.fn(), + onEnd: jest.fn(), + onEnabledChange: jest.fn(), + updateDOM: jest.fn(), + } as unknown as GestureHandlerDelegate; + + const handler = new TestNativeViewGestureHandler(delegate); + handler.setGestureConfig({ enabled: true }); + handler.init(1, { current: {} } as never, ActionType.NATIVE_DETECTOR); + + // Route scroll events the same way the real delegate does. + handler.attachEventManager( + new ScrollEventManager(view as unknown as HTMLElement) + ); + + // The full event pipeline is not under test — silence event emission. + handler.sendEvent = jest.fn(); + + return handler; +} + +describe('NativeViewGestureHandler activation', () => { + afterEach(() => { + // The orchestrator is a singleton — drop handlers recorded by the test. + ( + GestureHandlerOrchestrator.instance as unknown as { + gestureHandlers: IGestureHandler[]; + } + ).gestureHandlers = []; + }); + + test('scrollable view does not activate on pointer distance alone', () => { + const view = new FakeHTMLElement(); + view.setAttribute( + NATIVE_GESTURE_ROLE_ATTRIBUTE, + NativeGestureRole.ScrollView + ); + const handler = createHandler(view); + + handler.pointerDown(touchEvent(100, 100, EventTypes.DOWN)); + expect(handler.state).toBe(State.BEGAN); + + handler.pointerMove(touchEvent(100, 200, EventTypes.MOVE)); + expect(handler.state).toBe(State.BEGAN); + }); + + test('scrollable view activates when it really scrolls during a drag', () => { + const view = new FakeHTMLElement(); + view.setAttribute( + NATIVE_GESTURE_ROLE_ATTRIBUTE, + NativeGestureRole.ScrollView + ); + const handler = createHandler(view); + + handler.pointerDown(touchEvent(100, 100, EventTypes.DOWN)); + handler.pointerMove(touchEvent(100, 130, EventTypes.MOVE)); + + view.dispatchEvent('scroll'); + expect(handler.state).toBe(State.ACTIVE); + }); + + test('scroll under a resting pointer does not activate (momentum stop)', () => { + const view = new FakeHTMLElement(); + view.setAttribute( + NATIVE_GESTURE_ROLE_ATTRIBUTE, + NativeGestureRole.ScrollView + ); + const handler = createHandler(view); + + handler.pointerDown(touchEvent(100, 100, EventTypes.DOWN)); + view.dispatchEvent('scroll'); + expect(handler.state).toBe(State.BEGAN); + + // Once the pointer really moves, the earlier scroll counts. + handler.pointerMove(touchEvent(100, 110, EventTypes.MOVE)); + expect(handler.state).toBe(State.ACTIVE); + }); + + test('scroll with no tracked pointers is ignored', () => { + const view = new FakeHTMLElement(); + view.setAttribute( + NATIVE_GESTURE_ROLE_ATTRIBUTE, + NativeGestureRole.ScrollView + ); + const handler = createHandler(view); + + view.dispatchEvent('scroll'); + expect(handler.state).toBe(State.UNDETERMINED); + }); + + test('non-scrollable view keeps distance-based activation', () => { + const view = new FakeHTMLElement(); + const handler = createHandler(view); + + handler.pointerDown(touchEvent(100, 100, EventTypes.DOWN)); + expect(handler.state).toBe(State.BEGAN); + + handler.pointerMove(touchEvent(100, 130, EventTypes.MOVE)); + expect(handler.state).toBe(State.ACTIVE); + }); + + test('role-less view keeps distance-based activation (scroll-driven mode is v3-only)', () => { + const view = new FakeHTMLElement(); + view.style.overflowY = 'scroll'; + const handler = createHandler(view); + + handler.pointerDown(touchEvent(100, 100, EventTypes.DOWN)); + handler.pointerMove(touchEvent(100, 130, EventTypes.MOVE)); + expect(handler.state).toBe(State.ACTIVE); + }); +}); From 162e504e325738ba1fa56cf9f06705240a54dda3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Fri, 14 Aug 2026 09:13:03 +0200 Subject: [PATCH 3/6] Check if is scroll driven --- .../src/web/handlers/NativeViewGestureHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts index 95368411ce..62985e7416 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts @@ -205,7 +205,7 @@ export default class NativeViewGestureHandler extends GestureHandler { } protected override onScroll(_event: AdaptedEvent): void { - if (this.tracker.trackedPointersCount === 0) { + if (!this.isScrollDriven || this.tracker.trackedPointersCount === 0) { return; } From 60cd2e59350412fb41706c4981466dd23bd36d77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Fri, 14 Aug 2026 09:22:46 +0200 Subject: [PATCH 4/6] Add test --- .../webNativeViewGestureHandler.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/react-native-gesture-handler/src/__tests__/webNativeViewGestureHandler.test.ts b/packages/react-native-gesture-handler/src/__tests__/webNativeViewGestureHandler.test.ts index 5f2115c962..e5f261d45a 100644 --- a/packages/react-native-gesture-handler/src/__tests__/webNativeViewGestureHandler.test.ts +++ b/packages/react-native-gesture-handler/src/__tests__/webNativeViewGestureHandler.test.ts @@ -200,4 +200,20 @@ describe('NativeViewGestureHandler activation', () => { handler.pointerMove(touchEvent(100, 130, EventTypes.MOVE)); expect(handler.state).toBe(State.ACTIVE); }); + + test('scroll on a role-less view does not add an activation path', () => { + const view = new FakeHTMLElement(); + view.style.overflowY = 'scroll'; + const handler = createHandler(view); + + // Below DEFAULT_TOUCH_SLOP, a scroll of the view itself must not activate + // a handler that is not scroll-driven (e.g. legacy ScrollView, TextInput). + handler.pointerDown(touchEvent(100, 100, EventTypes.DOWN)); + handler.pointerMove(touchEvent(100, 110, EventTypes.MOVE)); + view.dispatchEvent('scroll'); + expect(handler.state).toBe(State.BEGAN); + + handler.pointerMove(touchEvent(100, 130, EventTypes.MOVE)); + expect(handler.state).toBe(State.ACTIVE); + }); }); From 648473f88159390954175ffce3d0776b4c44a3fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Fri, 14 Aug 2026 09:47:21 +0200 Subject: [PATCH 5/6] Copilot review --- .../src/__tests__/webNativeViewGestureHandler.test.ts | 6 +----- .../src/web/tools/ScrollEventManager.ts | 9 +++++---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/react-native-gesture-handler/src/__tests__/webNativeViewGestureHandler.test.ts b/packages/react-native-gesture-handler/src/__tests__/webNativeViewGestureHandler.test.ts index e5f261d45a..14db42919d 100644 --- a/packages/react-native-gesture-handler/src/__tests__/webNativeViewGestureHandler.test.ts +++ b/packages/react-native-gesture-handler/src/__tests__/webNativeViewGestureHandler.test.ts @@ -11,7 +11,7 @@ import GestureHandlerOrchestrator from '../web/tools/GestureHandlerOrchestrator' import ScrollEventManager from '../web/tools/ScrollEventManager'; // The Jest environment is node — provide the minimal DOM surface the handler -// touches (canUseDOM, instanceof HTMLElement, getComputedStyle). +// touches (canUseDOM, instanceof HTMLElement). class FakeHTMLElement { public style: Record = {}; public scrollLeft = 0; @@ -19,10 +19,6 @@ class FakeHTMLElement { private attributes = new Map(); private listeners = new Map void>>(); - public getBoundingClientRect() { - return { x: 0, y: 0, width: 0, height: 0 }; - } - public setAttribute(name: string, value: string): void { this.attributes.set(name, value); } diff --git a/packages/react-native-gesture-handler/src/web/tools/ScrollEventManager.ts b/packages/react-native-gesture-handler/src/web/tools/ScrollEventManager.ts index 203d8f8da7..a8e6067065 100644 --- a/packages/react-native-gesture-handler/src/web/tools/ScrollEventManager.ts +++ b/packages/react-native-gesture-handler/src/web/tools/ScrollEventManager.ts @@ -19,11 +19,12 @@ export default class ScrollEventManager extends EventManager { } protected mapEvent(event: Event): AdaptedEvent { - const rect = this.view.getBoundingClientRect(); - + // Synthesize a pointer glued to the scrolled content - deltas between + // events equal the scrolled distance. getBoundingClientRect would only + // add a constant offset at the cost of a layout read on every event. return { - x: rect.x - this.view.scrollLeft, - y: rect.y - this.view.scrollTop, + x: -this.view.scrollLeft, + y: -this.view.scrollTop, offsetX: -this.view.scrollLeft, offsetY: -this.view.scrollTop, pointerId: -1, From b1e461241ea3ab31308ba36a3ea0643bdcb796e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 17 Aug 2026 09:30:41 +0200 Subject: [PATCH 6/6] Set ScrollDriven once --- .../src/web/handlers/NativeViewGestureHandler.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts index 62985e7416..193d60e7f7 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts @@ -69,6 +69,7 @@ export default class NativeViewGestureHandler extends GestureHandler { super.init(ref, propsRef, actionType, hostDetector); this.shouldCancelWhenOutside = true; + this.isScrollDriven = false; const view = this.delegate.view; @@ -90,6 +91,8 @@ export default class NativeViewGestureHandler extends GestureHandler { this.role = NativeGestureRole.Switch; } } + + this.isScrollDriven = this.role === NativeGestureRole.ScrollView; } public override updateGestureConfig(config: Config): void { @@ -149,7 +152,6 @@ export default class NativeViewGestureHandler extends GestureHandler { } this.scrollDetected = false; - this.isScrollDriven = this.computeIsScrollDriven(); this.begin(); @@ -200,10 +202,6 @@ export default class NativeViewGestureHandler extends GestureHandler { return dx * dx + dy * dy; } - private computeIsScrollDriven(): boolean { - return this.role === NativeGestureRole.ScrollView; - } - protected override onScroll(_event: AdaptedEvent): void { if (!this.isScrollDriven || this.tracker.trackedPointersCount === 0) { return; @@ -471,7 +469,6 @@ export default class NativeViewGestureHandler extends GestureHandler { this.lastEventWasInside = false; this.longPressDetected = false; this.scrollDetected = false; - this.isScrollDriven = false; } public override onDestroy(): void {