Skip to content

maintainVisibleContentPosition under-corrects by the list's document offset when useWindowScroll is set #530

Description

@nick87kelly

maintainVisibleContentPosition under-corrects by the list's document offset when useWindowScroll is set

Version: 3.3.3 (web build, react-native.web.mjs)

Summary

With useWindowScroll, every maintainVisibleContentPosition correction is short
by the list element's offset in the document. Prepending a page of items shifts the
viewport by that constant amount instead of holding position, and because the
correction re-runs as each new row measures in, it reads as a repeating hop rather
than a single jump.

In our app the list sits below 104px of sticky chrome, and the observed shift was
exactly 104px per adjustment.

Cause

ScrollAdjust mixes two coordinate spaces:

// src/components/ScrollAdjust (react-native.web.mjs)
const currentScroll  = horizontal ? el.scrollLeft : el.scrollTop;
const intendedScroll = userOffsetDelta !== 0
  ? currentScroll + userOffsetDelta
  : ctx.state.scroll;
const scrollDelta    = intendedScroll - currentScroll;
  • el comes from getScrollAdjustTarget → refScroller.current.getScrollableNode()
    → resolveScrollableNode(node, isWindowScroll), which under useWindowScroll
    returns getDocumentScrollerNode(). So el.scrollTop is an absolute
    document scroll offset.
  • ctx.state.scroll is list-relative in that mode. It's fed by
    getCurrentScrollOffset(), which computes
    horizontal ? scroll.x - listPos.left : scroll.y - listPos.top.

requestAdjust correctly advances the intent (state.scroll += positionDiff), so
the mechanism itself is sound — the subtraction is just between an absolute and a
list-relative value, leaving scrollDelta = positionDiff - listPos.top.

This only manifests when the list is not at the very top of the document; with
listPos.top === 0 the two spaces coincide, which is probably why it isn't caught
by the common case.

Suggested fix

The scroller already exposes the value in the right space, and it's axis-aware:

const scroller = ctx.state.refScroller.current;
const currentScroll = typeof scroller?.getCurrentScrollOffset === "function"
  ? scroller.getCurrentScrollOffset()
  : horizontal ? el.scrollLeft : el.scrollTop;

For contained lists getCurrentScrollOffset() returns exactly
scrollElement.scrollTop / scrollLeft, so contained behaviour is unchanged.

We're running this as a local patch and it resolves the issue.

Possibly related, same mode

Under useWindowScroll, getScrollAdjustTarget resolves contentNode via
scrollElement.querySelector(":scope > .<content container class>"). Since
scrollElement is the document scroller, the content container isn't a direct
child, so contentNode is null and the needsTemporaryPadding path is skipped
entirely. Contained lists get that safety net for adjustments that need room past
the current content end; window-scrolled lists don't. We haven't hit a concrete
failure from it, so flagging rather than claiming.

Environment

React 19.1, react-native-web, Next.js 15 (App Router), Chrome. Variable-height
rows, bidirectional pagination (onStartReached / onEndReached),
maintainVisibleContentPosition, initialScrollAtEnd.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions