[GH-ISSUE #530] ScrollBox _hasManualScroll is never reset, breaking stickyScroll behavior #139

Closed
opened 2026-03-02 23:44:50 +03:00 by kerem · 1 comment
Owner

Originally created by @bradleat on GitHub (Jan 15, 2026).
Original GitHub issue: https://github.com/anomalyco/opentui/issues/530

Description

When using a ScrollBox with stickyScroll={true} and stickyStart="bottom", the sticky scroll behavior permanently breaks after the user scrolls manually. The scroll unexpectedly jumps to the top instead of staying at the bottom when new content is added.

Steps to Reproduce

  1. Create a scrollbox with stickyScroll={true} and stickyStart="bottom"
  2. Add enough content to enable scrolling
  3. Scroll up manually (mouse wheel or keyboard)
  4. Scroll back down to the bottom
  5. Add new content

Expected: Scroll stays at bottom (sticky behavior re-engages)
Actual: Scroll jumps to top randomly

Root Cause

In ScrollBoxRenderable, the _hasManualScroll flag is set to true in 7 different places but is never reset to false:

// Lines where _hasManualScroll is set to true:
// - scrollTop/scrollLeft setters
// - scrollbar onChange handlers  
// - mouse/keyboard handlers

// Only initialized to false, never reset
_hasManualScroll = false;

Once _hasManualScroll is true, recalculateBarProps() skips applyStickyStart():

if (this._stickyStart && !this._hasManualScroll) {
  this.applyStickyStart(this._stickyStart);  // Never called once _hasManualScroll is true
} else {
  if (this._stickyScrollTop) {
    this.scrollTop = 0;  // Scrolls to top unexpectedly
  }
}

The bug manifests when:

  1. Content is initially small (scrollTop = 0)
  2. updateStickyState() sets _stickyScrollTop = true
  3. User scrolls at some point, setting _hasManualScroll = true
  4. On content update, the else branch runs and scrolls to top

Proposed Fix

Reset _hasManualScroll = false when the user scrolls back to the sticky position. In updateStickyState():

if (this.scrollTop <= 0) {
  this._stickyScrollTop = true;
  this._stickyScrollBottom = false;
  // Reset manual scroll when back at sticky position
  if (this._stickyStart === 'top') {
    this._hasManualScroll = false;
  }
} else if (this.scrollTop >= maxScrollTop) {
  this._stickyScrollTop = false;
  this._stickyScrollBottom = true;
  // Reset manual scroll when back at sticky position
  if (this._stickyStart === 'bottom') {
    this._hasManualScroll = false;
  }
}

This allows sticky scroll to re-engage naturally when the user scrolls back to the designated sticky position.

Environment

  • @opentui/core: 0.1.72
  • Use case: Terminal emulator with streaming content (should stay at bottom)
Originally created by @bradleat on GitHub (Jan 15, 2026). Original GitHub issue: https://github.com/anomalyco/opentui/issues/530 ## Description When using a `ScrollBox` with `stickyScroll={true}` and `stickyStart="bottom"`, the sticky scroll behavior permanently breaks after the user scrolls manually. The scroll unexpectedly jumps to the top instead of staying at the bottom when new content is added. ## Steps to Reproduce 1. Create a scrollbox with `stickyScroll={true}` and `stickyStart="bottom"` 2. Add enough content to enable scrolling 3. Scroll up manually (mouse wheel or keyboard) 4. Scroll back down to the bottom 5. Add new content **Expected:** Scroll stays at bottom (sticky behavior re-engages) **Actual:** Scroll jumps to top randomly ## Root Cause In `ScrollBoxRenderable`, the `_hasManualScroll` flag is set to `true` in 7 different places but is **never reset to `false`**: ```javascript // Lines where _hasManualScroll is set to true: // - scrollTop/scrollLeft setters // - scrollbar onChange handlers // - mouse/keyboard handlers // Only initialized to false, never reset _hasManualScroll = false; ``` Once `_hasManualScroll` is `true`, `recalculateBarProps()` skips `applyStickyStart()`: ```javascript if (this._stickyStart && !this._hasManualScroll) { this.applyStickyStart(this._stickyStart); // Never called once _hasManualScroll is true } else { if (this._stickyScrollTop) { this.scrollTop = 0; // Scrolls to top unexpectedly } } ``` The bug manifests when: 1. Content is initially small (scrollTop = 0) 2. `updateStickyState()` sets `_stickyScrollTop = true` 3. User scrolls at some point, setting `_hasManualScroll = true` 4. On content update, the else branch runs and scrolls to top ## Proposed Fix Reset `_hasManualScroll = false` when the user scrolls back to the sticky position. In `updateStickyState()`: ```javascript if (this.scrollTop <= 0) { this._stickyScrollTop = true; this._stickyScrollBottom = false; // Reset manual scroll when back at sticky position if (this._stickyStart === 'top') { this._hasManualScroll = false; } } else if (this.scrollTop >= maxScrollTop) { this._stickyScrollTop = false; this._stickyScrollBottom = true; // Reset manual scroll when back at sticky position if (this._stickyStart === 'bottom') { this._hasManualScroll = false; } } ``` This allows sticky scroll to re-engage naturally when the user scrolls back to the designated sticky position. ## Environment - `@opentui/core`: 0.1.72 - Use case: Terminal emulator with streaming content (should stay at bottom)
kerem closed this issue 2026-03-02 23:44:50 +03:00
Author
Owner

@bradleat commented on GitHub (Jan 15, 2026):

Updated Fix

After further testing, I found an edge case in my original proposed fix. When content is smaller than the viewport (maxScrollTop === 0), both scrollTop <= 0 and scrollTop >= maxScrollTop are true. The code hits the first branch, setting _stickyScrollTop = true even when stickyStart="bottom".

The fix needs to account for this - when content fits in the viewport, we're effectively at both top and bottom:

updateStickyState() {
  if (!this._stickyScroll)
    return;
  const maxScrollTop = Math.max(0, this.scrollHeight - this.viewport.height);
  const maxScrollLeft = Math.max(0, this.scrollWidth - this.viewport.width);
  
  if (this.scrollTop <= 0) {
    this._stickyScrollTop = true;
    this._stickyScrollBottom = false;
    // Reset manual scroll when back at sticky position
    // When maxScrollTop is 0, content fits in viewport - we're at both top AND bottom
    if (this._stickyStart === "top" || (this._stickyStart === "bottom" && maxScrollTop === 0)) {
      this._hasManualScroll = false;
    }
  } else if (this.scrollTop >= maxScrollTop) {
    this._stickyScrollTop = false;
    this._stickyScrollBottom = true;
    // Reset manual scroll when back at sticky position
    if (this._stickyStart === "bottom") {
      this._hasManualScroll = false;
    }
  } else {
    this._stickyScrollTop = false;
    this._stickyScrollBottom = false;
  }
  
  // Same pattern for horizontal scrolling
  if (this.scrollLeft <= 0) {
    this._stickyScrollLeft = true;
    this._stickyScrollRight = false;
    // When maxScrollLeft is 0, content fits in viewport - we're at both left AND right
    if (this._stickyStart === "left" || (this._stickyStart === "right" && maxScrollLeft === 0)) {
      this._hasManualScroll = false;
    }
  } else if (this.scrollLeft >= maxScrollLeft) {
    this._stickyScrollLeft = false;
    this._stickyScrollRight = true;
    if (this._stickyStart === "right") {
      this._hasManualScroll = false;
    }
  } else {
    this._stickyScrollLeft = false;
    this._stickyScrollRight = false;
  }
}

I've tested this fix locally and it resolves the scroll-jump-to-top issue in our terminal emulator.

<!-- gh-comment-id:3757127726 --> @bradleat commented on GitHub (Jan 15, 2026): ## Updated Fix After further testing, I found an edge case in my original proposed fix. When content is smaller than the viewport (`maxScrollTop === 0`), both `scrollTop <= 0` and `scrollTop >= maxScrollTop` are true. The code hits the first branch, setting `_stickyScrollTop = true` even when `stickyStart="bottom"`. The fix needs to account for this - when content fits in the viewport, we're effectively at **both** top and bottom: ```javascript updateStickyState() { if (!this._stickyScroll) return; const maxScrollTop = Math.max(0, this.scrollHeight - this.viewport.height); const maxScrollLeft = Math.max(0, this.scrollWidth - this.viewport.width); if (this.scrollTop <= 0) { this._stickyScrollTop = true; this._stickyScrollBottom = false; // Reset manual scroll when back at sticky position // When maxScrollTop is 0, content fits in viewport - we're at both top AND bottom if (this._stickyStart === "top" || (this._stickyStart === "bottom" && maxScrollTop === 0)) { this._hasManualScroll = false; } } else if (this.scrollTop >= maxScrollTop) { this._stickyScrollTop = false; this._stickyScrollBottom = true; // Reset manual scroll when back at sticky position if (this._stickyStart === "bottom") { this._hasManualScroll = false; } } else { this._stickyScrollTop = false; this._stickyScrollBottom = false; } // Same pattern for horizontal scrolling if (this.scrollLeft <= 0) { this._stickyScrollLeft = true; this._stickyScrollRight = false; // When maxScrollLeft is 0, content fits in viewport - we're at both left AND right if (this._stickyStart === "left" || (this._stickyStart === "right" && maxScrollLeft === 0)) { this._hasManualScroll = false; } } else if (this.scrollLeft >= maxScrollLeft) { this._stickyScrollLeft = false; this._stickyScrollRight = true; if (this._stickyStart === "right") { this._hasManualScroll = false; } } else { this._stickyScrollLeft = false; this._stickyScrollRight = false; } } ``` I've tested this fix locally and it resolves the scroll-jump-to-top issue in our terminal emulator.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/opentui#139
No description provided.