Describe the bug
On iOS, TerminalView cannot be scrolled by the user when data is fed via DispatchQueue.main.async from a background thread — the standard pattern for WebSocket connections.
Feeding the same data at the same rate via Timer.scheduledTimer works perfectly.
Root cause
DispatchQueue.main.async fires during all RunLoop modes, including .tracking (active while the user is scrolling). Each feed() call triggers updateScroller() which unconditionally resets contentOffset to the bottom. This fights the scroll gesture.
Timer.scheduledTimer only fires in .default RunLoop mode, which is suspended during .tracking. So the Timer naturally pauses data delivery while the user scrolls — that's why it works.
To Reproduce
Minimal single-file repro (Demo: SwiftTermScrollBugRepro.swift.txt):
- Create a new iOS project, add SwiftTerm as a package dependency
- Replace
ViewController.swift with the attached file
- Run on device or simulator
- Tap "Async" — 100 lines/sec via
DispatchQueue.global() → DispatchQueue.main.async → feed()
- Try scrolling up — impossible
- Tap "Stop", then "Timer" — same data, same rate, via
Timer.scheduledTimer
- Try scrolling up — works fine
Both feeds deliver identical data at ~100 lines/sec. Only the dispatch mechanism differs.
Expected behavior
The user should be able to scroll up through the terminal scrollback buffer while data is being fed via DispatchQueue.main.async. This is the standard pattern for any app receiving data from WebSocket, URLSession, or other async sources.
Smartphone (please complete the following information):
- Device: iPhone (tested on multiple models)
- OS: iOS 18.x
- Version: SwiftTerm main branch, Xcode 16.x
Additional context
Why this matters
Any iOS app using SwiftTerm with a network backend (WebSocket, SSH via NIO, etc.) will hit this bug, because the natural pattern is:
// Background thread receives data, dispatches to main for UI update
DispatchQueue.main.async {
terminalView.feed(text: data)
}
This is fundamentally broken for scrolling because DispatchQueue.main.async fires during .tracking RunLoop mode.
A client-side workaround exists (buffer data and drain via Timer.scheduledTimer so delivery pauses during scroll tracking), but:
- It requires every SwiftTerm consumer to implement the same buffer hack
- It only works for simple text feeds — with real terminal sessions (escape sequences, cursor positioning, resize events), we still see rendering issues (lines at wrong positions,
contentOffset.x drift, inability to scroll through scrollback)
The fix should be in updateScroller() itself — it should not reset contentOffset while the user is actively scrolling.
PR #468 by @alvarolb adds a userScrolling flag that guards contentOffset updates in updateScroller(). We applied it to a local fork and it fixed the core scrolling problem — the user can scroll up while data is streaming.
However, we ran into secondary contentOffset ↔ yDisp synchronization issues:
- Lines rendered in wrong order — When the circular buffer wraps during scrolling,
contentOffset stays fixed but displayBuffer.lines[N] returns different content (because startIndex shifted).
- Can't scroll to the very top / very bottom —
userScrolling stays true after the gesture ends (only cleared when user scrolls to the exact bottom). New content at the bottom becomes unreachable.
- Content glitch on over-scroll — When bouncing past the top of the scroll range, incorrect rows briefly appear.
We tried splitting userScrolling (finger on screen) from userScrolledBack (user not at bottom, don't auto-scroll) and various delta adjustments, but couldn't fully resolve the rendering issues (30+ test variations).
What's the recommended way to keep contentOffset and yDisp in sync during user scrolling? Should yDisp be continuously updated from contentOffset during the scroll gesture? Should contentOffset be adjusted when the buffer shifts, and if so, how to prevent it from fighting the scroll?
Any guidance would be very appreciated. Happy to test patches.
Describe the bug
On iOS,
TerminalViewcannot be scrolled by the user when data is fed viaDispatchQueue.main.asyncfrom a background thread — the standard pattern for WebSocket connections.Feeding the same data at the same rate via
Timer.scheduledTimerworks perfectly.Root cause
DispatchQueue.main.asyncfires during all RunLoop modes, including.tracking(active while the user is scrolling). Eachfeed()call triggersupdateScroller()which unconditionally resetscontentOffsetto the bottom. This fights the scroll gesture.Timer.scheduledTimeronly fires in.defaultRunLoop mode, which is suspended during.tracking. So the Timer naturally pauses data delivery while the user scrolls — that's why it works.To Reproduce
Minimal single-file repro (Demo: SwiftTermScrollBugRepro.swift.txt):
ViewController.swiftwith the attached fileDispatchQueue.global()→DispatchQueue.main.async→feed()Timer.scheduledTimerBoth feeds deliver identical data at ~100 lines/sec. Only the dispatch mechanism differs.
Expected behavior
The user should be able to scroll up through the terminal scrollback buffer while data is being fed via
DispatchQueue.main.async. This is the standard pattern for any app receiving data from WebSocket, URLSession, or other async sources.Smartphone (please complete the following information):
Additional context
Why this matters
Any iOS app using SwiftTerm with a network backend (WebSocket, SSH via NIO, etc.) will hit this bug, because the natural pattern is:
This is fundamentally broken for scrolling because
DispatchQueue.main.asyncfires during.trackingRunLoop mode.A client-side workaround exists (buffer data and drain via
Timer.scheduledTimerso delivery pauses during scroll tracking), but:contentOffset.xdrift, inability to scroll through scrollback)The fix should be in
updateScroller()itself — it should not resetcontentOffsetwhile the user is actively scrolling.PR #468
PR #468 by @alvarolb adds a
userScrollingflag that guardscontentOffsetupdates inupdateScroller(). We applied it to a local fork and it fixed the core scrolling problem — the user can scroll up while data is streaming.However, we ran into secondary
contentOffset↔yDispsynchronization issues:contentOffsetstays fixed butdisplayBuffer.lines[N]returns different content (becausestartIndexshifted).userScrollingstaystrueafter the gesture ends (only cleared when user scrolls to the exact bottom). New content at the bottom becomes unreachable.We tried splitting
userScrolling(finger on screen) fromuserScrolledBack(user not at bottom, don't auto-scroll) and various delta adjustments, but couldn't fully resolve the rendering issues (30+ test variations).What's the recommended way to keep
contentOffsetandyDispin sync during user scrolling? ShouldyDispbe continuously updated fromcontentOffsetduring the scroll gesture? ShouldcontentOffsetbe adjusted when the buffer shifts, and if so, how to prevent it from fighting the scroll?Any guidance would be very appreciated. Happy to test patches.