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.
maintainVisibleContentPosition under-corrects by the list's document offset when
useWindowScrollis setVersion: 3.3.3 (web build,
react-native.web.mjs)Summary
With
useWindowScroll, everymaintainVisibleContentPositioncorrection is shortby 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
ScrollAdjustmixes two coordinate spaces:elcomes fromgetScrollAdjustTarget→refScroller.current.getScrollableNode()→
resolveScrollableNode(node, isWindowScroll), which underuseWindowScrollreturns
getDocumentScrollerNode(). Soel.scrollTopis an absolutedocument scroll offset.
ctx.state.scrollis list-relative in that mode. It's fed bygetCurrentScrollOffset(), which computeshorizontal ? scroll.x - listPos.left : scroll.y - listPos.top.requestAdjustcorrectly advances the intent (state.scroll += positionDiff), sothe 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 === 0the two spaces coincide, which is probably why it isn't caughtby the common case.
Suggested fix
The scroller already exposes the value in the right space, and it's axis-aware:
For contained lists
getCurrentScrollOffset()returns exactlyscrollElement.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,getScrollAdjustTargetresolvescontentNodeviascrollElement.querySelector(":scope > .<content container class>"). SincescrollElementis the document scroller, the content container isn't a directchild, so
contentNodeisnulland theneedsTemporaryPaddingpath is skippedentirely. 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.