Skip to content

Commit 8bfc53f

Browse files
sirily11claude
andcommitted
fix(message-list): keep bottom anchor sticky through tall-card layout settle
A tall card (Edit diff, Bash output) that lays out in one frame grows the content height while the throttled/async scroll-to-bottom is still pending, leaving distanceFromBottom huge. A non-user-driven stable geometry frame in that window would recompute isNearBottom to false even though the user never scrolled, causing the pending auto-scroll to bail and stranding the view above the bottom. Gate the un-stick recompute in MessageListScrollAnchor.apply on a new isUserDriven flag so only a genuine user scroll releases the anchor; layout settles keep it sticky. Preserves the "only follow the bottom while at the bottom" semantics. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 798733d commit 8bfc53f

3 files changed

Lines changed: 64 additions & 9 deletions

File tree

Packages/Sources/MessageList/MessageList.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,8 @@ public struct MessageList<Message: MessageListItem, RowContent: View>: View {
277277
private func handleScrollMetrics(_ metrics: MessageListScrollMetrics, proxy: ScrollViewProxy) {
278278
let decision = anchor.apply(
279279
contentHeight: metrics.contentHeight,
280-
visibleMaxY: metrics.visibleMaxY
280+
visibleMaxY: metrics.visibleMaxY,
281+
isUserDriven: isUserDrivenScroll
281282
)
282283
updateIsAtBottomBinding(anchor.isNearBottom)
283284

Packages/Sources/MessageList/MessageListScrollAnchor.swift

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,14 @@ nonisolated struct MessageListScrollAnchor: Equatable {
1818
self.hasReceivedFirstUpdate = false
1919
}
2020

21+
/// Apply a new scroll geometry sample.
22+
///
23+
/// `isUserDriven` reports whether the change is driven by the user's finger /
24+
/// trackpad (or post-flick glide) rather than by layout or a programmatic
25+
/// scroll. It gates the only branch that can *un-stick* the anchor — see
26+
/// below for why that matters.
2127
@discardableResult
22-
mutating func apply(contentHeight: CGFloat, visibleMaxY: CGFloat) -> Decision {
28+
mutating func apply(contentHeight: CGFloat, visibleMaxY: CGFloat, isUserDriven: Bool) -> Decision {
2329
let distanceFromBottom = max(0, contentHeight - visibleMaxY)
2430
let nowNearBottom = distanceFromBottom < threshold
2531

@@ -39,6 +45,17 @@ nonisolated struct MessageListScrollAnchor: Equatable {
3945
return previouslyNearBottom ? .scrollToBottom : .none
4046
}
4147

48+
// Content stable (or shrunk). Recomputing `isNearBottom` from the raw
49+
// distance is the ONLY path that can un-stick the anchor, so it must
50+
// only run for a genuine user scroll. A tall card (Edit diff, Bash
51+
// output, etc.) that lays out in one frame leaves `distanceFromBottom`
52+
// huge until the throttled/async scroll-to-bottom executes; a layout
53+
// settle that lands in that window would otherwise recompute
54+
// `isNearBottom` to false even though the user never scrolled — which
55+
// then makes the pending auto-scroll bail and strands the view above
56+
// the bottom. Gating on `isUserDriven` keeps the anchor sticky through
57+
// that settle while still letting a deliberate scroll up release it.
58+
guard isUserDriven else { return .none }
4259
isNearBottom = nowNearBottom
4360
return .none
4461
}

Packages/Tests/MessageListTests/MessageListScrollAnchorTests.swift

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ struct MessageListScrollAnchorTests {
77
@Test("Content growth while anchored requests bottom scroll")
88
func contentGrowthWhileAnchoredRequestsBottomScroll() {
99
var anchor = MessageListScrollAnchor(threshold: 120)
10-
_ = anchor.apply(contentHeight: 1000, visibleMaxY: 1000)
10+
_ = anchor.apply(contentHeight: 1000, visibleMaxY: 1000, isUserDriven: false)
1111

12-
let decision = anchor.apply(contentHeight: 1400, visibleMaxY: 1000)
12+
let decision = anchor.apply(contentHeight: 1400, visibleMaxY: 1000, isUserDriven: false)
1313

1414
#expect(decision == .scrollToBottom)
1515
#expect(anchor.isNearBottom)
@@ -18,10 +18,11 @@ struct MessageListScrollAnchorTests {
1818
@Test("Content growth while scrolled up does not re-anchor")
1919
func contentGrowthWhileScrolledUpDoesNotReanchor() {
2020
var anchor = MessageListScrollAnchor(threshold: 120)
21-
_ = anchor.apply(contentHeight: 1000, visibleMaxY: 1000)
22-
_ = anchor.apply(contentHeight: 1000, visibleMaxY: 600)
21+
_ = anchor.apply(contentHeight: 1000, visibleMaxY: 1000, isUserDriven: false)
22+
// User scrolls up — a user-driven stable frame un-sticks the anchor.
23+
_ = anchor.apply(contentHeight: 1000, visibleMaxY: 600, isUserDriven: true)
2324

24-
let decision = anchor.apply(contentHeight: 1400, visibleMaxY: 600)
25+
let decision = anchor.apply(contentHeight: 1400, visibleMaxY: 600, isUserDriven: false)
2526

2627
#expect(decision == .none)
2728
#expect(!anchor.isNearBottom)
@@ -30,12 +31,48 @@ struct MessageListScrollAnchorTests {
3031
@Test("Reset restores bottom anchoring")
3132
func resetRestoresBottomAnchoring() {
3233
var anchor = MessageListScrollAnchor(threshold: 120)
33-
_ = anchor.apply(contentHeight: 1000, visibleMaxY: 1000)
34-
_ = anchor.apply(contentHeight: 1000, visibleMaxY: 500)
34+
_ = anchor.apply(contentHeight: 1000, visibleMaxY: 1000, isUserDriven: false)
35+
_ = anchor.apply(contentHeight: 1000, visibleMaxY: 500, isUserDriven: true)
3536
#expect(!anchor.isNearBottom)
3637

3738
anchor.resetToBottom()
3839

3940
#expect(anchor.isNearBottom)
4041
}
42+
43+
@Test("Tall card layout settle does not un-stick the anchor")
44+
func tallCardLayoutSettleKeepsAnchorSticky() {
45+
var anchor = MessageListScrollAnchor(threshold: 120)
46+
// User is following the bottom.
47+
_ = anchor.apply(contentHeight: 1000, visibleMaxY: 1000, isUserDriven: false)
48+
49+
// A tall Edit card lays out in one frame: content grows but the visible
50+
// rect hasn't been re-anchored yet, so distance is huge. Still sticky;
51+
// schedules a scroll-to-bottom.
52+
let growth = anchor.apply(contentHeight: 1800, visibleMaxY: 1000, isUserDriven: false)
53+
#expect(growth == .scrollToBottom)
54+
#expect(anchor.isNearBottom)
55+
56+
// Before the (async/throttled) scroll executes, a *non*-user-driven
57+
// stable frame arrives while distance is still huge. This must NOT
58+
// un-stick the anchor — otherwise the pending auto-scroll bails and the
59+
// view is stranded above the bottom.
60+
let settle = anchor.apply(contentHeight: 1800, visibleMaxY: 1000, isUserDriven: false)
61+
#expect(settle == .none)
62+
#expect(anchor.isNearBottom)
63+
}
64+
65+
@Test("User scroll up still releases the anchor after a tall card")
66+
func userScrollUpReleasesAnchorAfterTallCard() {
67+
var anchor = MessageListScrollAnchor(threshold: 120)
68+
_ = anchor.apply(contentHeight: 1000, visibleMaxY: 1000, isUserDriven: false)
69+
_ = anchor.apply(contentHeight: 1800, visibleMaxY: 1000, isUserDriven: false)
70+
// Layout settles to the bottom (visible rect now catches up).
71+
_ = anchor.apply(contentHeight: 1800, visibleMaxY: 1800, isUserDriven: false)
72+
#expect(anchor.isNearBottom)
73+
74+
// User deliberately scrolls up.
75+
_ = anchor.apply(contentHeight: 1800, visibleMaxY: 1200, isUserDriven: true)
76+
#expect(!anchor.isNearBottom)
77+
}
4178
}

0 commit comments

Comments
 (0)