From 75d0c4824a6081b1754bc37c6a3aac9b9455cc46 Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:36:39 +0900 Subject: [PATCH] Fix chat scroll reset animation race --- lib/ui/home/chat/chat_scroll_coordinator.dart | 14 +++++ .../chat/chat_scroll_coordinator_test.dart | 51 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/lib/ui/home/chat/chat_scroll_coordinator.dart b/lib/ui/home/chat/chat_scroll_coordinator.dart index 7025044108..f9fc2c784f 100644 --- a/lib/ui/home/chat/chat_scroll_coordinator.dart +++ b/lib/ui/home/chat/chat_scroll_coordinator.dart @@ -189,6 +189,7 @@ class ChatScrollCoordinator { _animateNextRestore || animateLatestReset); if (reset) { + _cancelProgrammaticScrollBeforeReset(); _animatedRestoreMessageId = null; _animatedRestoreDirection = null; _animatedRestoreComplete = null; @@ -225,6 +226,19 @@ class ChatScrollCoordinator { }); } + void _cancelProgrammaticScrollBeforeReset() { + _followTailAfterProgrammaticScroll = false; + _tailFollowAnchorSnapshot = null; + if (!scrollController.hasClients) return; + if (_programmaticScrollDepth <= 0 && _programmaticBottomScrollDepth <= 0) { + return; + } + final position = scrollController.position; + if (!position.isScrollingNotifier.value) return; + traceChatJump('cancel programmatic scroll before reset'); + scrollController.jumpTo(position.pixels); + } + void _resetCenteredOffsetBeforePaint() { if (!scrollController.hasClients) return; if (scrollController.offset.abs() <= _jumpToTolerance) return; diff --git a/test/ui/home/chat/chat_scroll_coordinator_test.dart b/test/ui/home/chat/chat_scroll_coordinator_test.dart index 3cb88eccb2..14451d784f 100644 --- a/test/ui/home/chat/chat_scroll_coordinator_test.dart +++ b/test/ui/home/chat/chat_scroll_coordinator_test.dart @@ -602,6 +602,57 @@ void main() { ); }); + testWidgets('reset restore cancels pending latest animation', (tester) async { + final coordinator = ChatScrollCoordinator(); + final messages = List.generate(30, testMessage); + final top = List.generate(8, testMessage); + final bottom = List.generate(8, (index) => testMessage(index + 100)); + final nextMessages = [...top, ...bottom]; + final keysByMessageId = { + for (final message in [...messages, ...nextMessages]) + message.messageId: GlobalKey(), + }; + final centerKey = GlobalKey(); + + addTearDown(coordinator.dispose); + await pumpFullyBuiltScrollableMessages( + tester, + coordinator, + messages, + keysByMessageId, + ); + coordinator.scrollController.jumpTo( + coordinator.scrollController.position.maxScrollExtent - 120, + ); + + unawaited(coordinator.scrollToBottomIfInLoadedWindow(animated: true)); + await tester.pump(const Duration(milliseconds: 100)); + expect( + coordinator.scrollController.position.isScrollingNotifier.value, + true, + ); + + await pumpCenteredScrollableMessages( + tester, + coordinator, + centerKey: centerKey, + top: top, + bottom: bottom, + keysByMessageId: keysByMessageId, + ); + coordinator.scheduleRestore( + messages: nextMessages, + keysByMessageId: keysByMessageId, + reset: true, + isLatest: false, + hasCenteredAnchor: true, + traceTargetMessageId: bottom.first.messageId, + ); + await tester.pumpAndSettle(); + + expect(coordinator.scrollController.offset, 0); + }); + testWidgets( 'scheduleRestore animates tail-follow without jump notifications', (tester) async {