Skip to content

Commit 219adbd

Browse files
feat: UserEvent pullToRefresh() (#1822)
1 parent c1978b3 commit 219adbd

10 files changed

Lines changed: 186 additions & 3 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
`@testing-library/react-native` is a TypeScript/Jest library for testing React Native components with user-focused testing patterns.
44

5+
> [!IMPORTANT]
6+
> 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).
7+
58
- Package manager: `yarn` (`yarn@4.11.0`)
69
- Common commands:
710
- `yarn test`

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ with v14.
1111
`onLayout` handler with a synthetic layout event.
1212
- Added `userEvent.accessibilityAction()` to dispatch a named accessibility action to an
1313
element, invoking its `onAccessibilityAction` handler.
14+
- Added `userEvent.pullToRefresh()` to simulate the pull-to-refresh gesture on a host
15+
`ScrollView` element, invoking the `onRefresh` handler of its `refreshControl` prop.
1416

1517
## 14.0.0
1618

agents/git-workflow.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Git, Releases, And PR Workflow
22

3+
## Agent git restrictions
4+
5+
- Never run git commands that create commits, push, or modify the index/history. The human owns these actions.
6+
- 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`.
7+
- Read-only inspection is fine: `git status`, `git log`, `git diff`, `git show`, `git blame`.
8+
- 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.
9+
310
## Commits and releases
411

512
- Use Conventional Commits such as `fix:`, `feat:`, and `chore:`.

docs/api/user-event.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,30 @@ The sequence of events depends on whether the scroll includes an optional moment
294294
- `scroll` (multiple events)
295295
- `momentumScrollEnd`
296296

297+
## `pullToRefresh()` \
298+
299+
> [!NOTE]
300+
> Available since React Native Testing Library 14.1.0.
301+
302+
```ts
303+
pullToRefresh(
304+
instance: TestInstance,
305+
): Promise<void>
306+
```
307+
308+
Example
309+
310+
```ts
311+
const user = userEvent.setup();
312+
await user.pullToRefresh(scrollView);
313+
```
314+
315+
Simulates a user performing the pull-to-refresh gesture on a host `ScrollView` element, invoking the `onRefresh` handler of its `refreshControl` prop.
316+
317+
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.
318+
319+
If the element has no `refreshControl` prop, or its `RefreshControl` has no `onRefresh` handler, the call resolves without doing anything.
320+
297321
## `accessibilityAction()`
298322

299323
> [!NOTE]

src/user-event/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const userEvent = {
2121
paste: (instance: TestInstance, text: string) => setup().paste(instance, text),
2222
scrollTo: (instance: TestInstance, options: ScrollToOptions) =>
2323
setup().scrollTo(instance, options),
24+
pullToRefresh: (instance: TestInstance) => setup().pullToRefresh(instance),
2425
accessibilityAction: (instance: TestInstance, actionName: AccessibilityActionName) =>
2526
setup().accessibilityAction(instance, actionName),
2627
};
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import * as React from 'react';
2+
import { FlatList, RefreshControl, ScrollView, SectionList, Text, View } from 'react-native';
3+
4+
import { render, screen, userEvent } from '../../..';
5+
6+
describe('pullToRefresh()', () => {
7+
test('supports ScrollView', async () => {
8+
const onRefreshMock = jest.fn();
9+
await render(
10+
<ScrollView
11+
testID="view"
12+
refreshControl={<RefreshControl refreshing={false} onRefresh={onRefreshMock} />}
13+
/>,
14+
);
15+
const user = userEvent.setup();
16+
17+
await user.pullToRefresh(screen.getByTestId('view'));
18+
expect(onRefreshMock).toHaveBeenCalled();
19+
});
20+
21+
test('supports FlatList', async () => {
22+
const onRefreshMock = jest.fn();
23+
await render(
24+
<FlatList
25+
testID="view"
26+
data={['A', 'B', 'C']}
27+
renderItem={({ item }) => <Text>{item}</Text>}
28+
refreshControl={<RefreshControl refreshing={false} onRefresh={onRefreshMock} />}
29+
/>,
30+
);
31+
const user = userEvent.setup();
32+
33+
await user.pullToRefresh(screen.getByTestId('view'));
34+
expect(onRefreshMock).toHaveBeenCalled();
35+
});
36+
37+
test('supports SectionList', async () => {
38+
const onRefreshMock = jest.fn();
39+
await render(
40+
<SectionList
41+
testID="view"
42+
sections={[
43+
{ title: 'Section 1', data: ['A', 'B', 'C'] },
44+
{ title: 'Section 2', data: ['D', 'E', 'F'] },
45+
]}
46+
renderItem={({ item }) => <Text>{item}</Text>}
47+
refreshControl={<RefreshControl refreshing={false} onRefresh={onRefreshMock} />}
48+
/>,
49+
);
50+
const user = userEvent.setup();
51+
52+
await user.pullToRefresh(screen.getByTestId('view'));
53+
expect(onRefreshMock).toHaveBeenCalled();
54+
});
55+
56+
test('does not throw when RefreshControl is not set', async () => {
57+
await render(<ScrollView testID="view" />);
58+
const user = userEvent.setup();
59+
60+
await expect(user.pullToRefresh(screen.getByTestId('view'))).resolves.toBeUndefined();
61+
});
62+
63+
test('does not throw when RefreshControl onRefresh is not set', async () => {
64+
await render(
65+
<ScrollView testID="view" refreshControl={<RefreshControl refreshing={false} />} />,
66+
);
67+
const user = userEvent.setup();
68+
69+
await expect(user.pullToRefresh(screen.getByTestId('view'))).resolves.toBeUndefined();
70+
});
71+
72+
test('throws when passed a non-ScrollView element', async () => {
73+
await render(<View testID="view" />);
74+
const user = userEvent.setup();
75+
76+
await expect(user.pullToRefresh(screen.getByTestId('view'))).rejects.toThrow(
77+
/pullToRefresh\(\) works only with host "ScrollView" instances/,
78+
);
79+
});
80+
});

src/user-event/scroll/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
export { pullToRefresh } from './pull-to-refresh';
12
export { scrollTo, ScrollToOptions } from './scroll-to';
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { TestInstance } from 'test-renderer';
2+
3+
import { act } from '../../act';
4+
import { ErrorWithStack } from '../../helpers/errors';
5+
import { isHostScrollView } from '../../helpers/host-component-names';
6+
import type { UserEventInstance } from '../setup';
7+
8+
export async function pullToRefresh(
9+
this: UserEventInstance,
10+
instance: TestInstance,
11+
): Promise<void> {
12+
if (!isHostScrollView(instance)) {
13+
throw new ErrorWithStack(
14+
`pullToRefresh() works only with host "ScrollView" instances. Passed instance has type "${instance.type}".`,
15+
pullToRefresh,
16+
);
17+
}
18+
19+
const refreshControl = instance.props.refreshControl;
20+
if (typeof refreshControl?.props?.onRefresh !== 'function') {
21+
return;
22+
}
23+
24+
await act(() => {
25+
refreshControl.props.onRefresh();
26+
});
27+
}

src/user-event/setup/setup.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { paste } from '../paste';
1010
import type { PressOptions } from '../press';
1111
import { longPress, press } from '../press';
1212
import type { ScrollToOptions } from '../scroll';
13-
import { scrollTo } from '../scroll';
13+
import { pullToRefresh, scrollTo } from '../scroll';
1414
import type { TypeOptions } from '../type';
1515
import { type } from '../type';
1616
import { wait } from '../utils';
@@ -149,13 +149,25 @@ export interface UserEventInstance {
149149
paste: (instance: TestInstance, text: string) => Promise<void>;
150150

151151
/**
152-
* Simlate user scorlling a ScrollView element.
152+
* Simulate user scrolling a given `ScrollView`-like element.
153153
*
154-
* @param instance ScrollView instance
154+
* Supported components: ScrollView, FlatList, SectionList
155+
*
156+
* @param instance ScrollView-like instance
155157
* @returns
156158
*/
157159
scrollTo: (instance: TestInstance, options: ScrollToOptions) => Promise<void>;
158160

161+
/**
162+
* Simulate using pull-to-refresh gesture on a given `ScrollView`-like element.
163+
*
164+
* Supported components: ScrollView, FlatList, SectionList
165+
*
166+
* @param instance ScrollView-like instance
167+
* @returns
168+
*/
169+
pullToRefresh: (instance: TestInstance) => Promise<void>;
170+
159171
/**
160172
* Simulate an assistive technology (e.g. screen reader) triggering an
161173
* accessibility action on a given element.
@@ -185,6 +197,7 @@ function createInstance(config: UserEventConfig): UserEventInstance {
185197
clear: wrapAndBindImpl(instance, clear),
186198
paste: wrapAndBindImpl(instance, paste),
187199
scrollTo: wrapAndBindImpl(instance, scrollTo),
200+
pullToRefresh: wrapAndBindImpl(instance, pullToRefresh),
188201
accessibilityAction: wrapAndBindImpl(instance, accessibilityAction),
189202
};
190203

website/docs/14.x/docs/api/events/user-event.mdx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,31 @@ The sequence of events depends on whether the scroll includes an optional moment
295295
- `scroll` (multiple events)
296296
- `momentumScrollEnd`
297297

298+
## `pullToRefresh()` \{#pull-to-refresh}
299+
300+
:::note
301+
Available since React Native Testing Library 14.1.0.
302+
:::
303+
304+
```ts
305+
pullToRefresh(
306+
instance: TestInstance,
307+
): Promise<void>
308+
```
309+
310+
Example
311+
312+
```ts
313+
const user = userEvent.setup();
314+
await user.pullToRefresh(scrollView);
315+
```
316+
317+
Simulates a user performing the pull-to-refresh gesture on a host `ScrollView` element, invoking the `onRefresh` handler of its `refreshControl` prop.
318+
319+
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.
320+
321+
If the element has no `refreshControl` prop, or its `RefreshControl` has no `onRefresh` handler, the call resolves without doing anything.
322+
298323
## `accessibilityAction()`
299324

300325
:::note

0 commit comments

Comments
 (0)