-
Notifications
You must be signed in to change notification settings - Fork 90
fix(macOS): defer lifecycle bottom-pin writes to avoid SwiftUI update-cycle mutation #24332
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -428,6 +428,12 @@ final class MessageListScrollState { | |
| /// Timeout task for expansion stabilization — auto-ends after 200ms. | ||
| @ObservationIgnored private var expansionTimeoutTask: Task<Void, Never>? | ||
|
|
||
| /// Generation counter for lifecycle-driven bottom pins that are deferred | ||
| /// onto the next main-queue turn. This keeps `onChange` handlers from | ||
| /// mutating `ScrollPosition` during the same SwiftUI update pass that | ||
| /// triggered them. | ||
| @ObservationIgnored private var deferredBottomPinGeneration: UInt64 = 0 | ||
|
|
||
| /// Tracks overlapping stabilization windows. Stabilization only ends | ||
| /// when all active windows have completed, so concurrent reasons | ||
| /// (e.g. resize during pagination) don't prematurely restore the mode. | ||
|
|
@@ -590,6 +596,40 @@ final class MessageListScrollState { | |
| } | ||
| } | ||
|
|
||
| /// Schedules a bottom-pin for the next main-queue turn, coalescing | ||
| /// repeated requests from the same render cycle into a single execution. | ||
| /// | ||
| /// This is primarily used by lifecycle `onChange` handlers that can fire | ||
| /// during a SwiftUI update pass (for example `isSending` and | ||
| /// `messages.count`). Deferring the actual `ScrollPosition` write avoids | ||
| /// tripping SwiftUI's "Modifying state during view update" runtime guard. | ||
| func scheduleDeferredBottomPin( | ||
| animated: Bool = false, | ||
| userInitiated: Bool = false, | ||
| forceFollowingBottom: Bool = false, | ||
| refreshRecoveryWindow: Bool = false | ||
| ) { | ||
| deferredBottomPinGeneration &+= 1 | ||
| let generation = deferredBottomPinGeneration | ||
| DispatchQueue.main.async { [weak self] in | ||
| Task { @MainActor [weak self] in | ||
| guard let self, self.deferredBottomPinGeneration == generation else { return } | ||
| if forceFollowingBottom { | ||
| self.transition(to: .followingBottom) | ||
| } | ||
| if refreshRecoveryWindow { | ||
| self.bottomAnchorAppeared = false | ||
| self.recoveryDeadline = Date().addingTimeInterval(2.0) | ||
| } | ||
| _ = self.requestPinToBottom(animated: animated, userInitiated: userInitiated) | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+611
to
+627
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Generation-based coalescing drops The When the user is in The test masks this bug by using the wrong call orderIn Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| func cancelDeferredBottomPin() { | ||
| deferredBottomPinGeneration &+= 1 | ||
| } | ||
|
|
||
| // MARK: - Scroll Execution | ||
|
|
||
| /// Executes a bottom-pin scroll if the current mode allows it. | ||
|
|
@@ -810,6 +850,7 @@ final class MessageListScrollState { | |
| func reset(for newConversationId: UUID?) { | ||
| cancelStabilizationTasks() | ||
| stabilizationGeneration &+= 1 | ||
| cancelDeferredBottomPin() | ||
| paginationTask?.cancel() | ||
| paginationTask = nil | ||
| ScrollGeometryUpdateDispatcher.shared.cancel(for: self) | ||
|
|
@@ -857,6 +898,7 @@ final class MessageListScrollState { | |
| func cancelAll() { | ||
| cancelStabilizationTasks() | ||
| stabilizationGeneration &+= 1 | ||
| cancelDeferredBottomPin() | ||
| uiSyncTask?.cancel() | ||
| uiSyncTask = nil | ||
| scrollRestoreTask?.cancel() | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The coalescing strategy here is last-call-wins, which drops options from earlier requests in the same update cycle. In the current flow,
handleSendingChanged()queuesscheduleDeferredBottomPin(... forceFollowingBottom: true, refreshRecoveryWindow: true), thenhandleMessagesCountChanged()queuesscheduleDeferredBottomPin(animated: true); the second call bumps the generation and suppresses the first closure. When the user sends while in.freeBrowsing, the surviving request no longer transitions to.followingBottom, sorequestPinToBottomcan no-op and the transcript may not scroll to latest after send. Coalescing should merge intent (e.g., OR these flags) instead of discarding earlier stronger requests.Useful? React with 👍 / 👎.