diff --git a/src/__tests__/fireEvent-textInput.test.tsx b/src/__tests__/fireEvent-textInput.test.tsx new file mode 100644 index 000000000..f36a68f4b --- /dev/null +++ b/src/__tests__/fireEvent-textInput.test.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import { Text, TextInput, TextInputProps } from 'react-native'; +import { render, fireEvent } from '..'; + +function WrappedTextInput(props: TextInputProps) { + return ; +} + +function DoubleWrappedTextInput(props: TextInputProps) { + return ; +} + +const layoutEvent = { nativeEvent: { layout: { width: 100, height: 100 } } }; + +test('should fire only non-touch-related events on non-editable TextInput', () => { + const onFocus = jest.fn(); + const onChangeText = jest.fn(); + const onSubmitEditing = jest.fn(); + const onLayout = jest.fn(); + + const view = render( + + ); + + const subject = view.getByTestId('subject'); + fireEvent(subject, 'focus'); + fireEvent.changeText(subject, 'Text'); + fireEvent(subject, 'submitEditing', { nativeEvent: { text: 'Text' } }); + fireEvent(subject, 'layout', layoutEvent); + + expect(onFocus).not.toHaveBeenCalled(); + expect(onChangeText).not.toHaveBeenCalled(); + expect(onSubmitEditing).not.toHaveBeenCalled(); + expect(onLayout).toHaveBeenCalledWith(layoutEvent); +}); + +test('should fire only non-touch-related events on non-editable TextInput with nested Text', () => { + const onFocus = jest.fn(); + const onChangeText = jest.fn(); + const onSubmitEditing = jest.fn(); + const onLayout = jest.fn(); + + const view = render( + + Nested Text + + ); + + const subject = view.getByText('Nested Text'); + fireEvent(subject, 'focus'); + fireEvent.changeText(subject, 'Text'); + fireEvent(subject, 'submitEditing', { nativeEvent: { text: 'Text' } }); + fireEvent(subject, 'layout', layoutEvent); + + expect(onFocus).not.toHaveBeenCalled(); + expect(onChangeText).not.toHaveBeenCalled(); + expect(onSubmitEditing).not.toHaveBeenCalled(); + expect(onLayout).toHaveBeenCalledWith(layoutEvent); +}); + +/** + * Historically there were problems with custom TextInput wrappers, as they + * could creat a hierarchy of three or more composite text input views with + * very similar event props. + * + * Typical hierarchy would be: + * - User composite TextInput + * - UI library composite TextInput + * - RN composite TextInput + * - RN host TextInput + * + * Previous implementation of fireEvent only checked `editable` prop for + * RN TextInputs, both host & composite but did not check on the UI library or + * user composite TextInput level, hence invoking the event handlers that + * should be blocked by `editable={false}` prop. + */ +test('should fire only non-touch-related events on non-editable wrapped TextInput', () => { + const onFocus = jest.fn(); + const onChangeText = jest.fn(); + const onSubmitEditing = jest.fn(); + const onLayout = jest.fn(); + + const view = render( + + ); + + const subject = view.getByTestId('subject'); + fireEvent(subject, 'focus'); + fireEvent.changeText(subject, 'Text'); + fireEvent(subject, 'submitEditing', { nativeEvent: { text: 'Text' } }); + fireEvent(subject, 'layout', layoutEvent); + + expect(onFocus).not.toHaveBeenCalled(); + expect(onChangeText).not.toHaveBeenCalled(); + expect(onSubmitEditing).not.toHaveBeenCalled(); + expect(onLayout).toHaveBeenCalledWith(layoutEvent); +}); + +/** + * Ditto testing for even deeper hierarchy of TextInput wrappers. + */ +test('should fire only non-touch-related events on non-editable double wrapped TextInput', () => { + const onFocus = jest.fn(); + const onChangeText = jest.fn(); + const onSubmitEditing = jest.fn(); + const onLayout = jest.fn(); + + const view = render( + + ); + + const subject = view.getByTestId('subject'); + fireEvent(subject, 'focus'); + fireEvent.changeText(subject, 'Text'); + fireEvent(subject, 'submitEditing', { nativeEvent: { text: 'Text' } }); + fireEvent(subject, 'layout', layoutEvent); + + expect(onFocus).not.toHaveBeenCalled(); + expect(onChangeText).not.toHaveBeenCalled(); + expect(onSubmitEditing).not.toHaveBeenCalled(); + expect(onLayout).toHaveBeenCalledWith(layoutEvent); +}); diff --git a/src/__tests__/fireEvent.test.tsx b/src/__tests__/fireEvent.test.tsx index 0da46994e..ce1d66326 100644 --- a/src/__tests__/fireEvent.test.tsx +++ b/src/__tests__/fireEvent.test.tsx @@ -216,44 +216,6 @@ test('should not fire on disabled Pressable', () => { expect(handlePress).not.toHaveBeenCalled(); }); -test('should not fire on non-editable TextInput', () => { - const testID = 'my-text-input'; - const onChangeTextMock = jest.fn(); - const NEW_TEXT = 'New text'; - - const { getByTestId } = render( - - ); - - fireEvent.changeText(getByTestId(testID), NEW_TEXT); - expect(onChangeTextMock).not.toHaveBeenCalled(); -}); - -test('should not fire on non-editable TextInput with nested Text', () => { - const placeholder = 'Test placeholder'; - const onChangeTextMock = jest.fn(); - const NEW_TEXT = 'New text'; - - const { getByPlaceholderText } = render( - - - Test text - - - ); - - fireEvent.changeText(getByPlaceholderText(placeholder), NEW_TEXT); - expect(onChangeTextMock).not.toHaveBeenCalled(); -}); - test('should not fire inside View with pointerEvents="none"', () => { const onPress = jest.fn(); const screen = render( diff --git a/src/fireEvent.ts b/src/fireEvent.ts index f32ef6a37..c9ddac132 100644 --- a/src/fireEvent.ts +++ b/src/fireEvent.ts @@ -1,22 +1,13 @@ import { ReactTestInstance } from 'react-test-renderer'; -import { TextInput } from 'react-native'; import act from './act'; import { getHostParent, isHostElement } from './helpers/component-tree'; -import { filterNodeByType } from './helpers/filterNodeByType'; import { getHostComponentNames } from './helpers/host-component-names'; type EventHandler = (...args: unknown[]) => unknown; -function isTextInput(element: ReactTestInstance) { - // We have to test if the element type is either the `TextInput` component - // (for composite component) or the string "TextInput" (for host component) - // All queries return host components but since fireEvent bubbles up - // it would trigger the parent prop without the composite component check. - return ( - filterNodeByType(element, TextInput) || - filterNodeByType(element, getHostComponentNames().textInput) - ); -} +const isHostTextInput = (element?: ReactTestInstance) => { + return element?.type === getHostComponentNames().textInput; +}; function isTouchResponder(element: ReactTestInstance) { if (!isHostElement(element)) { @@ -24,7 +15,7 @@ function isTouchResponder(element: ReactTestInstance) { } return ( - Boolean(element.props.onStartShouldSetResponder) || isTextInput(element) + Boolean(element.props.onStartShouldSetResponder) || isHostTextInput(element) ); } @@ -57,13 +48,23 @@ function isTouchEvent(eventName: string) { return touchEventNames.includes(eventName); } +// Experimentally checked which events are called on non-editable TextInput +const textInputEventsIgnoringEditableProp = [ + 'contentSizeChange', + 'layout', + 'scroll', +]; + function isEventEnabled( element: ReactTestInstance, eventName: string, nearestTouchResponder?: ReactTestInstance ) { - if (isTextInput(element)) { - return element.props.editable !== false; + if (isHostTextInput(nearestTouchResponder)) { + return ( + nearestTouchResponder?.props.editable !== false || + textInputEventsIgnoringEditableProp.includes(eventName) + ); } if (isTouchEvent(eventName) && !isPointerEventEnabled(element)) {