From 1ede5ee50645c609d5b44ac5427ad5592c563e79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 8 Oct 2026 10:56:11 +0200 Subject: [PATCH 1/5] fix: align scrollTo behavior with RN --- .../scroll-to-flat-list.test.tsx.snap | 40 +++++++++++++++++++ .../__snapshots__/scroll-to.test.tsx.snap | 40 +++++++++++++++++++ .../__tests__/scroll-to-flat-list.test.tsx | 27 +++++++++++++ .../scroll/__tests__/scroll-to.test.tsx | 9 +++++ src/user-event/scroll/scroll-to.ts | 7 ++-- src/user-event/scroll/utils.ts | 2 +- 6 files changed, 120 insertions(+), 5 deletions(-) diff --git a/src/user-event/scroll/__tests__/__snapshots__/scroll-to-flat-list.test.tsx.snap b/src/user-event/scroll/__tests__/__snapshots__/scroll-to-flat-list.test.tsx.snap index e71333cf6..ba7a21e4d 100644 --- a/src/user-event/scroll/__tests__/__snapshots__/scroll-to-flat-list.test.tsx.snap +++ b/src/user-event/scroll/__tests__/__snapshots__/scroll-to-flat-list.test.tsx.snap @@ -162,6 +162,46 @@ exports[`scrollTo() with FlatList supports vertical drag scroll: scrollTo({ y: 1 "timeStamp": 0, }, }, + { + "name": "scroll", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "contentInset": { + "bottom": 0, + "left": 0, + "right": 0, + "top": 0, + }, + "contentOffset": { + "x": 0, + "y": 100, + }, + "contentSize": { + "height": 0, + "width": 0, + }, + "layoutMeasurement": { + "height": 0, + "width": 0, + }, + "responderIgnoreScroll": true, + "target": 0, + "velocity": { + "x": 0, + "y": 0, + }, + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, { "name": "scrollEndDrag", "payload": { diff --git a/src/user-event/scroll/__tests__/__snapshots__/scroll-to.test.tsx.snap b/src/user-event/scroll/__tests__/__snapshots__/scroll-to.test.tsx.snap index ef252e6df..b12ed6bae 100644 --- a/src/user-event/scroll/__tests__/__snapshots__/scroll-to.test.tsx.snap +++ b/src/user-event/scroll/__tests__/__snapshots__/scroll-to.test.tsx.snap @@ -162,6 +162,46 @@ exports[`scrollTo() supports vertical drag scroll: scrollTo({ y: 100 }) 1`] = ` "timeStamp": 0, }, }, + { + "name": "scroll", + "payload": { + "currentTarget": {}, + "isDefaultPrevented": [Function], + "isPersistent": [Function], + "isPropagationStopped": [Function], + "nativeEvent": { + "contentInset": { + "bottom": 0, + "left": 0, + "right": 0, + "top": 0, + }, + "contentOffset": { + "x": 0, + "y": 100, + }, + "contentSize": { + "height": 0, + "width": 0, + }, + "layoutMeasurement": { + "height": 0, + "width": 0, + }, + "responderIgnoreScroll": true, + "target": 0, + "velocity": { + "x": 0, + "y": 0, + }, + }, + "persist": [Function], + "preventDefault": [Function], + "stopPropagation": [Function], + "target": {}, + "timeStamp": 0, + }, + }, { "name": "scrollEndDrag", "payload": { diff --git a/src/user-event/scroll/__tests__/scroll-to-flat-list.test.tsx b/src/user-event/scroll/__tests__/scroll-to-flat-list.test.tsx index 80141ee77..ddcfd289b 100644 --- a/src/user-event/scroll/__tests__/scroll-to-flat-list.test.tsx +++ b/src/user-event/scroll/__tests__/scroll-to-flat-list.test.tsx @@ -52,6 +52,7 @@ describe('scrollTo() with FlatList', () => { ['scroll', 25, 0], ['scroll', 50, 0], ['scroll', 75, 0], + ['scroll', 100, 0], ['scrollEndDrag', 100, 0], ]); expect(events).toMatchSnapshot('scrollTo({ y: 100 })'); @@ -67,6 +68,7 @@ describe('scrollTo() with FlatList', () => { ['scroll', 0, 25], ['scroll', 0, 50], ['scroll', 0, 75], + ['scroll', 0, 100], ['scrollEndDrag', 0, 100], ]); }); @@ -117,3 +119,28 @@ test('scrollTo with contentSize and layoutMeasurement update FlatList content', // Prevent act warning by unmounting the component await screen.unmount(); }); + +test('drag scroll delivers final offset to FlatList', async () => { + const onEndReached = jest.fn(); + await render( + } + onEndReached={onEndReached} + onEndReachedThreshold={0} + />, + ); + const user = userEvent.setup(); + + await user.scrollTo(screen.getByTestId('flat-list'), { + y: 520, + contentSize: { width: 240, height: 1000 }, + layoutMeasurement: { width: 240, height: 480 }, + }); + + expect(onEndReached).toHaveBeenCalledTimes(1); + + // Prevent act warning by unmounting the component + await screen.unmount(); +}); diff --git a/src/user-event/scroll/__tests__/scroll-to.test.tsx b/src/user-event/scroll/__tests__/scroll-to.test.tsx index 3deb234fa..45f6db491 100644 --- a/src/user-event/scroll/__tests__/scroll-to.test.tsx +++ b/src/user-event/scroll/__tests__/scroll-to.test.tsx @@ -49,6 +49,7 @@ describe('scrollTo()', () => { ['scroll', 25, 0], ['scroll', 50, 0], ['scroll', 75, 0], + ['scroll', 100, 0], ['scrollEndDrag', 100, 0], ]); expect(events).toMatchSnapshot('scrollTo({ y: 100 })'); @@ -64,6 +65,7 @@ describe('scrollTo()', () => { ['scroll', 0, 25], ['scroll', 0, 50], ['scroll', 0, 75], + ['scroll', 0, 100], ['scrollEndDrag', 0, 100], ]); }); @@ -81,6 +83,7 @@ describe('scrollTo()', () => { ['scroll', 25, 0], ['scroll', 50, 0], ['scroll', 75, 0], + ['scroll', 100, 0], ['scrollEndDrag', 100, 0], ['momentumScrollBegin', 100, 0], ['scroll', 110, 0], @@ -102,6 +105,7 @@ describe('scrollTo()', () => { ['scroll', 25, 0], ['scroll', 50, 0], ['scroll', 75, 0], + ['scroll', 100, 0], ['scrollEndDrag', 100, 0], ]); }); @@ -117,11 +121,13 @@ describe('scrollTo()', () => { ['scroll', 25, 0], ['scroll', 50, 0], ['scroll', 75, 0], + ['scroll', 100, 0], ['scrollEndDrag', 100, 0], ['scrollBeginDrag', 100, 0], ['scroll', 125, 0], ['scroll', 150, 0], ['scroll', 175, 0], + ['scroll', 200, 0], ['scrollEndDrag', 200, 0], ]); }); @@ -140,6 +146,7 @@ describe('scrollTo()', () => { ['scroll', 125, 0], ['scroll', 150, 0], ['scroll', 175, 0], + ['scroll', 200, 0], ['scrollEndDrag', 200, 0], ]); }); @@ -190,6 +197,7 @@ describe('scrollTo()', () => { ['scroll', 25, 0], ['scroll', 50, 0], ['scroll', 75, 0], + ['scroll', 100, 0], ['scrollEndDrag', 100, 0], ['momentumScrollBegin', 100, 0], ['scroll', 100, 0], @@ -254,6 +262,7 @@ describe('scrollTo()', () => { ['scroll', 25, 0], ['scroll', 50, 0], ['scroll', 75, 0], + ['scroll', 100, 0], ['scrollEndDrag', 100, 0], ]); }); diff --git a/src/user-event/scroll/scroll-to.ts b/src/user-event/scroll/scroll-to.ts index 19f29d06b..95c80d5d6 100644 --- a/src/user-event/scroll/scroll-to.ts +++ b/src/user-event/scroll/scroll-to.ts @@ -103,10 +103,9 @@ async function emitDragScrollEvents( buildScrollEvent(scrollSteps[0], scrollOptions), ); - // Note: experimentally, in case of drag scroll the last scroll step - // will not trigger `scroll` event. - // See: https://github.com/callstack/react-native-testing-library/wiki/ScrollView-Events - for (let i = 1; i < scrollSteps.length - 1; i += 1) { + // Note: native emits `scroll` for every offset change while dragging, including the last one, + // and `scrollEndDrag` reports that same offset. + for (let i = 1; i < scrollSteps.length; i += 1) { await wait(config); await interaction.dispatchEvent('scroll', buildScrollEvent(scrollSteps[i], scrollOptions)); } diff --git a/src/user-event/scroll/utils.ts b/src/user-event/scroll/utils.ts index 8fd043edd..c934334a9 100644 --- a/src/user-event/scroll/utils.ts +++ b/src/user-event/scroll/utils.ts @@ -31,7 +31,7 @@ export function createScrollSteps( */ export function linearInterpolator(end: number, start: number, steps: number): number[] { if (end === start) { - return [end, start]; + return [start]; } const result = []; From 3278ec745a917cef49506559cf813b1d85773d17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 8 Oct 2026 11:47:19 +0200 Subject: [PATCH 2/5] comments --- src/user-event/scroll/scroll-to.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/user-event/scroll/scroll-to.ts b/src/user-event/scroll/scroll-to.ts index 95c80d5d6..a7a6b6cb9 100644 --- a/src/user-event/scroll/scroll-to.ts +++ b/src/user-event/scroll/scroll-to.ts @@ -103,8 +103,7 @@ async function emitDragScrollEvents( buildScrollEvent(scrollSteps[0], scrollOptions), ); - // Note: native emits `scroll` for every offset change while dragging, including the last one, - // and `scrollEndDrag` reports that same offset. + // Native emits `scroll` for every drag step, including the last; `scrollEndDrag` repeats its offset. for (let i = 1; i < scrollSteps.length; i += 1) { await wait(config); await interaction.dispatchEvent('scroll', buildScrollEvent(scrollSteps[i], scrollOptions)); From ccde6e3ccc0fc676dde3113a097a10bf24062c79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 8 Oct 2026 11:48:31 +0200 Subject: [PATCH 3/5] events seq tweaks --- .../__tests__/scroll-to-flat-list.test.tsx | 6 +++--- .../scroll/__tests__/scroll-to.test.tsx | 1 - src/user-event/scroll/scroll-to.ts | 4 +--- src/user-event/scroll/utils.ts | 21 ++++++++++--------- 4 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/user-event/scroll/__tests__/scroll-to-flat-list.test.tsx b/src/user-event/scroll/__tests__/scroll-to-flat-list.test.tsx index ddcfd289b..92080a3f4 100644 --- a/src/user-event/scroll/__tests__/scroll-to-flat-list.test.tsx +++ b/src/user-event/scroll/__tests__/scroll-to-flat-list.test.tsx @@ -23,7 +23,7 @@ async function renderFlatListWithToolkit(props: ScrollViewProps = {}) { const renderItem = (title: string) => {title}; await render( { const { events } = await renderFlatListWithToolkit(); const user = userEvent.setup(); - await user.scrollTo(screen.getByTestId('flatList'), { y: 100 }); + await user.scrollTo(screen.getByTestId('flat-list'), { y: 100 }); expect(mapEventsToShortForm(events)).toEqual([ ['scrollBeginDrag', 0, 0], ['scroll', 25, 0], @@ -62,7 +62,7 @@ describe('scrollTo() with FlatList', () => { const { events } = await renderFlatListWithToolkit({ horizontal: true }); const user = userEvent.setup(); - await user.scrollTo(screen.getByTestId('flatList'), { x: 100 }); + await user.scrollTo(screen.getByTestId('flat-list'), { x: 100 }); expect(mapEventsToShortForm(events)).toEqual([ ['scrollBeginDrag', 0, 0], ['scroll', 0, 25], diff --git a/src/user-event/scroll/__tests__/scroll-to.test.tsx b/src/user-event/scroll/__tests__/scroll-to.test.tsx index 45f6db491..e2190ad3d 100644 --- a/src/user-event/scroll/__tests__/scroll-to.test.tsx +++ b/src/user-event/scroll/__tests__/scroll-to.test.tsx @@ -200,7 +200,6 @@ describe('scrollTo()', () => { ['scroll', 100, 0], ['scrollEndDrag', 100, 0], ['momentumScrollBegin', 100, 0], - ['scroll', 100, 0], ['momentumScrollEnd', 100, 0], ]); }); diff --git a/src/user-event/scroll/scroll-to.ts b/src/user-event/scroll/scroll-to.ts index a7a6b6cb9..6d581d6ef 100644 --- a/src/user-event/scroll/scroll-to.ts +++ b/src/user-event/scroll/scroll-to.ts @@ -130,9 +130,7 @@ async function emitMomentumScrollEvents( buildScrollEvent(scrollSteps[0], scrollOptions), ); - // Note: experimentally, in case of momentum scroll the last scroll step - // will trigger `scroll` event. - // See: https://github.com/callstack/react-native-testing-library/wiki/ScrollView-Events + // Native emits `scroll` for every momentum step, including the last; `momentumScrollEnd` repeats its offset. for (let i = 1; i < scrollSteps.length; i += 1) { await wait(config); await interaction.dispatchEvent('scroll', buildScrollEvent(scrollSteps[i], scrollOptions)); diff --git a/src/user-event/scroll/utils.ts b/src/user-event/scroll/utils.ts index c934334a9..4bb28483a 100644 --- a/src/user-event/scroll/utils.ts +++ b/src/user-event/scroll/utils.ts @@ -10,14 +10,14 @@ export function createScrollSteps( interpolator: InterpolatorFn, ): Point[] { if (target.y != null) { - return interpolator(target.y, initialOffset.y, DEFAULT_STEPS_COUNT).map((y) => ({ + return interpolate(target.y, initialOffset.y, interpolator).map((y) => ({ y, x: initialOffset.x, })); } if (target.x != null) { - return interpolator(target.x, initialOffset.x, DEFAULT_STEPS_COUNT).map((x) => ({ + return interpolate(target.x, initialOffset.x, interpolator).map((x) => ({ x, y: initialOffset.y, })); @@ -26,14 +26,19 @@ export function createScrollSteps( return []; } -/** - * Generate linear scroll values (with equal steps). - */ -export function linearInterpolator(end: number, start: number, steps: number): number[] { +function interpolate(end: number, start: number, interpolator: InterpolatorFn): number[] { + // No movement: a single step, so no `scroll` event is emitted for an unchanged offset. if (end === start) { return [start]; } + return interpolator(end, start, DEFAULT_STEPS_COUNT); +} + +/** + * Generate linear scroll values (with equal steps). + */ +export function linearInterpolator(end: number, start: number, steps: number): number[] { const result = []; for (let i = 0; i < steps; i += 1) { result.push(lerp(start, end, i / (steps - 1))); @@ -46,10 +51,6 @@ export function linearInterpolator(end: number, start: number, steps: number): n * Generate inertial scroll values (exponentially slowing down). */ export function inertialInterpolator(end: number, start: number, steps: number): number[] { - if (end === start) { - return [end, start]; - } - const result = []; let factor = 1; for (let i = 0; i < steps - 1; i += 1) { From 45956f8e0f8ded7f5ad5ecc1f038b65858a3d96a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 8 Oct 2026 11:51:35 +0200 Subject: [PATCH 4/5] tweaks --- website/docs/14.x/docs/api/events/user-event.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 2c7f5fcd6..984f74015 100644 --- a/website/docs/14.x/docs/api/events/user-event.mdx +++ b/website/docs/14.x/docs/api/events/user-event.mdx @@ -286,13 +286,13 @@ The sequence of events depends on whether the scroll includes an optional moment - `contentSizeChange` - `scrollBeginDrag` -- `scroll` (multiple events) +- `scroll` (zero or more events; none if the scroll offset does not change) - `scrollEndDrag` **Momentum scroll (optional)**: - `momentumScrollBegin` -- `scroll` (multiple events) +- `scroll` (zero or more events; none if the scroll offset does not change) - `momentumScrollEnd` ## `pullToRefresh()` \{#pull-to-refresh} From 76d9742933a7d50bd7f9546618c28f9156efcfaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 8 Oct 2026 11:54:57 +0200 Subject: [PATCH 5/5] fix ci docs --- docs/api/user-event.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api/user-event.md b/docs/api/user-event.md index bfee6e2c3..b3b9e8797 100644 --- a/docs/api/user-event.md +++ b/docs/api/user-event.md @@ -285,13 +285,13 @@ The sequence of events depends on whether the scroll includes an optional moment - `contentSizeChange` - `scrollBeginDrag` -- `scroll` (multiple events) +- `scroll` (zero or more events; none if the scroll offset does not change) - `scrollEndDrag` **Momentum scroll (optional)**: - `momentumScrollBegin` -- `scroll` (multiple events) +- `scroll` (zero or more events; none if the scroll offset does not change) - `momentumScrollEnd` ## `pullToRefresh()` \