diff --git a/src/cleanup.ts b/src/cleanup.ts index c7ee13cb..880bc2ee 100644 --- a/src/cleanup.ts +++ b/src/cleanup.ts @@ -1,4 +1,3 @@ -import { clearNativeState } from './native-state'; import { clearRenderResult } from './screen'; type CleanUpFunction = () => void; @@ -6,7 +5,6 @@ type CleanUpFunction = () => void; const cleanupQueue = new Set(); export default function cleanup() { - clearNativeState(); clearRenderResult(); cleanupQueue.forEach((fn) => fn()); diff --git a/src/fire-event.ts b/src/fire-event.ts index ff1a4e6e..ac7d8802 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -8,10 +8,10 @@ import { } from 'react-native'; import act from './act'; import { isHostElement } from './helpers/component-tree'; -import { isHostTextInput } from './helpers/host-component-names'; +import { isHostScrollView, isHostTextInput } from './helpers/host-component-names'; import { isPointerEventEnabled } from './helpers/pointer-events'; import { isTextInputEditable } from './helpers/text-input'; -import { StringWithAutocomplete } from './types'; +import { Point, StringWithAutocomplete } from './types'; import { nativeState } from './native-state'; type EventHandler = (...args: unknown[]) => unknown; @@ -147,6 +147,14 @@ fireEvent.scroll = (element: ReactTestInstance, ...data: unknown[]) => export default fireEvent; +const scrollEventNames = new Set([ + 'scroll', + 'scrollBeginDrag', + 'scrollEndDrag', + 'momentumScrollBegin', + 'momentumScrollEnd', +]); + function setNativeStateIfNeeded(element: ReactTestInstance, eventName: string, value: unknown) { if ( eventName === 'changeText' && @@ -154,6 +162,32 @@ function setNativeStateIfNeeded(element: ReactTestInstance, eventName: string, v isHostTextInput(element) && isTextInputEditable(element) ) { - nativeState?.valueForElement.set(element, value); + nativeState.valueForElement.set(element, value); + } + + if (scrollEventNames.has(eventName) && isHostScrollView(element)) { + const contentOffset = tryGetContentOffset(value); + if (contentOffset) { + nativeState.contentOffsetForElement.set(element, contentOffset); + } } } + +function tryGetContentOffset(value: unknown): Point | null { + try { + // @ts-expect-error: try to extract contentOffset from the event value + const contentOffset = value?.nativeEvent?.contentOffset; + const x = contentOffset?.x; + const y = contentOffset?.y; + if (typeof x === 'number' || typeof y === 'number') { + return { + x: Number.isFinite(x) ? x : 0, + y: Number.isFinite(y) ? y : 0, + }; + } + } catch { + // Do nothing + } + + return null; +} diff --git a/src/helpers/matchers/match-label-text.ts b/src/helpers/matchers/match-label-text.ts index 2aa5b9e5..1da29d86 100644 --- a/src/helpers/matchers/match-label-text.ts +++ b/src/helpers/matchers/match-label-text.ts @@ -18,10 +18,10 @@ export function matchLabelText( function matchAccessibilityLabel( element: ReactTestInstance, - extpectedLabel: TextMatch, + expectedLabel: TextMatch, options: TextMatchOptions, ) { - return matches(extpectedLabel, computeAriaLabel(element), options.normalizer, options.exact); + return matches(expectedLabel, computeAriaLabel(element), options.normalizer, options.exact); } function matchAccessibilityLabelledBy( diff --git a/src/helpers/text-input.ts b/src/helpers/text-input.ts index 3c4ba83e..bf76389f 100644 --- a/src/helpers/text-input.ts +++ b/src/helpers/text-input.ts @@ -17,7 +17,7 @@ export function getTextInputValue(element: ReactTestInstance) { return ( element.props.value ?? - nativeState?.valueForElement.get(element) ?? + nativeState.valueForElement.get(element) ?? element.props.defaultValue ?? '' ); diff --git a/src/native-state.ts b/src/native-state.ts index 378c1fbf..46f53141 100644 --- a/src/native-state.ts +++ b/src/native-state.ts @@ -11,15 +11,7 @@ export type NativeState = { contentOffsetForElement: WeakMap; }; -export let nativeState: NativeState | null = null; - -export function initNativeState(): void { - nativeState = { - valueForElement: new WeakMap(), - contentOffsetForElement: new WeakMap(), - }; -} - -export function clearNativeState(): void { - nativeState = null; -} +export let nativeState: NativeState = { + valueForElement: new WeakMap(), + contentOffsetForElement: new WeakMap(), +}; diff --git a/src/render.tsx b/src/render.tsx index dc9335b4..5f31dcb2 100644 --- a/src/render.tsx +++ b/src/render.tsx @@ -12,7 +12,6 @@ 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; @@ -128,7 +127,6 @@ function buildRenderResult( }); setRenderResult(result); - initNativeState(); return result; } diff --git a/src/user-event/paste.ts b/src/user-event/paste.ts index d94d9e73..856501bf 100644 --- a/src/user-event/paste.ts +++ b/src/user-event/paste.ts @@ -33,7 +33,7 @@ export async function paste( dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(rangeToClear)); // 3. Paste the text - nativeState?.valueForElement.set(element, text); + nativeState.valueForElement.set(element, text); dispatchEvent(element, 'change', EventBuilder.TextInput.change(text)); dispatchEvent(element, 'changeText', text); diff --git a/src/user-event/scroll/__tests__/scroll-to.test.tsx b/src/user-event/scroll/__tests__/scroll-to.test.tsx index e3288c22..684deff9 100644 --- a/src/user-event/scroll/__tests__/scroll-to.test.tsx +++ b/src/user-event/scroll/__tests__/scroll-to.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { ScrollView, ScrollViewProps, View } from 'react-native'; import { EventEntry, createEventLogger } from '../../../test-utils'; -import { render, screen } from '../../..'; +import { fireEvent, render, screen } from '../../..'; import { userEvent } from '../..'; function mapEventsToShortForm(events: EventEntry[]) { @@ -103,7 +103,7 @@ describe('scrollTo()', () => { ]); }); - test('remembers previous scroll position', async () => { + test('remembers previous scroll offset', async () => { const { events } = renderScrollViewWithToolkit(); const user = userEvent.setup(); @@ -123,6 +123,24 @@ describe('scrollTo()', () => { ]); }); + test('remembers previous scroll offset from "fireEvent.scroll"', async () => { + const { events } = renderScrollViewWithToolkit(); + const user = userEvent.setup(); + + fireEvent.scroll(screen.getByTestId('scrollView'), { + nativeEvent: { contentOffset: { y: 100 } }, + }); + await user.scrollTo(screen.getByTestId('scrollView'), { y: 200 }); + expect(mapEventsToShortForm(events)).toEqual([ + ['scroll', 100, undefined], + ['scrollBeginDrag', 100, 0], + ['scroll', 125, 0], + ['scroll', 150, 0], + ['scroll', 175, 0], + ['scrollEndDrag', 200, 0], + ]); + }); + it('validates vertical scroll direction', async () => { renderScrollViewWithToolkit(); const user = userEvent.setup(); diff --git a/src/user-event/scroll/scroll-to.ts b/src/user-event/scroll/scroll-to.ts index a75ac387..b90cc219 100644 --- a/src/user-event/scroll/scroll-to.ts +++ b/src/user-event/scroll/scroll-to.ts @@ -56,15 +56,15 @@ export async function scrollTo( options.contentSize?.height ?? 0, ); - const initialPosition = nativeState?.contentOffsetForElement.get(element) ?? { x: 0, y: 0 }; + const initialOffset = nativeState.contentOffsetForElement.get(element) ?? { x: 0, y: 0 }; const dragSteps = createScrollSteps( { y: options.y, x: options.x }, - initialPosition, + initialOffset, linearInterpolator, ); await emitDragScrollEvents(this.config, element, dragSteps, options); - const momentumStart = dragSteps.at(-1) ?? initialPosition; + const momentumStart = dragSteps.at(-1) ?? initialOffset; const momentumSteps = createScrollSteps( { y: options.momentumY, x: options.momentumX }, momentumStart, @@ -72,8 +72,8 @@ export async function scrollTo( ); await emitMomentumScrollEvents(this.config, element, momentumSteps, options); - const finalPosition = momentumSteps.at(-1) ?? dragSteps.at(-1) ?? initialPosition; - nativeState?.contentOffsetForElement.set(element, finalPosition); + const finalOffset = momentumSteps.at(-1) ?? dragSteps.at(-1) ?? initialOffset; + nativeState.contentOffsetForElement.set(element, finalOffset); } async function emitDragScrollEvents( diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts index 517b12bb..540a2879 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -95,7 +95,7 @@ export async function emitTypingEvents( return; } - nativeState?.valueForElement.set(element, text); + nativeState.valueForElement.set(element, text); dispatchEvent(element, 'change', EventBuilder.TextInput.change(text)); dispatchEvent(element, 'changeText', text); diff --git a/website/docs/12.x/docs/api/events/user-event.mdx b/website/docs/12.x/docs/api/events/user-event.mdx index 68baceb6..5a8379fe 100644 --- a/website/docs/12.x/docs/api/events/user-event.mdx +++ b/website/docs/12.x/docs/api/events/user-event.mdx @@ -269,10 +269,10 @@ Each scroll interaction consists of a mandatory drag scroll part, which simulate ### Options {#scroll-to-options} -- `y` - target vertical drag scroll position -- `x` - target horizontal drag scroll position -- `momentumY` - target vertical momentum scroll position -- `momentumX` - target horizontal momentum scroll position +- `y` - target vertical drag scroll offset +- `x` - target horizontal drag scroll offset +- `momentumY` - target vertical momentum scroll offset +- `momentumX` - target horizontal momentum scroll offset - `contentSize` - passed to `ScrollView` events and enabling `FlatList` updates - `layoutMeasurement` - passed to `ScrollView` events and enabling `FlatList` updates