Skip to content

Inverted VirtualizedList scrolls backwards when the wheel target is the scroller itself #2843

Description

@vemikrs

Is there an existing issue for this?

  • I have searched the existing issues

Describe the issue

The inverted wheel handler (invertedWheelEventHandler in src/vendor/react-native/VirtualizedList/index.js, introduced in #2223 and extended in #2436) has a path that first consumes the wheel delta on a nested scrollable element under the cursor:

const scrollOffset = ev.target.scrollTop;
const scrollLength = ev.target.scrollHeight;
const clientLength = ev.target.clientHeight;
const isEventTargetScrollable = scrollLength > clientLength;
...
ev.target.scrollTop += targetDelta;              // natural, non-inverted direction
node.scrollTop = node.scrollTop - leftoverDelta;  // inverted direction

This consume path was added in #2436 to correctly handle genuine descendant scrollables, such as a scrollable code block (or a nested TextInput) inside a row.

However, when the cursor is over the list scroller's own surface — for example its padding area, or the horizontal gutters created by a centered contentContainerStyle (maxWidth + alignSelf: 'center', a common desktop chat layout) — the wheel event target is the same element the listener is attached to (ev.target === ev.currentTarget).

In that case, the handler misidentifies the list scroller itself as a nested scrollable target and applies:

ev.target.scrollTop += targetDelta;

That writes to the inverted list in the natural, non-inverted direction. As a result, wheel scrolling over the gutter visibly scrolls the list backwards.

At scroll boundaries, targetDelta clamps and the remaining leftoverDelta is then applied to the same node in the inverted direction:

node.scrollTop = node.scrollTop - leftoverDelta;

So the same scroller can receive writes in both directions from the same handler path, which causes visible oscillation or drift near the top/bottom boundary.

This is easy to misdiagnose as an intermittent or viewport-dependent issue: opening DevTools narrows the viewport, the centered content column may cover the gutters, the cursor lands on list items, and the bug appears to disappear.

I confirmed this with instrumentation in a production app:

  • With the cursor over a list item, every wheel event scrolls in the expected inverted-corrected direction.
  • With the cursor over the gutter, the recorded scrollTop delta matches ev.deltaY 1:1, i.e. the natural non-inverted direction.
  • ev.defaultPrevented is true for 100% of wheel events, so the movement is caused by the handler writing scrollTop, not by native browser scrolling.

Expected behavior

Wheel scrolling over any part of an inverted list — including its own padding and the empty gutters beside a centered content column — should scroll in the same inverted-corrected direction as scrolling over a list item.

The nested-scrollable consume path should only apply to descendant scrollables, never to the list scroller itself.

Steps to reproduce

Environment confirmed:

  • react-native-web 0.21.2
  • React 19
  • Chrome / Edge, Chromium 149
  • Reproduced on both macOS and Windows

The bug is OS-independent by construction: invertedWheelEventHandler contains no platform branch — the behavior is decided purely by DOM hit-testing (ev.target vs ev.currentTarget), which is the same across platforms on Chromium. The affected handler shape appears to date back to the inverted wheel scroller patch, but I have only verified this repro on 0.21.2.

  1. Render an inverted FlatList whose content column is narrower than the scroller, using the code below, in a browser viewport wider than the column.
  2. Hover the mouse over a list item and use the mouse wheel.
  3. The list scrolls in the expected inverted-corrected direction.
  4. Move the mouse to the empty gutter to the left or right of the column, still inside the list scroller.
  5. Use the mouse wheel again.
  6. The list scrolls in the opposite direction. Near the top/bottom boundary, it may oscillate.
import React from 'react';
import { FlatList, Text, View } from 'react-native';

const data = Array.from({ length: 50 }, (_, i) => ({
  key: String(i),
  text: 'Message ' + i,
}));

export default function App() {
  return (
    <FlatList
      inverted
      data={data}
      style={{ height: '100%' }}
      contentContainerStyle={{
        maxWidth: 400,
        width: '100%',
        alignSelf: 'center',
        padding: 16,
      }}
      renderItem={({ item }) => (
        <View
          style={{
            padding: 12,
            margin: 8,
            backgroundColor: '#eee',
            borderRadius: 8,
          }}
        >
          <Text>{item.text}</Text>
        </View>
      )}
    />
  );
}
react-native-web-2843.mov

Test case

https://codesandbox.io/p/sandbox/gifted-babbage-g9yyxn

Additional comments

I searched existing issues (#995, #2500, #2658, #2233, #1790, #1254) and open/closed PRs touching this handler (#2223, #2436, #2601). Some discuss related inverted scrolling behavior or the same wheel handler, but I could not find an issue or PR for this specific mechanism — the wheel target being the scroller itself.

Proposed one-line fix: the element the wheel listener is attached to must not be treated as a nested scrollable target.

const isEventTargetScrollable =
  ev.target !== ev.currentTarget && scrollLength > clientLength;

Since the wheel listener is attached directly to the list's scrollable node, ev.currentTarget is the list scroller. Therefore, the nested-scrollable consume path should only run for descendant scrollables.

This is not a regression fix for #2436 but a guard for its edge case: the consume path #2436 introduced still runs for genuine descendants (over a row, ev.target !== ev.currentTarget), and only stops treating the scroller itself as a nested target.

We are running this fix in production via a package patch (verified on macOS and Windows, Chromium), and it resolves the issue without affecting genuine nested scrollables. Scrollable descendants inside rows are still consumed first, in the natural direction.

Happy to open a PR.

Possibly related: #2500 may be another manifestation of the same self-consume path, since ev.target.scrollTop += targetDelta and node.scrollTop -= leftoverDelta can both hit the same node.


While investigating this, we also found a separate issue in the same handler. With no getItemLayout, node.scrollTop can be clamped to this._totalCellLength, which is stale immediately after mount while cells are still being measured, causing jumps. I kept that out of scope here and can file it separately if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions