From 403032f8fbdd553bd877614c81a358f15dad59d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Huy?= <68500260+huytdps13400@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:47:57 +0700 Subject: [PATCH] Fix inverted list wheel scrolling over its own surface --- .../VirtualizedList/__tests__/index-test.js | 109 ++++++++++++++++++ .../react-native/VirtualizedList/index.js | 2 +- 2 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 packages/react-native-web/src/exports/VirtualizedList/__tests__/index-test.js diff --git a/packages/react-native-web/src/exports/VirtualizedList/__tests__/index-test.js b/packages/react-native-web/src/exports/VirtualizedList/__tests__/index-test.js new file mode 100644 index 0000000000..37121e2a01 --- /dev/null +++ b/packages/react-native-web/src/exports/VirtualizedList/__tests__/index-test.js @@ -0,0 +1,109 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import VirtualizedList from '../'; + +function setScrollMetrics(node, horizontal, offset, length = 500) { + const position = horizontal ? 'scrollLeft' : 'scrollTop'; + let current = offset; + Object.defineProperties(node, { + [horizontal ? 'scrollWidth' : 'scrollHeight']: { + configurable: true, + value: length + }, + [horizontal ? 'clientWidth' : 'clientHeight']: { + configurable: true, + value: 100 + }, + [position]: { + configurable: true, + get: () => current, + set: (value) => { + current = Math.max(0, Math.min(value, length - 100)); + } + } + }); + return () => current; +} + +describe.each([false, true])( + 'VirtualizedList inverted wheel, horizontal=%s', + (horizontal) => { + function setup(inverted = true) { + const { getByTestId } = render( + data[index]} + getItemCount={(data) => data.length} + getItemLayout={(data, index) => ({ length: 500, offset: 0, index })} + horizontal={horizontal} + inverted={inverted} + keyExtractor={String} + renderItem={() => ( +
+
Nested scrollable
+
+ )} + testID="list" + /> + ); + const list = getByTestId('list'); + const position = setScrollMetrics(list, horizontal, 200); + return { list, position, getByTestId }; + } + + function wheel(target, delta) { + const event = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + [horizontal ? 'deltaX' : 'deltaY']: delta + }); + fireEvent(target, event); + return event; + } + + test.each([ + [200, 20, 180], + [200, -20, 220], + [390, 20, 370] + ])( + 'scrolls its own surface from %s by inverted delta %s', + (offset, delta, expected) => { + const { list } = setup(); + const position = setScrollMetrics(list, horizontal, offset); + const event = wheel(list, delta); + expect(position()).toBe(expected); + expect(event.defaultPrevented).toBe(true); + } + ); + + test('inverts a wheel over a non-scrollable row', () => { + const { position, getByTestId } = setup(); + const item = getByTestId('item'); + setScrollMetrics(item, horizontal, 0, 100); + expect(wheel(item, 20).defaultPrevented).toBe(true); + expect(position()).toBe(180); + }); + + test.each([ + [50, 20, 70, 200], + [195, 20, 200, 185], + [5, -20, 0, 215] + ])( + 'consumes a descendant at %s before passing leftover delta %s', + (offset, delta, expectedChild, expectedList) => { + const { position, getByTestId } = setup(); + const nested = getByTestId('nested'); + const childPosition = setScrollMetrics(nested, horizontal, offset, 300); + expect(wheel(nested, delta).defaultPrevented).toBe(true); + expect(childPosition()).toBe(expectedChild); + expect(position()).toBe(expectedList); + } + ); + + test('leaves non-inverted wheel scrolling to the browser', () => { + const { list, position } = setup(false); + expect(wheel(list, 20).defaultPrevented).toBe(false); + expect(position()).toBe(200); + }); + } +); diff --git a/packages/react-native-web/src/vendor/react-native/VirtualizedList/index.js b/packages/react-native-web/src/vendor/react-native/VirtualizedList/index.js index 97c817f0b2..26870c3d42 100644 --- a/packages/react-native-web/src/vendor/react-native/VirtualizedList/index.js +++ b/packages/react-native-web/src/vendor/react-native/VirtualizedList/index.js @@ -457,7 +457,7 @@ class VirtualizedList extends StateSafePureComponent { const scrollOffset = this.props.horizontal ? ev.target.scrollLeft : ev.target.scrollTop; const scrollLength = this.props.horizontal ? ev.target.scrollWidth : ev.target.scrollHeight; const clientLength = this.props.horizontal ? ev.target.clientWidth : ev.target.clientHeight; - const isEventTargetScrollable = scrollLength > clientLength; + const isEventTargetScrollable = ev.target !== ev.currentTarget && scrollLength > clientLength; const delta = this.props.horizontal ? ev.deltaX || ev.wheelDeltaX : ev.deltaY || ev.wheelDeltaY;