From 5f819223212a29e5778695e6b577c77a9dfd3d16 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Sun, 25 Aug 2024 23:07:39 +0200 Subject: [PATCH 1/3] feat: basic text input state --- src/__tests__/fire-event.test.tsx | 11 +++++++++++ src/cleanup.ts | 9 ++++++--- src/fire-event.ts | 5 +++++ src/helpers/text-input.ts | 8 +++++++- src/native-state.ts | 17 +++++++++++++++++ src/render.tsx | 3 +++ src/user-event/type/__tests__/type.test.tsx | 12 ++++++++++++ src/user-event/type/type.ts | 2 ++ 8 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 src/native-state.ts diff --git a/src/__tests__/fire-event.test.tsx b/src/__tests__/fire-event.test.tsx index c0ac2a9f6..b3f28ad8c 100644 --- a/src/__tests__/fire-event.test.tsx +++ b/src/__tests__/fire-event.test.tsx @@ -9,6 +9,7 @@ import { View, } from 'react-native'; import { fireEvent, render, screen } from '..'; +import '../matchers/extend-expect'; type OnPressComponentProps = { onPress: () => void; @@ -443,4 +444,14 @@ describe('native events', () => { fireEvent(screen.getByTestId('test-id'), 'onMomentumScrollEnd'); expect(onMomentumScrollEndSpy).toHaveBeenCalled(); }); + + it('sets native state value for unmanaged text inputs', () => { + render(); + + const input = screen.getByTestId('input'); + expect(input).toHaveDisplayValue(''); + + fireEvent.changeText(input, 'abc'); + expect(input).toHaveDisplayValue('abc'); + }); }); diff --git a/src/cleanup.ts b/src/cleanup.ts index 681d22a47..c7ee13cb5 100644 --- a/src/cleanup.ts +++ b/src/cleanup.ts @@ -1,11 +1,14 @@ -import * as React from 'react'; +import { clearNativeState } from './native-state'; import { clearRenderResult } from './screen'; -type CleanUpFunction = (nextElement?: React.ReactElement) => void; -let cleanupQueue = new Set(); +type CleanUpFunction = () => void; + +const cleanupQueue = new Set(); export default function cleanup() { + clearNativeState(); clearRenderResult(); + cleanupQueue.forEach((fn) => fn()); cleanupQueue.clear(); } diff --git a/src/fire-event.ts b/src/fire-event.ts index 6859857ff..35aa761fc 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -12,6 +12,7 @@ import { isHostTextInput } from './helpers/host-component-names'; import { isPointerEventEnabled } from './helpers/pointer-events'; import { isTextInputEditable } from './helpers/text-input'; import { StringWithAutocomplete } from './types'; +import { nativeState } from './native-state'; type EventHandler = (...args: unknown[]) => unknown; @@ -120,6 +121,10 @@ type EventName = StringWithAutocomplete< >; function fireEvent(element: ReactTestInstance, eventName: EventName, ...data: unknown[]) { + if (eventName === 'changeText' && isHostTextInput(element) && typeof data[0] === 'string') { + nativeState?.elementValues.set(element, data[0]); + } + const handler = findEventHandler(element, eventName); if (!handler) { return; diff --git a/src/helpers/text-input.ts b/src/helpers/text-input.ts index 1a1158369..eaa10f7b9 100644 --- a/src/helpers/text-input.ts +++ b/src/helpers/text-input.ts @@ -1,4 +1,5 @@ import { ReactTestInstance } from 'react-test-renderer'; +import { nativeState } from '../native-state'; import { isHostTextInput } from './host-component-names'; export function isTextInputEditable(element: ReactTestInstance) { @@ -14,5 +15,10 @@ export function getTextInputValue(element: ReactTestInstance) { throw new Error(`Element is not a "TextInput", but it has type "${element.type}".`); } - return element.props.value ?? element.props.defaultValue; + return ( + element.props.value ?? + nativeState?.elementValues.get(element) ?? + element.props.defaultValue ?? + '' + ); } diff --git a/src/native-state.ts b/src/native-state.ts new file mode 100644 index 000000000..a3f41bc5d --- /dev/null +++ b/src/native-state.ts @@ -0,0 +1,17 @@ +import { ReactTestInstance } from 'react-test-renderer'; + +export type NativeState = { + elementValues: WeakMap; +}; + +export let nativeState: NativeState | null = null; + +export function initNativeState(): void { + nativeState = { + elementValues: new WeakMap(), + }; +} + +export function clearNativeState(): void { + nativeState = null; +} diff --git a/src/render.tsx b/src/render.tsx index 69d27b53e..dc9335b49 100644 --- a/src/render.tsx +++ b/src/render.tsx @@ -12,6 +12,7 @@ import { validateStringsRenderedWithinText } from './helpers/string-validation'; import { renderWithAct } from './render-act'; import { setRenderResult } from './screen'; import { getQueriesForElement } from './within'; +import { initNativeState } from './native-state'; export interface RenderOptions { wrapper?: React.ComponentType; @@ -127,6 +128,8 @@ function buildRenderResult( }); setRenderResult(result); + initNativeState(); + return result; } diff --git a/src/user-event/type/__tests__/type.test.tsx b/src/user-event/type/__tests__/type.test.tsx index 0ded57f86..c03ff2c1a 100644 --- a/src/user-event/type/__tests__/type.test.tsx +++ b/src/user-event/type/__tests__/type.test.tsx @@ -3,6 +3,7 @@ import { TextInput, TextInputProps, View } from 'react-native'; import { createEventLogger, getEventsNames, lastEventPayload } from '../../../test-utils'; import { render, screen } from '../../..'; import { userEvent } from '../..'; +import '../../../matchers/extend-expect'; beforeEach(() => { jest.useRealTimers(); @@ -372,4 +373,15 @@ describe('type()', () => { }, }); }); + + it('supports value of unmanaged text inputs', async () => { + render(); + + const user = userEvent.setup(); + const input = screen.getByTestId('input'); + expect(input).toHaveDisplayValue(''); + + await user.type(input, 'abc'); + expect(input).toHaveDisplayValue('abc'); + }); }); diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts index dbf351fee..0d90dbb21 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -1,5 +1,6 @@ import { ReactTestInstance } from 'react-test-renderer'; import { isHostTextInput } from '../../helpers/host-component-names'; +import { nativeState } from '../../native-state'; import { EventBuilder } from '../event-builder'; import { ErrorWithStack } from '../../helpers/errors'; import { isTextInputEditable } from '../../helpers/text-input'; @@ -96,6 +97,7 @@ export async function emitTypingEvents( dispatchEvent(element, 'change', EventBuilder.TextInput.change(text)); dispatchEvent(element, 'changeText', text); + nativeState?.elementValues.set(element, text); const selectionRange = { start: text.length, From ce6efa148521ef215d5d67d4e2bb2593506566f0 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Tue, 27 Aug 2024 21:39:59 +0200 Subject: [PATCH 2/3] refactor: self code review --- src/fire-event.ts | 15 ++++++++++++--- src/native-state.ts | 5 +++++ src/user-event/__tests__/clear.test.tsx | 13 +++++++++++++ src/user-event/__tests__/paste.test.tsx | 12 ++++++++++++ src/user-event/paste.ts | 2 ++ src/user-event/type/__tests__/type.test.tsx | 2 +- src/user-event/type/type.ts | 2 +- 7 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/fire-event.ts b/src/fire-event.ts index 35aa761fc..849c01eea 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -121,9 +121,7 @@ type EventName = StringWithAutocomplete< >; function fireEvent(element: ReactTestInstance, eventName: EventName, ...data: unknown[]) { - if (eventName === 'changeText' && isHostTextInput(element) && typeof data[0] === 'string') { - nativeState?.elementValues.set(element, data[0]); - } + setNativeStateIfNeeded(element, eventName, data[0]); const handler = findEventHandler(element, eventName); if (!handler) { @@ -148,3 +146,14 @@ fireEvent.scroll = (element: ReactTestInstance, ...data: unknown[]) => fireEvent(element, 'scroll', ...data); export default fireEvent; + +function setNativeStateIfNeeded(element: ReactTestInstance, eventName: string, value: unknown) { + if ( + eventName === 'changeText' && + typeof value === 'string' && + isHostTextInput(element) && + isTextInputEditable(element) + ) { + nativeState?.elementValues.set(element, value); + } +} diff --git a/src/native-state.ts b/src/native-state.ts index a3f41bc5d..48793fd31 100644 --- a/src/native-state.ts +++ b/src/native-state.ts @@ -1,5 +1,10 @@ import { ReactTestInstance } from 'react-test-renderer'; +/** + * Simulated native state for unmanaged controls. + * + * Values from `value` props (managed controls) should take precedence over these values. + */ export type NativeState = { elementValues: WeakMap; }; diff --git a/src/user-event/__tests__/clear.test.tsx b/src/user-event/__tests__/clear.test.tsx index b0fe11c34..27d713a1d 100644 --- a/src/user-event/__tests__/clear.test.tsx +++ b/src/user-event/__tests__/clear.test.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { TextInput, TextInputProps, View } from 'react-native'; import { createEventLogger, getEventsNames } from '../../test-utils'; import { render, userEvent, screen } from '../..'; +import '../../matchers/extend-expect'; beforeEach(() => { jest.useRealTimers(); @@ -205,4 +206,16 @@ describe('clear()', () => { await user.clear(screen.getByTestId('input')); expect(parentHandler).not.toHaveBeenCalled(); }); + + it('sets native state value for unmanaged text inputs', async () => { + render(); + + const user = userEvent.setup(); + const input = screen.getByTestId('input'); + await user.type(input, 'abc'); + expect(input).toHaveDisplayValue('abc'); + + await user.clear(input); + expect(input).toHaveDisplayValue(''); + }); }); diff --git a/src/user-event/__tests__/paste.test.tsx b/src/user-event/__tests__/paste.test.tsx index 1c01c8b0b..702edbb21 100644 --- a/src/user-event/__tests__/paste.test.tsx +++ b/src/user-event/__tests__/paste.test.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { TextInput, TextInputProps, View } from 'react-native'; import { createEventLogger, getEventsNames } from '../../test-utils'; import { render, userEvent, screen } from '../..'; +import '../../matchers/extend-expect'; beforeEach(() => { jest.useRealTimers(); @@ -221,4 +222,15 @@ describe('paste()', () => { await user.paste(screen.getByTestId('input'), 'Hi!'); expect(parentHandler).not.toHaveBeenCalled(); }); + + it('sets native state value for unmanaged text inputs', async () => { + render(); + + const user = userEvent.setup(); + const input = screen.getByTestId('input'); + expect(input).toHaveDisplayValue(''); + + await user.paste(input, 'abc'); + expect(input).toHaveDisplayValue('abc'); + }); }); diff --git a/src/user-event/paste.ts b/src/user-event/paste.ts index 80ed6b2bd..ccdaff713 100644 --- a/src/user-event/paste.ts +++ b/src/user-event/paste.ts @@ -3,6 +3,7 @@ import { ErrorWithStack } from '../helpers/errors'; import { isHostTextInput } from '../helpers/host-component-names'; import { isPointerEventEnabled } from '../helpers/pointer-events'; import { isTextInputEditable } from '../helpers/text-input'; +import { nativeState } from '../native-state'; import { EventBuilder } from './event-builder'; import { UserEventInstance } from './setup'; import { dispatchEvent, getTextContentSize, wait } from './utils'; @@ -32,6 +33,7 @@ export async function paste( dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(rangeToClear)); // 3. Paste the text + nativeState?.elementValues.set(element, text); dispatchEvent(element, 'change', EventBuilder.TextInput.change(text)); dispatchEvent(element, 'changeText', text); diff --git a/src/user-event/type/__tests__/type.test.tsx b/src/user-event/type/__tests__/type.test.tsx index c03ff2c1a..0be7f35fa 100644 --- a/src/user-event/type/__tests__/type.test.tsx +++ b/src/user-event/type/__tests__/type.test.tsx @@ -374,7 +374,7 @@ describe('type()', () => { }); }); - it('supports value of unmanaged text inputs', async () => { + it('sets native state value for unmanaged text inputs', async () => { render(); const user = userEvent.setup(); diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts index 0d90dbb21..7d3a8e6d6 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -95,9 +95,9 @@ export async function emitTypingEvents( return; } + nativeState?.elementValues.set(element, text); dispatchEvent(element, 'change', EventBuilder.TextInput.change(text)); dispatchEvent(element, 'changeText', text); - nativeState?.elementValues.set(element, text); const selectionRange = { start: text.length, From d9a206efee912c002b56cd628aa1820dc806173a Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Tue, 3 Sep 2024 09:06:48 +0200 Subject: [PATCH 3/3] refactor: code review changes --- src/__tests__/fire-event.test.tsx | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/__tests__/fire-event.test.tsx b/src/__tests__/fire-event.test.tsx index b3f28ad8c..2adf0ba78 100644 --- a/src/__tests__/fire-event.test.tsx +++ b/src/__tests__/fire-event.test.tsx @@ -140,7 +140,6 @@ test('fireEvent.scroll', () => { test('fireEvent.changeText', () => { const onChangeTextMock = jest.fn(); - const CHANGE_TEXT = 'content'; render( @@ -148,9 +147,19 @@ test('fireEvent.changeText', () => { , ); - fireEvent.changeText(screen.getByPlaceholderText('Customer placeholder'), CHANGE_TEXT); + const input = screen.getByPlaceholderText('Customer placeholder'); + fireEvent.changeText(input, 'content'); + expect(onChangeTextMock).toHaveBeenCalledWith('content'); +}); + +it('sets native state value for unmanaged text inputs', () => { + render(); - expect(onChangeTextMock).toHaveBeenCalledWith(CHANGE_TEXT); + const input = screen.getByTestId('input'); + expect(input).toHaveDisplayValue(''); + + fireEvent.changeText(input, 'abc'); + expect(input).toHaveDisplayValue('abc'); }); test('custom component with custom event name', () => { @@ -444,14 +453,4 @@ describe('native events', () => { fireEvent(screen.getByTestId('test-id'), 'onMomentumScrollEnd'); expect(onMomentumScrollEndSpy).toHaveBeenCalled(); }); - - it('sets native state value for unmanaged text inputs', () => { - render(); - - const input = screen.getByTestId('input'); - expect(input).toHaveDisplayValue(''); - - fireEvent.changeText(input, 'abc'); - expect(input).toHaveDisplayValue('abc'); - }); });