diff --git a/src/adapters/TouchablePreview.test.tsx b/src/adapters/TouchablePreview.test.tsx new file mode 100644 index 0000000000..80d210a935 --- /dev/null +++ b/src/adapters/TouchablePreview.test.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; +import * as PropTypes from 'prop-types'; +import { Platform, TouchableNativeFeedback, TouchableWithoutFeedback } from 'react-native'; +import { TouchablePreview } from './TouchablePreview'; + +describe('TouchablePreview', () => { + const originalPlatform = Platform.OS; + + afterEach(() => { + Platform.OS = originalPlatform; + TouchablePreview.peeking = false; + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + it('accepts any React element type as the touchable component', () => { + const MemoizedTouchable = React.memo(() => null); + const consoleError = jest.spyOn(console, 'error').mockImplementation(); + + PropTypes.checkPropTypes( + TouchablePreview.propTypes, + { touchableComponent: MemoizedTouchable }, + 'prop', + 'TouchablePreview' + ); + + expect(consoleError).not.toHaveBeenCalled(); + }); + + it('falls back from TouchableNativeFeedback on iOS', () => { + Platform.OS = 'ios'; + const uut = new TouchablePreview({ touchableComponent: TouchableNativeFeedback }); + + expect(uut.render().type).toBe(TouchableWithoutFeedback); + }); + + it('clears pending preview work when unmounted', () => { + jest.useFakeTimers(); + const onPeekOut = jest.fn(); + const uut = new TouchablePreview({ onPeekOut }); + uut.onTouchStart({ nativeEvent: { timestamp: 0 } } as any); + uut.onTouchMove({ nativeEvent: { force: 0.5, timestamp: 400 } } as any); + + (uut as any).componentWillUnmount(); + jest.runOnlyPendingTimers(); + + expect(onPeekOut).not.toHaveBeenCalled(); + expect(TouchablePreview.peeking).toBe(false); + }); + + it('does not clear another instance\'s active peek when unmounted', () => { + jest.useFakeTimers(); + const owner = new TouchablePreview({}); + const nonOwner = new TouchablePreview({}); + owner.onTouchStart({ nativeEvent: { timestamp: 0 } } as any); + owner.onTouchMove({ nativeEvent: { force: 0.5, timestamp: 400 } } as any); + + nonOwner.componentWillUnmount(); + + expect(TouchablePreview.peeking).toBe(true); + + owner.componentWillUnmount(); + expect(TouchablePreview.peeking).toBe(false); + }); +}); diff --git a/src/adapters/TouchablePreview.tsx b/src/adapters/TouchablePreview.tsx index e515ffd489..7a8715b6d1 100644 --- a/src/adapters/TouchablePreview.tsx +++ b/src/adapters/TouchablePreview.tsx @@ -4,8 +4,6 @@ import { View, Platform, findNodeHandle, - TouchableOpacity, - TouchableHighlight, TouchableNativeFeedback, TouchableWithoutFeedback, GestureResponderEvent, @@ -21,12 +19,7 @@ interface GestureResponderEventWithForce extends NativeSyntheticEvent void; onPressIn?: (payload: { reactTag: number | null }) => void; onPeekIn?: () => void; @@ -40,7 +33,7 @@ const PREVIEW_TIMEOUT = 1250; export class TouchablePreview extends React.PureComponent { static propTypes = { children: PropTypes.node, - touchableComponent: PropTypes.func, + touchableComponent: PropTypes.elementType, onPress: PropTypes.func, onPressIn: PropTypes.func, onPeekIn: PropTypes.func, @@ -53,10 +46,17 @@ export class TouchablePreview extends React.PureComponent { }; static peeking = false; + private static peekOwner: TouchablePreview | undefined; private timeout: number | undefined; private touchStartedAt: number = 0; private onRef = React.createRef(); + + componentWillUnmount() { + clearTimeout(this.timeout); + this.releasePeekOwnership(); + } + onPress = () => { const { onPress } = this.props; @@ -96,6 +96,7 @@ export class TouchablePreview extends React.PureComponent { if (force > PREVIEW_MIN_FORCE && diff > PREVIEW_DELAY) { TouchablePreview.peeking = true; + TouchablePreview.peekOwner = this; if (typeof this.props.onPeekIn === 'function') { this.props.onPeekIn(); @@ -107,7 +108,7 @@ export class TouchablePreview extends React.PureComponent { onTouchEnd = () => { clearTimeout(this.timeout); - TouchablePreview.peeking = false; + this.releasePeekOwnership(); if (typeof this.props.onPeekOut === 'function') { this.props.onPeekOut(); @@ -116,21 +117,17 @@ export class TouchablePreview extends React.PureComponent { render() { const { children, touchableComponent, ...props } = this.props; + const requestedTouchable = touchableComponent ?? TouchableWithoutFeedback; // Default to TouchableWithoutFeedback for iOS if set to TouchableNativeFeedback const Touchable = - Platform.OS === 'ios' && touchableComponent instanceof TouchableNativeFeedback + Platform.OS === 'ios' && requestedTouchable === TouchableNativeFeedback ? TouchableWithoutFeedback - : (touchableComponent as React.Component); + : requestedTouchable; // Wrap component with Touchable for handling platform touches // and a single react View for detecting force and timing. return ( - /** - * @TODO (Jin Shin 25 June 2020) - * Ignoring this for now so that it builds. - */ - // @ts-ignore { ); } + + private releasePeekOwnership() { + if (TouchablePreview.peekOwner === this) { + TouchablePreview.peekOwner = undefined; + TouchablePreview.peeking = false; + } + } }