From e6d4ac9f664bbd304437573d7ea0a4e0182cd058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 21 Jul 2026 16:56:55 +0200 Subject: [PATCH 1/5] feat: User Event `pullToRefresh()` --- docs/api/user-event.md | 24 ++++++ src/user-event/index.ts | 1 + .../scroll/__tests__/pull-to-refresh.test.tsx | 80 +++++++++++++++++++ src/user-event/scroll/pull-to-refresh.ts | 28 +++++++ src/user-event/setup/setup.ts | 18 ++++- 5 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 src/user-event/scroll/__tests__/pull-to-refresh.test.tsx create mode 100644 src/user-event/scroll/pull-to-refresh.ts diff --git a/docs/api/user-event.md b/docs/api/user-event.md index 234ec5b5e..56f2806f9 100644 --- a/docs/api/user-event.md +++ b/docs/api/user-event.md @@ -294,6 +294,30 @@ The sequence of events depends on whether the scroll includes an optional moment - `scroll` (multiple events) - `momentumScrollEnd` +## `pullToRefresh()` + +> [!NOTE] +> Available since React Native Testing Library 14.1.0. + +```ts +pullToRefresh( + instance: TestInstance, +): Promise +``` + +Example + +```ts +const user = userEvent.setup(); +await user.pullToRefresh(scrollView); +``` + +Simulates a user performing the pull-to-refresh gesture on a host `ScrollView` element, invoking the `onRefresh` handler of its `refreshControl` prop. + +This function supports only host `ScrollView` elements, passing other element types will result in an error. Note that `FlatList` and `SectionList` are accepted as they render to a host `ScrollView` element. + +If the element has no `refreshControl` prop, or its `RefreshControl` has no `onRefresh` handler, the call resolves without doing anything. + ## `accessibilityAction()` > [!NOTE] diff --git a/src/user-event/index.ts b/src/user-event/index.ts index b19451cdf..fad60a88a 100644 --- a/src/user-event/index.ts +++ b/src/user-event/index.ts @@ -21,6 +21,7 @@ export const userEvent = { paste: (instance: TestInstance, text: string) => setup().paste(instance, text), scrollTo: (instance: TestInstance, options: ScrollToOptions) => setup().scrollTo(instance, options), + pullToRefresh: (instance: TestInstance) => setup().pullToRefresh(instance), accessibilityAction: (instance: TestInstance, actionName: AccessibilityActionName) => setup().accessibilityAction(instance, actionName), }; diff --git a/src/user-event/scroll/__tests__/pull-to-refresh.test.tsx b/src/user-event/scroll/__tests__/pull-to-refresh.test.tsx new file mode 100644 index 000000000..0dff4b813 --- /dev/null +++ b/src/user-event/scroll/__tests__/pull-to-refresh.test.tsx @@ -0,0 +1,80 @@ +import * as React from 'react'; +import { FlatList, RefreshControl, ScrollView, SectionList, Text, View } from 'react-native'; + +import { render, screen, userEvent } from '../../..'; + +describe('pullToRefresh()', () => { + it('supports ScrollView', async () => { + const onRefreshMock = jest.fn(); + await render( + } + />, + ); + const user = userEvent.setup(); + + await user.pullToRefresh(screen.getByTestId('view')); + expect(onRefreshMock).toHaveBeenCalled(); + }); + + it('supports FlatList', async () => { + const onRefreshMock = jest.fn(); + await render( + {item}} + refreshControl={} + />, + ); + const user = userEvent.setup(); + + await user.pullToRefresh(screen.getByTestId('view')); + expect(onRefreshMock).toHaveBeenCalled(); + }); + + it('supports SectionList', async () => { + const onRefreshMock = jest.fn(); + await render( + {item}} + refreshControl={} + />, + ); + const user = userEvent.setup(); + + await user.pullToRefresh(screen.getByTestId('view')); + expect(onRefreshMock).toHaveBeenCalled(); + }); + + it('does not throw when RefreshControl is not set', async () => { + await render(); + const user = userEvent.setup(); + + await expect(user.pullToRefresh(screen.getByTestId('view'))).resolves.toBeUndefined(); + }); + + it('does not throw when RefreshControl onRefresh is not set', async () => { + await render( + } />, + ); + const user = userEvent.setup(); + + await expect(user.pullToRefresh(screen.getByTestId('view'))).resolves.toBeUndefined(); + }); + + it('throws when passed a non-ScrollView element', async () => { + await render(); + const user = userEvent.setup(); + + await expect(user.pullToRefresh(screen.getByTestId('view'))).rejects.toThrow( + /pullToRefresh\(\) works only with host "ScrollView" elements/, + ); + }); +}); diff --git a/src/user-event/scroll/pull-to-refresh.ts b/src/user-event/scroll/pull-to-refresh.ts new file mode 100644 index 000000000..8ba6e0adc --- /dev/null +++ b/src/user-event/scroll/pull-to-refresh.ts @@ -0,0 +1,28 @@ +import type { TestInstance } from 'test-renderer'; + +import { act } from '../../act'; +import { ErrorWithStack } from '../../helpers/errors'; +import { isHostScrollView } from '../../helpers/host-component-names'; +import type { UserEventInstance } from '../setup'; + +export async function pullToRefresh( + this: UserEventInstance, + instance: TestInstance, +): Promise { + if (!isHostScrollView(instance)) { + throw new ErrorWithStack( + `pullToRefresh() works only with host "ScrollView" elements. Passed element has type "${instance.type}".`, + pullToRefresh, + ); + } + + const refreshControl = instance.props.refreshControl; + if (refreshControl == null || typeof refreshControl.props.onRefresh !== 'function') { + return; + } + + // eslint-disable-next-line require-await + await act(async () => { + refreshControl.props.onRefresh(); + }); +} diff --git a/src/user-event/setup/setup.ts b/src/user-event/setup/setup.ts index feb75d32d..d748c5b2a 100644 --- a/src/user-event/setup/setup.ts +++ b/src/user-event/setup/setup.ts @@ -11,6 +11,7 @@ import type { PressOptions } from '../press'; import { longPress, press } from '../press'; import type { ScrollToOptions } from '../scroll'; import { scrollTo } from '../scroll'; +import { pullToRefresh } from '../scroll/pull-to-refresh'; import type { TypeOptions } from '../type'; import { type } from '../type'; import { wait } from '../utils'; @@ -149,13 +150,25 @@ export interface UserEventInstance { paste: (instance: TestInstance, text: string) => Promise; /** - * Simlate user scorlling a ScrollView element. + * Simlate user scorlling a given `ScrollView`-like element. * - * @param instance ScrollView instance + * Supported components: ScrollView, FlatList, SectionList + * + * @param instance ScrollView-like instance * @returns */ scrollTo: (instance: TestInstance, options: ScrollToOptions) => Promise; + /** + * Simulate using pull-to-refresh gesture on a given `ScrollView`-like element. + * + * Supported components: ScrollView, FlatList, SectionList + * + * @param instance ScrollView-like instance + * @returns + */ + pullToRefresh: (instance: TestInstance) => Promise; + /** * Simulate an assistive technology (e.g. screen reader) triggering an * accessibility action on a given element. @@ -185,6 +198,7 @@ function createInstance(config: UserEventConfig): UserEventInstance { clear: wrapAndBindImpl(instance, clear), paste: wrapAndBindImpl(instance, paste), scrollTo: wrapAndBindImpl(instance, scrollTo), + pullToRefresh: wrapAndBindImpl(instance, pullToRefresh), accessibilityAction: wrapAndBindImpl(instance, accessibilityAction), }; From 4ae44b414bc276ef5fef7db94193f9c1971ff9d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 21 Jul 2026 16:58:41 +0200 Subject: [PATCH 2/5] agent docs --- AGENTS.md | 3 +++ agents/git-workflow.md | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 0c0e938b6..d233adba0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,9 @@ `@testing-library/react-native` is a TypeScript/Jest library for testing React Native components with user-focused testing patterns. +> [!IMPORTANT] +> Never run git commands that create commits, push, or modify the index/history (`git commit`, `git push`, `git add`, `git rm`, `git reset`, `git rebase`, `git merge`, `git stash`, `git tag`, etc.). Only make working-tree changes and read-only git inspections; the human stages and commits. See [Git, releases, and PR workflow](agents/git-workflow.md). + - Package manager: `yarn` (`yarn@4.11.0`) - Common commands: - `yarn test` diff --git a/agents/git-workflow.md b/agents/git-workflow.md index 6a0d60560..daef86153 100644 --- a/agents/git-workflow.md +++ b/agents/git-workflow.md @@ -1,5 +1,12 @@ # Git, Releases, And PR Workflow +## Agent git restrictions + +- Never run git commands that create commits, push, or modify the index/history. The human owns these actions. +- Forbidden commands include (non-exhaustive): `git commit`, `git push`, `git add`, `git rm`, `git restore --staged`, `git reset`, `git rebase`, `git merge`, `git cherry-pick`, `git stash`, `git commit --amend`, and `git tag`. +- Read-only inspection is fine: `git status`, `git log`, `git diff`, `git show`, `git blame`. +- When conflicts or staging are involved, resolve file contents in the working tree only, then hand off to the human to stage and commit. Describe the exact commands you would run instead of running them. + ## Commits and releases - Use Conventional Commits such as `fix:`, `feat:`, and `chore:`. From 6661ff5aedd3fd02266cc5b0798b800351dc6d14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 21 Jul 2026 17:06:55 +0200 Subject: [PATCH 3/5] code review changes --- CHANGELOG.md | 2 ++ docs/api/user-event.md | 2 +- .../scroll/__tests__/pull-to-refresh.test.tsx | 14 +++++------ src/user-event/scroll/index.ts | 1 + src/user-event/scroll/pull-to-refresh.ts | 5 ++-- src/user-event/setup/setup.ts | 5 ++-- .../docs/14.x/docs/api/events/user-event.mdx | 25 +++++++++++++++++++ 7 files changed, 40 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index baea67f71..6e6a1754c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ with v14. `onLayout` handler with a synthetic layout event. - Added `userEvent.accessibilityAction()` to dispatch a named accessibility action to an element, invoking its `onAccessibilityAction` handler. +- Added `userEvent.pullToRefresh()` to simulate the pull-to-refresh gesture on a host + `ScrollView` element, invoking the `onRefresh` handler of its `refreshControl` prop. ## 14.0.0 diff --git a/docs/api/user-event.md b/docs/api/user-event.md index 56f2806f9..5e38ac315 100644 --- a/docs/api/user-event.md +++ b/docs/api/user-event.md @@ -294,7 +294,7 @@ The sequence of events depends on whether the scroll includes an optional moment - `scroll` (multiple events) - `momentumScrollEnd` -## `pullToRefresh()` +## `pullToRefresh()` \ > [!NOTE] > Available since React Native Testing Library 14.1.0. diff --git a/src/user-event/scroll/__tests__/pull-to-refresh.test.tsx b/src/user-event/scroll/__tests__/pull-to-refresh.test.tsx index 0dff4b813..74e432d73 100644 --- a/src/user-event/scroll/__tests__/pull-to-refresh.test.tsx +++ b/src/user-event/scroll/__tests__/pull-to-refresh.test.tsx @@ -4,7 +4,7 @@ import { FlatList, RefreshControl, ScrollView, SectionList, Text, View } from 'r import { render, screen, userEvent } from '../../..'; describe('pullToRefresh()', () => { - it('supports ScrollView', async () => { + test('supports ScrollView', async () => { const onRefreshMock = jest.fn(); await render( { expect(onRefreshMock).toHaveBeenCalled(); }); - it('supports FlatList', async () => { + test('supports FlatList', async () => { const onRefreshMock = jest.fn(); await render( { expect(onRefreshMock).toHaveBeenCalled(); }); - it('supports SectionList', async () => { + test('supports SectionList', async () => { const onRefreshMock = jest.fn(); await render( { expect(onRefreshMock).toHaveBeenCalled(); }); - it('does not throw when RefreshControl is not set', async () => { + test('does not throw when RefreshControl is not set', async () => { await render(); const user = userEvent.setup(); await expect(user.pullToRefresh(screen.getByTestId('view'))).resolves.toBeUndefined(); }); - it('does not throw when RefreshControl onRefresh is not set', async () => { + test('does not throw when RefreshControl onRefresh is not set', async () => { await render( } />, ); @@ -69,12 +69,12 @@ describe('pullToRefresh()', () => { await expect(user.pullToRefresh(screen.getByTestId('view'))).resolves.toBeUndefined(); }); - it('throws when passed a non-ScrollView element', async () => { + test('throws when passed a non-ScrollView element', async () => { await render(); const user = userEvent.setup(); await expect(user.pullToRefresh(screen.getByTestId('view'))).rejects.toThrow( - /pullToRefresh\(\) works only with host "ScrollView" elements/, + /pullToRefresh\(\) works only with host "ScrollView" instances/, ); }); }); diff --git a/src/user-event/scroll/index.ts b/src/user-event/scroll/index.ts index 2eb6ff196..2307641fc 100644 --- a/src/user-event/scroll/index.ts +++ b/src/user-event/scroll/index.ts @@ -1 +1,2 @@ +export { pullToRefresh } from './pull-to-refresh'; export { scrollTo, ScrollToOptions } from './scroll-to'; diff --git a/src/user-event/scroll/pull-to-refresh.ts b/src/user-event/scroll/pull-to-refresh.ts index 8ba6e0adc..3cc2faffe 100644 --- a/src/user-event/scroll/pull-to-refresh.ts +++ b/src/user-event/scroll/pull-to-refresh.ts @@ -11,7 +11,7 @@ export async function pullToRefresh( ): Promise { if (!isHostScrollView(instance)) { throw new ErrorWithStack( - `pullToRefresh() works only with host "ScrollView" elements. Passed element has type "${instance.type}".`, + `pullToRefresh() works only with host "ScrollView" instances. Passed instance has type "${instance.type}".`, pullToRefresh, ); } @@ -21,8 +21,7 @@ export async function pullToRefresh( return; } - // eslint-disable-next-line require-await - await act(async () => { + await act(() => { refreshControl.props.onRefresh(); }); } diff --git a/src/user-event/setup/setup.ts b/src/user-event/setup/setup.ts index d748c5b2a..257d3627a 100644 --- a/src/user-event/setup/setup.ts +++ b/src/user-event/setup/setup.ts @@ -10,8 +10,7 @@ import { paste } from '../paste'; import type { PressOptions } from '../press'; import { longPress, press } from '../press'; import type { ScrollToOptions } from '../scroll'; -import { scrollTo } from '../scroll'; -import { pullToRefresh } from '../scroll/pull-to-refresh'; +import { pullToRefresh, scrollTo } from '../scroll'; import type { TypeOptions } from '../type'; import { type } from '../type'; import { wait } from '../utils'; @@ -150,7 +149,7 @@ export interface UserEventInstance { paste: (instance: TestInstance, text: string) => Promise; /** - * Simlate user scorlling a given `ScrollView`-like element. + * Simulate user scrolling a given `ScrollView`-like element. * * Supported components: ScrollView, FlatList, SectionList * diff --git a/website/docs/14.x/docs/api/events/user-event.mdx b/website/docs/14.x/docs/api/events/user-event.mdx index 4ecdbe85e..efa0e4dc2 100644 --- a/website/docs/14.x/docs/api/events/user-event.mdx +++ b/website/docs/14.x/docs/api/events/user-event.mdx @@ -295,6 +295,31 @@ The sequence of events depends on whether the scroll includes an optional moment - `scroll` (multiple events) - `momentumScrollEnd` +## `pullToRefresh()` \{#pull-to-refresh} + +:::note +Available since React Native Testing Library 14.1.0. +::: + +```ts +pullToRefresh( + instance: TestInstance, +): Promise +``` + +Example + +```ts +const user = userEvent.setup(); +await user.pullToRefresh(scrollView); +``` + +Simulates a user performing the pull-to-refresh gesture on a host `ScrollView` element, invoking the `onRefresh` handler of its `refreshControl` prop. + +This function supports only host `ScrollView` elements, passing other element types will result in an error. Note that `FlatList` and `SectionList` are accepted as they render to a host `ScrollView` element. + +If the element has no `refreshControl` prop, or its `RefreshControl` has no `onRefresh` handler, the call resolves without doing anything. + ## `accessibilityAction()` :::note From 37453d0180f6f8308ca7f51927695c920a985d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 21 Jul 2026 17:10:10 +0200 Subject: [PATCH 4/5] tweaks --- src/user-event/scroll/__tests__/pull-to-refresh.test.tsx | 8 ++++++++ src/user-event/scroll/pull-to-refresh.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/user-event/scroll/__tests__/pull-to-refresh.test.tsx b/src/user-event/scroll/__tests__/pull-to-refresh.test.tsx index 74e432d73..cba5be877 100644 --- a/src/user-event/scroll/__tests__/pull-to-refresh.test.tsx +++ b/src/user-event/scroll/__tests__/pull-to-refresh.test.tsx @@ -69,6 +69,14 @@ describe('pullToRefresh()', () => { await expect(user.pullToRefresh(screen.getByTestId('view'))).resolves.toBeUndefined(); }); + test('does not throw when refreshControl is not a valid element', async () => { + // Defensive: `refreshControl` violates its `ReactElement` type and has no `props`. + await render(); + const user = userEvent.setup(); + + await expect(user.pullToRefresh(screen.getByTestId('view'))).resolves.toBeUndefined(); + }); + test('throws when passed a non-ScrollView element', async () => { await render(); const user = userEvent.setup(); diff --git a/src/user-event/scroll/pull-to-refresh.ts b/src/user-event/scroll/pull-to-refresh.ts index 3cc2faffe..e22fe2645 100644 --- a/src/user-event/scroll/pull-to-refresh.ts +++ b/src/user-event/scroll/pull-to-refresh.ts @@ -17,7 +17,7 @@ export async function pullToRefresh( } const refreshControl = instance.props.refreshControl; - if (refreshControl == null || typeof refreshControl.props.onRefresh !== 'function') { + if (typeof refreshControl?.props?.onRefresh !== 'function') { return; } From d0de2aa3894ac80bc3bc3dbccfda1d6276f2ace5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 21 Jul 2026 17:17:16 +0200 Subject: [PATCH 5/5] fix --- src/user-event/scroll/__tests__/pull-to-refresh.test.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/user-event/scroll/__tests__/pull-to-refresh.test.tsx b/src/user-event/scroll/__tests__/pull-to-refresh.test.tsx index cba5be877..74e432d73 100644 --- a/src/user-event/scroll/__tests__/pull-to-refresh.test.tsx +++ b/src/user-event/scroll/__tests__/pull-to-refresh.test.tsx @@ -69,14 +69,6 @@ describe('pullToRefresh()', () => { await expect(user.pullToRefresh(screen.getByTestId('view'))).resolves.toBeUndefined(); }); - test('does not throw when refreshControl is not a valid element', async () => { - // Defensive: `refreshControl` violates its `ReactElement` type and has no `props`. - await render(); - const user = userEvent.setup(); - - await expect(user.pullToRefresh(screen.getByTestId('view'))).resolves.toBeUndefined(); - }); - test('throws when passed a non-ScrollView element', async () => { await render(); const user = userEvent.setup();