Skip to content

fix(scroll): keep autoscroll to bottom when item sizes settle - #2457

Open
dennytosp wants to merge 1 commit into
Shopify:mainfrom
dennytosp:fix/autoscroll-to-bottom-size-change
Open

fix(scroll): keep autoscroll to bottom when item sizes settle#2457
dennytosp wants to merge 1 commit into
Shopify:mainfrom
dennytosp:fix/autoscroll-to-bottom-size-change

Conversation

@dennytosp

@dennytosp dennytosp commented Aug 20, 2026

Copy link
Copy Markdown

Fixes #1903
Related: #1666

Problem

A chat-style list with maintainVisibleContentPosition.autoscrollToBottomThreshold stops sticking to the bottom as soon as item heights settle after the row mounts — an image finishing its load, a link preview appearing, a message wrapping onto another line.

useBoundDetection runs the autoscroll check on content size changes, but guards it so it does not fight an active scroll:

useEffect(() => {
  if (Date.now() - lastCheckBoundsTime.current >= 100) {
    runAutoScrollToBottomCheck();
  }
}, [contentHeight, contentWidth, ...]);

Inside that 100ms window the check is dropped, not deferred — and it never comes back:

  1. content grows → the effect runs within 100ms of the last checkBounds → skipped
  2. the taller content puts the bottom out of reach, so the next checkBounds computes isNearBottom === false and clears pendingAutoscrollToBottom
  3. nothing else changes, so no further effect run ever fires the autoscroll

Item heights settling a frame after new content arrives lands squarely inside that window, which is why this reproduces so consistently with dynamic-height rows.

Fix

Defer instead of drop. When the size change arrives mid-scroll, wait out the same 100ms and then run the autoscroll that was owed.

The deferred run is cancelled if the scroll offset moved in the meantime. That is the signal that separates the two cases, and it is a direct measurement rather than a heuristic:

  • content grew below the viewport → offset untouched → nothing is scrolling, and this is precisely the case autoscroll exists for
  • the user dragged away → offset changed → checkBounds has already recorded whether they are still near the bottom, so leave the decision to it and do not yank them back down

The deferred run goes through runAutoScrollToBottomCheck (with a force flag) rather than resurrecting pendingAutoscrollToBottom, so it shares the existing isOffsetProjectionEnabled guard — a scrollToIndex in flight is still not hijacked — and no stale flag is left behind for a later data change to fire.

Tests

Four tests in RecyclerView.test.tsx under autoscroll to bottom when item sizes change:

  • items growing right after a scroll still autoscroll once the scroll goes quiet (the bug)
  • items growing well after the last scroll still autoscroll immediately (control — unchanged path)
  • scrolling away while the growth settles does not autoscroll (control)
  • a list that was never near the bottom does not autoscroll (control)

Each guard was mutation-tested rather than assumed:

mutation result
revert the fix entirely only "still autoscrolls when items grow right after a scroll" fails
keep the deferral, delete the offset check only "does not autoscroll if the user scrolls away" fails

A fifth test was written and then deleted: it asserted no autoscroll on top of a settling scrollToIndex, and instrumenting it showed the deferred path was never entered — it passed with the guard removed, so it was testing nothing.

yarn test --forceExit   191 passed
yarn type-check         clean
yarn lint               clean

Scope note

This is unit-verified. #1903 also involves startRenderingFromBottom with items prepended through onStartReached, so there may be a second contributing factor in the MVCP anchoring on prepend; this change fixes the size-settling half, which is the part the report demonstrates and the part #1666 is entirely about.

On #1666 — the reply there was that the feature is meant to scroll on data change, not on item size change. Worth noting the effect already depends on contentHeight/contentWidth, so the size-change path exists in the code today; it is just lossy inside the guard window. This makes the existing path reliable rather than adding new behaviour. Happy to narrow it to data-change-only if you'd rather keep that boundary.

useBoundDetection skips the autoscroll check when the content size changes
within 100ms of the last bounds check, so an active scroll is not fought.
The check is dropped rather than deferred, and the taller content then
pushes the bottom out of reach, so the next checkBounds clears the pending
autoscroll and nothing ever brings it back.

Items measuring to their real height a frame after they mount is exactly
that case, which is why a list with dynamic item heights stops sticking to
the bottom: an image finishing its load or a message wrapping onto another
line is enough to lose it.

Defer the check to the moment the scroll goes quiet instead of dropping it,
and run the autoscroll that was owed. The deferred run is cancelled if the
scroll offset moved in the meantime, which is what separates the two cases:
content growing below the viewport leaves the offset untouched, while a
user who takes over changes it and keeps ownership of the position.

The deferred run goes through runAutoScrollToBottomCheck rather than
resurrecting the pending flag, so scrollToEnd stays suppressed while offset
projection is disabled and no stale flag is left behind for a later data
change to fire.

Fixes Shopify#1903
@dennytosp
dennytosp force-pushed the fix/autoscroll-to-bottom-size-change branch from f1b9ca8 to a0c4fbf Compare August 20, 2026 05:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

autoscrollToBottom does not stay at bottom on dynamic height item updates

1 participant