Skip to content

Commit a110fd8

Browse files
fix: non-editable wrapped TextInput events (#1385)
Co-authored-by: Maciej Jastrzebski <[email protected]>
1 parent 674b2f9 commit a110fd8

File tree

3 files changed

+166
-53
lines changed

3 files changed

+166
-53
lines changed
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import React from 'react';
2+
import { Text, TextInput, TextInputProps } from 'react-native';
3+
import { render, fireEvent } from '..';
4+
5+
function WrappedTextInput(props: TextInputProps) {
6+
return <TextInput {...props} />;
7+
}
8+
9+
function DoubleWrappedTextInput(props: TextInputProps) {
10+
return <WrappedTextInput {...props} />;
11+
}
12+
13+
const layoutEvent = { nativeEvent: { layout: { width: 100, height: 100 } } };
14+
15+
test('should fire only non-touch-related events on non-editable TextInput', () => {
16+
const onFocus = jest.fn();
17+
const onChangeText = jest.fn();
18+
const onSubmitEditing = jest.fn();
19+
const onLayout = jest.fn();
20+
21+
const view = render(
22+
<TextInput
23+
editable={false}
24+
testID="subject"
25+
onFocus={onFocus}
26+
onChangeText={onChangeText}
27+
onSubmitEditing={onSubmitEditing}
28+
onLayout={onLayout}
29+
/>
30+
);
31+
32+
const subject = view.getByTestId('subject');
33+
fireEvent(subject, 'focus');
34+
fireEvent.changeText(subject, 'Text');
35+
fireEvent(subject, 'submitEditing', { nativeEvent: { text: 'Text' } });
36+
fireEvent(subject, 'layout', layoutEvent);
37+
38+
expect(onFocus).not.toHaveBeenCalled();
39+
expect(onChangeText).not.toHaveBeenCalled();
40+
expect(onSubmitEditing).not.toHaveBeenCalled();
41+
expect(onLayout).toHaveBeenCalledWith(layoutEvent);
42+
});
43+
44+
test('should fire only non-touch-related events on non-editable TextInput with nested Text', () => {
45+
const onFocus = jest.fn();
46+
const onChangeText = jest.fn();
47+
const onSubmitEditing = jest.fn();
48+
const onLayout = jest.fn();
49+
50+
const view = render(
51+
<TextInput
52+
editable={false}
53+
testID="subject"
54+
onFocus={onFocus}
55+
onChangeText={onChangeText}
56+
onSubmitEditing={onSubmitEditing}
57+
onLayout={onLayout}
58+
>
59+
<Text>Nested Text</Text>
60+
</TextInput>
61+
);
62+
63+
const subject = view.getByText('Nested Text');
64+
fireEvent(subject, 'focus');
65+
fireEvent.changeText(subject, 'Text');
66+
fireEvent(subject, 'submitEditing', { nativeEvent: { text: 'Text' } });
67+
fireEvent(subject, 'layout', layoutEvent);
68+
69+
expect(onFocus).not.toHaveBeenCalled();
70+
expect(onChangeText).not.toHaveBeenCalled();
71+
expect(onSubmitEditing).not.toHaveBeenCalled();
72+
expect(onLayout).toHaveBeenCalledWith(layoutEvent);
73+
});
74+
75+
/**
76+
* Historically there were problems with custom TextInput wrappers, as they
77+
* could creat a hierarchy of three or more composite text input views with
78+
* very similar event props.
79+
*
80+
* Typical hierarchy would be:
81+
* - User composite TextInput
82+
* - UI library composite TextInput
83+
* - RN composite TextInput
84+
* - RN host TextInput
85+
*
86+
* Previous implementation of fireEvent only checked `editable` prop for
87+
* RN TextInputs, both host & composite but did not check on the UI library or
88+
* user composite TextInput level, hence invoking the event handlers that
89+
* should be blocked by `editable={false}` prop.
90+
*/
91+
test('should fire only non-touch-related events on non-editable wrapped TextInput', () => {
92+
const onFocus = jest.fn();
93+
const onChangeText = jest.fn();
94+
const onSubmitEditing = jest.fn();
95+
const onLayout = jest.fn();
96+
97+
const view = render(
98+
<WrappedTextInput
99+
editable={false}
100+
testID="subject"
101+
onFocus={onFocus}
102+
onChangeText={onChangeText}
103+
onSubmitEditing={onSubmitEditing}
104+
onLayout={onLayout}
105+
/>
106+
);
107+
108+
const subject = view.getByTestId('subject');
109+
fireEvent(subject, 'focus');
110+
fireEvent.changeText(subject, 'Text');
111+
fireEvent(subject, 'submitEditing', { nativeEvent: { text: 'Text' } });
112+
fireEvent(subject, 'layout', layoutEvent);
113+
114+
expect(onFocus).not.toHaveBeenCalled();
115+
expect(onChangeText).not.toHaveBeenCalled();
116+
expect(onSubmitEditing).not.toHaveBeenCalled();
117+
expect(onLayout).toHaveBeenCalledWith(layoutEvent);
118+
});
119+
120+
/**
121+
* Ditto testing for even deeper hierarchy of TextInput wrappers.
122+
*/
123+
test('should fire only non-touch-related events on non-editable double wrapped TextInput', () => {
124+
const onFocus = jest.fn();
125+
const onChangeText = jest.fn();
126+
const onSubmitEditing = jest.fn();
127+
const onLayout = jest.fn();
128+
129+
const view = render(
130+
<DoubleWrappedTextInput
131+
editable={false}
132+
testID="subject"
133+
onFocus={onFocus}
134+
onChangeText={onChangeText}
135+
onSubmitEditing={onSubmitEditing}
136+
onLayout={onLayout}
137+
/>
138+
);
139+
140+
const subject = view.getByTestId('subject');
141+
fireEvent(subject, 'focus');
142+
fireEvent.changeText(subject, 'Text');
143+
fireEvent(subject, 'submitEditing', { nativeEvent: { text: 'Text' } });
144+
fireEvent(subject, 'layout', layoutEvent);
145+
146+
expect(onFocus).not.toHaveBeenCalled();
147+
expect(onChangeText).not.toHaveBeenCalled();
148+
expect(onSubmitEditing).not.toHaveBeenCalled();
149+
expect(onLayout).toHaveBeenCalledWith(layoutEvent);
150+
});

src/__tests__/fireEvent.test.tsx

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -216,44 +216,6 @@ test('should not fire on disabled Pressable', () => {
216216
expect(handlePress).not.toHaveBeenCalled();
217217
});
218218

219-
test('should not fire on non-editable TextInput', () => {
220-
const testID = 'my-text-input';
221-
const onChangeTextMock = jest.fn();
222-
const NEW_TEXT = 'New text';
223-
224-
const { getByTestId } = render(
225-
<TextInput
226-
editable={false}
227-
testID={testID}
228-
onChangeText={onChangeTextMock}
229-
/>
230-
);
231-
232-
fireEvent.changeText(getByTestId(testID), NEW_TEXT);
233-
expect(onChangeTextMock).not.toHaveBeenCalled();
234-
});
235-
236-
test('should not fire on non-editable TextInput with nested Text', () => {
237-
const placeholder = 'Test placeholder';
238-
const onChangeTextMock = jest.fn();
239-
const NEW_TEXT = 'New text';
240-
241-
const { getByPlaceholderText } = render(
242-
<View>
243-
<TextInput
244-
editable={false}
245-
placeholder={placeholder}
246-
onChangeText={onChangeTextMock}
247-
>
248-
<Text>Test text</Text>
249-
</TextInput>
250-
</View>
251-
);
252-
253-
fireEvent.changeText(getByPlaceholderText(placeholder), NEW_TEXT);
254-
expect(onChangeTextMock).not.toHaveBeenCalled();
255-
});
256-
257219
test('should not fire inside View with pointerEvents="none"', () => {
258220
const onPress = jest.fn();
259221
const screen = render(

src/fireEvent.ts

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,21 @@
11
import { ReactTestInstance } from 'react-test-renderer';
2-
import { TextInput } from 'react-native';
32
import act from './act';
43
import { getHostParent, isHostElement } from './helpers/component-tree';
5-
import { filterNodeByType } from './helpers/filterNodeByType';
64
import { getHostComponentNames } from './helpers/host-component-names';
75

86
type EventHandler = (...args: unknown[]) => unknown;
97

10-
function isTextInput(element: ReactTestInstance) {
11-
// We have to test if the element type is either the `TextInput` component
12-
// (for composite component) or the string "TextInput" (for host component)
13-
// All queries return host components but since fireEvent bubbles up
14-
// it would trigger the parent prop without the composite component check.
15-
return (
16-
filterNodeByType(element, TextInput) ||
17-
filterNodeByType(element, getHostComponentNames().textInput)
18-
);
19-
}
8+
const isHostTextInput = (element?: ReactTestInstance) => {
9+
return element?.type === getHostComponentNames().textInput;
10+
};
2011

2112
function isTouchResponder(element: ReactTestInstance) {
2213
if (!isHostElement(element)) {
2314
return false;
2415
}
2516

2617
return (
27-
Boolean(element.props.onStartShouldSetResponder) || isTextInput(element)
18+
Boolean(element.props.onStartShouldSetResponder) || isHostTextInput(element)
2819
);
2920
}
3021

@@ -57,13 +48,23 @@ function isTouchEvent(eventName: string) {
5748
return touchEventNames.includes(eventName);
5849
}
5950

51+
// Experimentally checked which events are called on non-editable TextInput
52+
const textInputEventsIgnoringEditableProp = [
53+
'contentSizeChange',
54+
'layout',
55+
'scroll',
56+
];
57+
6058
function isEventEnabled(
6159
element: ReactTestInstance,
6260
eventName: string,
6361
nearestTouchResponder?: ReactTestInstance
6462
) {
65-
if (isTextInput(element)) {
66-
return element.props.editable !== false;
63+
if (isHostTextInput(nearestTouchResponder)) {
64+
return (
65+
nearestTouchResponder?.props.editable !== false ||
66+
textInputEventsIgnoringEditableProp.includes(eventName)
67+
);
6768
}
6869

6970
if (isTouchEvent(eventName) && !isPointerEventEnabled(element)) {

0 commit comments

Comments
 (0)