Skip to content

fix(core): fix _hasManualScroll lifecycle for correct sticky scroll re-engagement#816

Draft
Matteo-DelliRocioli wants to merge 2 commits intoanomalyco:mainfrom
Matteo-DelliRocioli:fix/scrollbox-sticky-manual-scroll-guard
Draft

fix(core): fix _hasManualScroll lifecycle for correct sticky scroll re-engagement#816
Matteo-DelliRocioli wants to merge 2 commits intoanomalyco:mainfrom
Matteo-DelliRocioli:fix/scrollbox-sticky-manual-scroll-guard

Conversation

@Matteo-DelliRocioli
Copy link
Copy Markdown

@Matteo-DelliRocioli Matteo-DelliRocioli commented Mar 14, 2026

Summary

Two fixes to make _hasManualScroll behave correctly in ScrollBoxRenderable:

  1. Guard the else branch in recalculateBarProps() with !this._hasManualScroll, making it consistent with the stickyStart branch which already has this guard.

  2. Remove redundant _hasManualScroll = true sets in onMouseEvent and handleKeyPress that unconditionally override the correct state already managed by the scrollTop/scrollLeft setters and updateStickyState().

Context

This is a follow-up to the work done in #531 and #722, which addressed two earlier aspects of the same underlying problem:

PR #722 solved the flag corruption (the flag is no longer accidentally cleared), but two problems remained:

Problem 1: Missing guard in recalculateBarProps()

The else branch force-scrolls via _stickyScrollBottom / _stickyScrollTop without checking _hasManualScroll:

// Branch 1: correctly guarded ✅
if (this._stickyStart && !this._hasManualScroll) {
    this.applyStickyStart(this._stickyStart)
// Branch 2: missing guard ❌
} else {
    if (this._stickyScrollBottom && newMaxScrollTop > 0) {
        this.scrollTop = newMaxScrollTop  // force-scrolls even if user scrolled up
    }
}

Fix: Change } else { to } else if (!this._hasManualScroll) {.

Problem 2: Redundant _hasManualScroll = true in input handlers

Both onMouseEvent (line 571) and handleKeyPress (lines 585, 591) unconditionally set _hasManualScroll = true after scroll events. This runs after updateStickyState() has already correctly cleared the flag (when the user is at the sticky edge), permanently re-setting it to true:

1. onMouseEvent → this.scrollTop += delta
2.   scrollTop setter → updateStickyState() → "at bottom" → _hasManualScroll = false ✅
3. Back in onMouseEvent:
     this._hasManualScroll = true  // ← overrides the correct state! ❌

This means scrolling to the bottom never re-engages auto-scroll, because _hasManualScroll is stuck on true.

Fix: Remove the redundant sets. The scrollTop/scrollLeft setters already handle _hasManualScroll correctly with isAtStickyPosition() checks, and the scrollbar onChange callbacks do the same. The removed code only ran in two cases:

  • After actual scroll movement → already handled by the setter
  • After sub-pixel accumulation (no actual movement) → shouldn't mark as manual scroll anyway

Why this matters for consumers with stickyStart set

Any consumer using stickyStart="bottom" (e.g., OpenCode's session view) is affected:

  • Normal auto-scroll (user hasn't scrolled up): _stickyStart is truthy, _hasManualScroll is falsetrue && truebranch 1 runsapplyStickyStart("bottom") → correctly auto-scrolls. Branch 2 is never reached.

  • User scrolls up during streaming: _stickyStart is still truthy, but _hasManualScroll is now truetrue && falsebranch 1 fails → falls to branch 2. Without the guard, branch 2 force-scrolls back to bottom.

So for any consumer with stickyStart set, the only way to reach branch 2 is when _hasManualScroll is true — exactly the case where force-scrolling should be blocked.

How it works end-to-end

  • User scrolls up during streaming: _hasManualScroll = true → guard blocks force-scrolling → viewport stays put
  • User scrolls back to bottom: scrollTop setter sees isAtStickyPosition() === true → doesn't set _hasManualScroll; updateStickyState() clears it → _hasManualScroll = false → auto-scroll resumes on next content resize
  • On submit / session change: scrollTo resets position → _hasManualScroll clears → sticky scroll re-engages

Test plan

  • Verify auto-scroll to bottom works normally when not manually scrolling
  • Scroll up during streaming content growth → viewport should stay at user's position
  • Scroll back to bottom → auto-scroll should re-engage
  • Test with stickyStart="bottom" and without stickyStart set
  • Verify horizontal sticky scroll behaves consistently
  • Keyboard scrolling (arrow keys / page up/down) follows same behavior

…rProps

The else branch in recalculateBarProps() was force-scrolling to
bottom/top via _stickyScrollBottom/_stickyScrollTop without checking
_hasManualScroll, causing the viewport to yank back to bottom during
streaming even when the user had scrolled up.

Add the same !_hasManualScroll guard that the stickyStart branch
already uses, making both paths consistent.
onMouseEvent and handleKeyPress both unconditionally set
_hasManualScroll = true after scroll events, overriding the correct
state set by the scrollTop/scrollLeft setters and updateStickyState().

This caused _hasManualScroll to be permanently stuck on true after any
scroll event — even when the user scrolled back to the bottom — because
the unconditional set ran after updateStickyState() had already cleared
the flag.

The scrollTop/scrollLeft setters already handle _hasManualScroll
correctly with isAtStickyPosition() checks, and the scrollbar onChange
callbacks do the same. These redundant sets are not needed and actively
break sticky scroll re-engagement.
@Matteo-DelliRocioli Matteo-DelliRocioli changed the title fix(core): guard sticky scroll against manual scroll in recalculateBarProps fix(scrollbox): fix _hasManualScroll lifecycle for correct sticky scroll re-engagement Mar 14, 2026
@Matteo-DelliRocioli Matteo-DelliRocioli changed the title fix(scrollbox): fix _hasManualScroll lifecycle for correct sticky scroll re-engagement fix(core): fix _hasManualScroll lifecycle for correct sticky scroll re-engagement Mar 14, 2026
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 14, 2026

@opentui/core

npm i https://pkg.pr.new/@opentui/core@816

@opentui/react

npm i https://pkg.pr.new/@opentui/react@816

@opentui/solid

npm i https://pkg.pr.new/@opentui/solid@816

@opentui/core-darwin-arm64

npm i https://pkg.pr.new/@opentui/core-darwin-arm64@816

@opentui/core-darwin-x64

npm i https://pkg.pr.new/@opentui/core-darwin-x64@816

@opentui/core-linux-arm64

npm i https://pkg.pr.new/@opentui/core-linux-arm64@816

@opentui/core-linux-x64

npm i https://pkg.pr.new/@opentui/core-linux-x64@816

@opentui/core-win32-arm64

npm i https://pkg.pr.new/@opentui/core-win32-arm64@816

@opentui/core-win32-x64

npm i https://pkg.pr.new/@opentui/core-win32-x64@816

commit: 3c4fb75

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