fix: end-aligned scroll completion — pendingTotalSize deadlock, watchdog bounding, moving-target slack#485
Conversation
getContentSize prefers state.pendingTotalSize when computing scroll targets, but the pending size is only committed to the rendered container in finishScrollTo. During a programmatic scrollToEnd this deadlocks: the dispatched target needs the container at the pending size, the container keeps the previous committed size until the scroll finishes, and the native scroll view clamps every dispatch (including fallback retries) to the smaller reachable range. The scroll settles short by exactly the uncommitted delta, and once finishScrollTo commits the pending size the missed distance becomes permanent because anchoredEndSpace-style end insets keep contentSize (and therefore maxScroll) constant. Observed in a streaming chat: target 2095 vs reachable 2071, five futile fallback retries, then finish 24 short; with chunks arriving during an animated scroll the pending delta grows to hundreds of pixels. After finishScrollTo commits the pending size for a non-initial end-aligned last-item target, wait two frames for the container to take the committed size and re-dispatch one unanimated scroll to the freshly recomputed end target. The correction is one-shot, bails if a new scroll session started, and only ever scrolls toward the end, so it cannot loop or fight user scrolling.
checkFinishedScrollFallback armed a new watchdog closure without clearing the pending one, so every re-arm (each dispatch while a scroll session stayed unresolved) leaked a concurrent retry loop. With an unreachable target the loops multiplied into thousands of 100ms timers, each issuing futile native scroll dispatches. Clearing on re-arm alone is not enough: the retry cap (numChecks) is closure-local, so continuous re-dispatches would reset it forever and the finish escape could never fire. Track the check count on state, keyed by the active scrollingTo session, and force-finish once a single session exceeds MAX_FALLBACK_CHECKS_PER_SESSION regardless of how many times the watchdog was re-armed. New sessions reset the counter, so normal scrolls are unaffected.
While content streams into the last item, the recomputed end-aligned target moves on every measurement and pendingTotalSize keeps it roughly one uncommitted delta beyond the reachable native range. Requiring sub-pixel alignment (< 1) meant the completion check could never pass, so the fallback watchdog kept re-dispatching unanimated scrolls against a moving target, which shows up as visible jumping while a chat response streams in. Treat end-aligned last-item targets as resolved within a small slack and finish the session promptly. The post-commit correction from the previous change then snaps the exact final position once, after the pending size has been committed.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 26e65ff179
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } | ||
| const correctedTarget = getCurrentTargetOffset(ctx, scrollingTo); | ||
| if (correctedTarget > state.scroll + 1) { | ||
| scrollToFallbackOffset(ctx, correctedTarget); |
There was a problem hiding this comment.
Convert RTL horizontal correction offsets before dispatch
For horizontal RTL lists on native, the new post-commit correction path passes correctedTarget as a logical offset directly to scrollTo. The normal doScrollTo path converts horizontal RTL offsets with toNativeHorizontalOffset; without that conversion, an inverted/negative RTL scroller can receive the wrong native x value and jump away from the end when this delayed correction fires after an end-aligned scroll.
Useful? React with 👍 / 👎.
Three related fixes for
scrollToEnd-style (end-aligned, last-item,viewPosition: 1) scroll sessions, found while debugging an AI-chat screen built onKeyboardAwareLegendList+anchoredEndSpace+ streaming responses. Instrumented logs for each mechanism are included below. Builds on the target-recompute machinery fromeefb9c5b(iOS retry when measurements shift the target).1.
fix: correct end-aligned scroll position after pendingTotalSize commitsgetContentSizeprefersstate.pendingTotalSize, but the pending size is committed to the rendered container only infinishScrollTo. During a programmatic scroll this deadlocks: the dispatched target assumes the pending size, the native container keeps the previous committed size until the scroll finishes, and the native view clamps every dispatch — including fallback retries — to the smaller reachable range.Observed (chat send):
target 2095,contentSize 2892, scroll pinned at2071— short by exactly the uncommitted delta — with 5 futile retries before the check cap finished the session. With chunks arriving during an animated scroll the delta grows to hundreds of px, and with ananchoredEndSpaceend inset the miss becomes permanent (content shrink and inset growth cancel, somaxScrollnever moves).Fix: after
finishScrollTocommits the pending size for a non-initial end-aligned target, wait two frames and re-dispatch one unanimated scroll to the freshly recomputed end target. One-shot, bails if a new session started, only scrolls toward the end.2.
fix: bound the scroll-completion fallback watchdog per sessioncheckFinishedScrollFallbackarmed new watchdog closures without clearing pending ones, so each re-arm leaked a concurrent retry loop; with an unreachable target this multiplied into thousands of live 100ms timers issuing native dispatches. Clearing on re-arm alone is insufficient (the closure-localnumChecksresets forever under continuous re-arms), so the check count is also tracked on state perscrollingTosession with a hard cap.3.
fix: complete end-aligned scrolls within slack instead of pixel-chasingWhile content streams into the last item the recomputed end target moves on every measurement and stays ~one uncommitted delta out of native reach, so the
< 1completion check can never pass — the watchdog re-dispatches against a moving target, which is visible jumping during streaming. End-aligned targets now resolve within a small slack; the post-commit correction from (1) snaps the exact position once afterwards.Notes
isEndAlignedLastItemTarget/getCurrentTargetOffset/scrollToFallbackOffsetinto a module bothcheckFinishedScrollandfinishScrollTocan import without a cycle).bun run tsc:src,biome check, andbun testpass —checkFinishedScroll.test.ts15/15 including theeefb9c5bregression test; the 3 failures incalculateItemsInView.test.tsare pre-existing onmain.maintainVisibleContentPosition: {size: true}accumulatesscrollAdjustper stream chunk while pinned at an anchored end (observed 28 → 244), displacing rendered content; (b) fresh items are seeded with the average measured size, soanchoredEndSpacecomputed before real measurement can dispatch against phantom sizes.🤖 Generated with Claude Code