ScrollBox stickyScroll: a layout shrink re-arms manual-scroll re-engagement and drags the reader to the tail
Summary
With stickyScroll + stickyStart="bottom", a reader who has scrolled up into history is pulled back to the live tail after any layout change that shortens the scroll range. The cause is the re-engagement path added in #1088: a clamp that the scrollbar applies when the range shrinks is indistinguishable from the user deliberately scrolling back to the bottom.
Verified against @opentui/core 0.4.5. I also diffed the whole ScrollBoxRenderable class against 0.5.11 — apart from minifier renames it is byte-identical, and isAtStickyReengagePoint / recalculateBarProps are unchanged, so this is still present on the latest release.
Root cause
recalculateBarProps():
} else if (stickyStart && this._hasManualScroll &&
this.isAtStickyReengagePoint(stickyStart, newMaxScrollTop, newMaxScrollLeft)) {
this._hasManualScroll = false;
this.applyStickyStart(stickyStart);
}case "bottom":
return maxScrollTop > 0 && this.scrollTop >= maxScrollTop - 1;The scrollbar position setter clamps into [0, maxScrollTop]. So when a layout change makes the viewport taller — a sibling pane below shrinking, a collapsible block above the viewport collapsing, anything that reduces content.height - viewport.height below the current scrollTop — the position is clamped to the new maximum. That satisfies scrollTop >= maxScrollTop - 1, which clears _hasManualScroll and re-arms sticky bottom, so every subsequent content growth drags the viewport down.
The - 1 threshold is deliberate (#1088 added it because exact equality is unreliable while content is growing), but it cannot tell "the user scrolled back to the bottom" apart from "the layout clamped us to the bottom".
Reproduction
A scrollbox that flexes against a sibling whose height can change (a dock, composer, or status area):
function Fixture(props) {
return (
<box flexDirection="column" height={20} width={40}>
<scrollbox ref={props.scroll} stickyScroll={true} stickyStart="bottom" flexGrow={1}>
<For each={props.rows()}>{(row) => <text>{row}</text>}</For>
</scrollbox>
<box height={props.dock()} />
</box>
);
}- Mount with 60 one-row children and
dock={6}. scroll.scrollBy(-5)— the user reads history, so_hasManualScrollis now true.- Set
dock={0}. The viewport grows,maxScrollTopdrops from 46 to 40, andscrollTopis clamped from 41 to 40. - Append three rows.
Measured with the test renderer from @opentui/solid (testRender), reading scrollTop / scrollHeight / viewport.height:
mounted top=46 max=46 height=60 viewport=14
scrolled -5 top=41 max=46 height=60 viewport=14 <- reading history
dock 0 top=40 max=40 height=60 viewport=20 <- clamped; manual state cleared
append top=43 max=43 height=63 viewport=20 <- reader dragged to the tail
append top=46 max=46 height=66 viewport=20Expected vs actual
- Expected: after step 3 the reader stays where they were reading, and follow stays released until they actually reach the bottom themselves.
- Actual: follow is silently re-armed, and every new line moves the viewport.
Worth noting: scrolling all the way to the top does not reproduce it, because scrollTop is already 0 and the clamp has nowhere to push it. That is a useful discriminator when triaging this (it is why the bug only appears once the content is taller than the viewport).
Secondary observation — possibly out of scope
Independent of the above: when a box above the viewport changes height, everything below it shifts while scrollTop stays put, so the reader's text slides. This is the gap that overflow-anchor fills on the web. It is a missing capability rather than a defect in the re-engagement logic, so it may not belong in this issue — mentioning it only as context.
Workaround
Handled in the application layer for now: tracking follow state from the scroll position rather than OpenTUI's internal flag, and compensating scrollTop from Yoga geometry when the anchored box moves. I am not proposing a patch here on purpose — a fix has to satisfy both directions, i.e. the re-engagement from #1088 must still fire when the user genuinely returns to the bottom, while a range clamp must not.
Source: anomalyco/opentui