From b1889fa59a1dc6d79ce3e9500e919e413d08cc83 Mon Sep 17 00:00:00 2001 From: Elehiggle Date: Fri, 23 Oct 2026 12:34:43 +0200 Subject: [PATCH] fix: refresh Reanimated subscriptions when gestures change Expose committed handler props to the retained NativeEventsManager before updating subscriptions. Reset previous props when effects attach again so Activity visibility changes do not leave stale or duplicate registrations. Add regression coverage for handler replacement, addition and removal, retained children, cleanup, and Activity reconnection. --- .../reanimatedNativeDetector.test.tsx | 220 ++++++++++++++++++ .../v3/detectors/ReanimatedNativeDetector.tsx | 10 +- 2 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 packages/react-native-gesture-handler/src/__tests__/reanimatedNativeDetector.test.tsx diff --git a/packages/react-native-gesture-handler/src/__tests__/reanimatedNativeDetector.test.tsx b/packages/react-native-gesture-handler/src/__tests__/reanimatedNativeDetector.test.tsx new file mode 100644 index 0000000000..bd838c17a9 --- /dev/null +++ b/packages/react-native-gesture-handler/src/__tests__/reanimatedNativeDetector.test.tsx @@ -0,0 +1,220 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import React, { act, Activity, useEffect, useState } from 'react'; +import * as ReactNative from 'react-native'; +import type { ReactTestRenderer } from 'react-test-renderer'; +import { create } from 'react-test-renderer'; + +import type { NativeEventsManager } from '../handlers/gestures/reanimatedWrapper'; +import type { RNGestureHandlerDetectorNativeComponentProps } from '../v3/detectors/HostGestureDetector'; +import { ReanimatedNativeDetector } from '../v3/detectors/ReanimatedNativeDetector'; + +// Keep Reanimated's event diffing; replace only its native registration boundary. +jest.mock('react-native-reanimated/src/WorkletEventHandler', () => ({ + WorkletEventHandler: class { + registered = new Set(); + registerForEvents = jest.fn((tag: number) => this.registered.add(tag)); + unregisterFromEvents = jest.fn((tag: number) => + this.registered.delete(tag) + ); + }, +})); + +jest.mock( + 'react-native-reanimated/src/platform-specific/findHostInstance', + () => ({ + findHostInstance: jest.fn(() => { + throw new Error('Expected the retained detector tag'); + }), + }) +); + +jest.mock( + 'react-native-reanimated/src/platformFunctions/findNodeHandle', + () => ({ + findNodeHandle: jest.fn(() => 42), + }) +); + +jest.mock('../handlers/gestures/reanimatedWrapper', () => ({ + Reanimated: jest.requireActual<{ NativeEventsManager: NativeEventsManager }>( + 'react-native-reanimated/src/createAnimatedComponent/NativeEventsManager' + ), +})); + +type RecordedHandler = { + registered: Set; + registerForEvents: jest.Mock; + unregisterFromEvents: jest.Mock; +}; + +// The wrapper mock always supplies NativeEventsManager. +const Detector = ReanimatedNativeDetector as NonNullable< + typeof ReanimatedNativeDetector +>; + +const { WorkletEventHandler } = jest.requireMock<{ + WorkletEventHandler: new () => RecordedHandler; +}>('react-native-reanimated/src/WorkletEventHandler'); + +type WorkletProp = + RNGestureHandlerDetectorNativeComponentProps['onGestureHandlerReanimatedEvent']; + +const eventProp = (handler: RecordedHandler) => + ({ workletEventHandler: handler }) as unknown as WorkletProp; + +describe('ReanimatedNativeDetector event registrations', () => { + let findNodeHandle: jest.SpyInstance; + + beforeEach(() => { + findNodeHandle = jest + .spyOn( + jest.requireActual('react-native'), + 'findNodeHandle' + ) + .mockReturnValue(42); + }); + + afterEach(() => { + findNodeHandle.mockRestore(); + }); + + test('replaces handlers while preserving the host and child state', () => { + const first = new WorkletEventHandler(); + const second = new WorkletEventHandler(); + const third = new WorkletEventHandler(); + const mounted = jest.fn(); + const unmounted = jest.fn(); + + function Child() { + const [count, setCount] = useState(0); + useEffect(() => { + mounted(); + return () => { + unmounted(); + }; + }, []); + return ( + setCount(count + 1)}> + {count} + + ); + } + + const tree = (handler: RecordedHandler) => ( + + + + ); + const view = render(tree(first)); + const host = view.getByTestId('detector'); + fireEvent.press(view.getByTestId('child')); + expect([...first.registered]).toEqual([42]); + + for (const handler of [second, first, second, third]) { + view.rerender(tree(handler)); + for (const candidate of [first, second, third]) { + expect([...candidate.registered]).toEqual( + candidate === handler ? [42] : [] + ); + } + expect(view.getByTestId('detector')).toBe(host); + expect(view.getByText('1')).toBeDefined(); + expect(mounted).toHaveBeenCalledTimes(1); + expect(unmounted).not.toHaveBeenCalled(); + } + + view.unmount(); + expect([...third.registered]).toEqual([]); + expect(third.unregisterFromEvents).toHaveBeenCalledTimes(1); + expect(unmounted).toHaveBeenCalledTimes(1); + }); + + test('adds and removes a handler on the retained host', () => { + const handler = new WorkletEventHandler(); + const view = render(); + view.rerender( + + ); + expect([...handler.registered]).toEqual([42]); + view.rerender(); + expect([...handler.registered]).toEqual([]); + expect(handler.unregisterFromEvents).toHaveBeenCalledTimes(1); + view.unmount(); + expect(handler.unregisterFromEvents).toHaveBeenCalledTimes(1); + }); + + test('keeps an unchanged handler registered until unmount', () => { + const handler = new WorkletEventHandler(); + const tree = (label: string) => ( + + ); + const view = render(tree('first')); + view.rerender(tree('second')); + expect(handler.registerForEvents).toHaveBeenCalledTimes(1); + expect(handler.unregisterFromEvents).not.toHaveBeenCalled(); + view.unmount(); + expect(handler.unregisterFromEvents).toHaveBeenCalledTimes(1); + expect([...handler.registered]).toEqual([]); + }); + + test.each(['hidden', 'visible'] as const)( + 'attaches the current handler once when an initially %s Activity is revealed', + async (initialMode) => { + const first = new WorkletEventHandler(); + const second = new WorkletEventHandler(); + const tree = (mode: 'hidden' | 'visible', handler: RecordedHandler) => ( + + + + ); + const options = { + createNodeMock: () => null, + unstable_isConcurrent: true, + }; + let renderer!: ReactTestRenderer; + await act(() => { + renderer = create(tree(initialMode, first), options); + return Promise.resolve(); + }); + expect(first.registerForEvents).toHaveBeenCalledTimes( + initialMode === 'visible' ? 1 : 0 + ); + + await act(() => Promise.resolve(renderer.update(tree('hidden', first)))); + expect([...first.registered]).toEqual([]); + await act(() => Promise.resolve(renderer.update(tree('hidden', second)))); + expect(second.registerForEvents).not.toHaveBeenCalled(); + + await act(() => + Promise.resolve(renderer.update(tree('visible', second))) + ); + expect([...first.registered]).toEqual([]); + expect([...second.registered]).toEqual([42]); + expect(second.registerForEvents).toHaveBeenCalledTimes(1); + + await act(() => Promise.resolve(renderer.unmount())); + expect([...first.registered]).toEqual([]); + expect([...second.registered]).toEqual([]); + expect(second.unregisterFromEvents).toHaveBeenCalledTimes(1); + } + ); +}); diff --git a/packages/react-native-gesture-handler/src/v3/detectors/ReanimatedNativeDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/ReanimatedNativeDetector.tsx index ffcd9ffd1d..ae28998792 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/ReanimatedNativeDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/ReanimatedNativeDetector.tsx @@ -60,14 +60,21 @@ function LeanReanimatedNativeDetector( ] ); + const committedProps = useRef(reaProps); + useEffect(() => { + committedProps.current = reaProps; + prevProps.current = null; const nativeTag = findNodeHandle(viewRef.current) ?? -1; // @ts-expect-error Reanimated expects __nativeTag to be present on the ref viewRef.__nativeTag = nativeTag; // @ts-expect-error NativeEventsManager should be defined here, if it isn't, we should // go the fallback way and use Reanimated's createAnimatedComponent eventManager.current = new NativeEventsManagerImpl({ - props: reaProps, + // NativeEventsManager reads this object again when handlers change. + get props() { + return committedProps.current; + }, _componentRef: viewRef, _componentViewTag: nativeTag, getComponentViewTag: () => nativeTag, @@ -80,6 +87,7 @@ function LeanReanimatedNativeDetector( }, []); useEffect(() => { + committedProps.current = reaProps; if (prevProps.current) { eventManager.current?.updateEvents(prevProps.current); }