Skip to content

fix: end-aligned scroll completion — pendingTotalSize deadlock, watchdog bounding, moving-target slack#485

Open
Nodonisko wants to merge 4 commits into
LegendApp:mainfrom
Nodonisko:fix/end-aligned-scroll-completion
Open

fix: end-aligned scroll completion — pendingTotalSize deadlock, watchdog bounding, moving-target slack#485
Nodonisko wants to merge 4 commits into
LegendApp:mainfrom
Nodonisko:fix/end-aligned-scroll-completion

Conversation

@Nodonisko

Copy link
Copy Markdown

Three related fixes for scrollToEnd-style (end-aligned, last-item, viewPosition: 1) scroll sessions, found while debugging an AI-chat screen built on KeyboardAwareLegendList + anchoredEndSpace + streaming responses. Instrumented logs for each mechanism are included below. Builds on the target-recompute machinery from eefb9c5b (iOS retry when measurements shift the target).

1. fix: correct end-aligned scroll position after pendingTotalSize commits

getContentSize prefers state.pendingTotalSize, but the pending size is committed to the rendered container only in finishScrollTo. 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 at 2071 — 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 an anchoredEndSpace end inset the miss becomes permanent (content shrink and inset growth cancel, so maxScroll never moves).

Fix: after finishScrollTo commits 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 session

checkFinishedScrollFallback armed 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-local numChecks resets forever under continuous re-arms), so the check count is also tracked on state per scrollingTo session with a hard cap.

3. fix: complete end-aligned scrolls within slack instead of pixel-chasing

While 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 < 1 completion 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

  • First commit is a pure refactor (extracts isEndAlignedLastItemTarget/getCurrentTargetOffset/scrollToFallbackOffset into a module both checkFinishedScroll and finishScrollTo can import without a cycle).
  • bun run tsc:src, biome check, and bun test pass — checkFinishedScroll.test.ts 15/15 including the eefb9c5b regression test; the 3 failures in calculateItemsInView.test.ts are pre-existing on main.
  • Two further findings from the same investigation, happy to file as separate issues: (a) maintainVisibleContentPosition: {size: true} accumulates scrollAdjust per stream chunk while pinned at an anchored end (observed 28 → 244), displacing rendered content; (b) fresh items are seeded with the average measured size, so anchoredEndSpace computed before real measurement can dispatch against phantom sizes.

🤖 Generated with Claude Code

Nodonisko added 4 commits July 2, 2026 18:09
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.
@Nodonisko Nodonisko marked this pull request as ready for review July 2, 2026 16:24

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

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.

1 participant