From 8a692f858df5357d10db07d3ba81ed778fa2a525 Mon Sep 17 00:00:00 2001 From: Context Found Date: Tue, 30 Jun 2026 08:58:39 -0400 Subject: [PATCH 1/4] fix: bound EdgeDraggingAutoScroller against an infinite microtask loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EdgeDraggingAutoScroller._scroll re-invokes itself via `await _scroll()` while `_scrolling` is true. position.moveTo can complete synchronously when the scrollable cannot advance (already at an extent, zero scroll range, or pinned), turning the self-recursion into a tight microtask loop that pins the platform/UI thread with no frames — an ANR on mobile when leaving a table cell (symbolicated: _microtaskLoop -> Future completion -> EdgeDraggingAutoScroller._scroll). Stop when moveTo makes no forward progress, which bounds the loop. --- lib/src/flutter/scrollable_helpers.dart | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lib/src/flutter/scrollable_helpers.dart b/lib/src/flutter/scrollable_helpers.dart index bda704ad9..d8c9c6456 100644 --- a/lib/src/flutter/scrollable_helpers.dart +++ b/lib/src/flutter/scrollable_helpers.dart @@ -365,6 +365,7 @@ class EdgeDraggingAutoScroller { return; } } + final double pixelsBeforeMove = scrollable.position.pixels; await scrollable.position.moveTo( newOffset, duration: _currentDuration ?? _animationDuration, @@ -372,6 +373,22 @@ class EdgeDraggingAutoScroller { // clamp: true, ); onScrollViewScrolled?.call(); + // Termination guard against an unbounded auto-scroll loop. + // + // `_scroll` re-invokes itself via `await _scroll()` as long as + // `_scrolling` is true. `moveTo` can complete synchronously when the + // position cannot actually advance (already at min/maxScrollExtent, a + // zero scroll range, or a pinned position), in which case the + // self-recursion degenerates into a tight microtask loop that pins the + // platform/UI thread with no frames produced — observed as an ANR on + // mobile when leaving a table cell. Requiring real forward progress + // bounds the loop: each tick must move the scroll offset by at least + // the precision tolerance, and the scroll extent is finite. + if ((scrollable.position.pixels - pixelsBeforeMove).abs() <= + precisionErrorTolerance) { + _scrolling = false; + return; + } if (_scrolling) { await _scroll(); } From e51b0584963cda78ea10935293ab4f21b445ef25 Mon Sep 17 00:00:00 2001 From: Context Found Date: Tue, 30 Jun 2026 14:43:59 -0400 Subject: [PATCH 2/4] fix: prevent mobile auto-scroll feedback loops Co-authored-by: Cursor --- .../service/scroll/auto_scroller.dart | 5 ++ .../scroll/desktop_scroll_service.dart | 2 + .../service/scroll/mobile_scroll_service.dart | 2 + .../service/scroll_service_widget.dart | 3 ++ lib/src/flutter/scrollable_helpers.dart | 51 +++++++++++++++---- 5 files changed, 52 insertions(+), 11 deletions(-) diff --git a/lib/src/editor/editor_component/service/scroll/auto_scroller.dart b/lib/src/editor/editor_component/service/scroll/auto_scroller.dart index dcb0ad7c8..f848403bb 100644 --- a/lib/src/editor/editor_component/service/scroll/auto_scroller.dart +++ b/lib/src/editor/editor_component/service/scroll/auto_scroller.dart @@ -7,6 +7,7 @@ abstract class AutoScrollerService { double edgeOffset = 200, AxisDirection? direction, Duration? duration, + bool repeat = true, }); void stopAutoScroll(); @@ -38,6 +39,7 @@ class AutoScroller extends EdgeDraggingAutoScroller double edgeOffset = 200, AxisDirection? direction, Duration? duration, + bool repeat = true, }) { lastOffset = offset; lastDuration = duration; @@ -47,6 +49,7 @@ class AutoScroller extends EdgeDraggingAutoScroller return startAutoScrollIfNecessary( Rect.fromLTWH(offset.dx, offset.dy - edgeOffset, 1, edgeOffset), duration: duration, + repeat: repeat, ); } @@ -54,6 +57,7 @@ class AutoScroller extends EdgeDraggingAutoScroller return startAutoScrollIfNecessary( Rect.fromLTWH(offset.dx, offset.dy, 1, edgeOffset), duration: duration, + repeat: repeat, ); } @@ -66,6 +70,7 @@ class AutoScroller extends EdgeDraggingAutoScroller startAutoScrollIfNecessary( dragTarget, duration: duration, + repeat: repeat, ); } diff --git a/lib/src/editor/editor_component/service/scroll/desktop_scroll_service.dart b/lib/src/editor/editor_component/service/scroll/desktop_scroll_service.dart index fdf17418a..ffeb32534 100644 --- a/lib/src/editor/editor_component/service/scroll/desktop_scroll_service.dart +++ b/lib/src/editor/editor_component/service/scroll/desktop_scroll_service.dart @@ -104,6 +104,7 @@ class _DesktopScrollServiceState extends State double edgeOffset = 200, AxisDirection? direction, Duration? duration, + bool repeat = true, }) { if (editorState.disableAutoScroll) { return; @@ -114,6 +115,7 @@ class _DesktopScrollServiceState extends State edgeOffset: edgeOffset, direction: direction, duration: duration ?? _kDesktopAutoScrollTickDuration, + repeat: repeat, ); } diff --git a/lib/src/editor/editor_component/service/scroll/mobile_scroll_service.dart b/lib/src/editor/editor_component/service/scroll/mobile_scroll_service.dart index a313e9c31..479419f28 100644 --- a/lib/src/editor/editor_component/service/scroll/mobile_scroll_service.dart +++ b/lib/src/editor/editor_component/service/scroll/mobile_scroll_service.dart @@ -98,12 +98,14 @@ class _MobileScrollServiceState extends State double edgeOffset = 200, AxisDirection? direction, Duration? duration, + bool repeat = true, }) { autoScroller?.startAutoScroll( offset, edgeOffset: edgeOffset, direction: direction, duration: duration, + repeat: repeat, ); } diff --git a/lib/src/editor/editor_component/service/scroll_service_widget.dart b/lib/src/editor/editor_component/service/scroll_service_widget.dart index 39fcd398e..742f35034 100644 --- a/lib/src/editor/editor_component/service/scroll_service_widget.dart +++ b/lib/src/editor/editor_component/service/scroll_service_widget.dart @@ -180,6 +180,7 @@ class _ScrollServiceWidgetState extends State edgeOffset: editorState.autoScrollEdgeOffset, direction: direction, duration: scrollDuration, + repeat: isDragOperation, ); }); } else { @@ -243,12 +244,14 @@ class _ScrollServiceWidgetState extends State double edgeOffset = 100, AxisDirection? direction, Duration? duration, + bool repeat = true, }) { forward.startAutoScroll( offset, edgeOffset: edgeOffset, direction: direction, duration: duration, + repeat: repeat, ); } diff --git a/lib/src/flutter/scrollable_helpers.dart b/lib/src/flutter/scrollable_helpers.dart index d8c9c6456..b5719041f 100644 --- a/lib/src/flutter/scrollable_helpers.dart +++ b/lib/src/flutter/scrollable_helpers.dart @@ -10,6 +10,7 @@ import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; export 'package:flutter/physics.dart' show Tolerance; @@ -188,12 +189,14 @@ class EdgeDraggingAutoScroller { final Duration _animationDuration; Duration? _currentDuration; double? _previousScrollDelta; + bool _repeatScroll = true; late Rect _dragTargetRelatedToScrollOrigin; /// Whether the auto scroll is in progress. bool get scrolling => _scrolling; bool _scrolling = false; + bool _scrollScheduled = false; double _offsetExtent(Offset offset, Axis scrollDirection) { return switch (scrollDirection) { @@ -220,28 +223,54 @@ class EdgeDraggingAutoScroller { /// /// If the scrollable is already scrolling, calling this method updates the /// previous dragTarget to the new value and continues scrolling if necessary. - void startAutoScrollIfNecessary(Rect dragTarget, {Duration? duration}) { + void startAutoScrollIfNecessary( + Rect dragTarget, { + Duration? duration, + bool repeat = true, + }) { final Offset deltaToOrigin = scrollable.deltaToScrollOrigin; _dragTargetRelatedToScrollOrigin = dragTarget.translate(deltaToOrigin.dx, deltaToOrigin.dy); _currentDuration = duration; + _repeatScroll = repeat; if (_scrolling) { // The change will be picked up in the next scroll. return; } assert(!_scrolling); - _scroll(); + _scrolling = true; + _scheduleScroll(); } /// Stop any ongoing auto scrolling. void stopAutoScroll() { _scrolling = false; + _scrollScheduled = false; _previousScrollDelta = null; _currentDuration = null; + _repeatScroll = true; + } + + void _scheduleScroll() { + if (!_scrolling || _scrollScheduled) { + return; + } + _scrollScheduled = true; + SchedulerBinding.instance.scheduleFrameCallback((_) { + _scrollScheduled = false; + if (!_scrolling) { + return; + } + unawaited(_scroll()); + }); + SchedulerBinding.instance.ensureVisualUpdate(); } Future _scroll() async { try { + if (!_scrolling) { + return; + } final RenderBox scrollRenderBox = scrollable.context.findRenderObject()! as RenderBox; final Matrix4 transform = scrollRenderBox.getTransformTo(null); @@ -266,7 +295,6 @@ class EdgeDraggingAutoScroller { // do nothing. } - _scrolling = true; double? newOffset; const double overDragMax = 20.0; @@ -340,14 +368,14 @@ class EdgeDraggingAutoScroller { final double currentPixels = scrollable.position.pixels; if (newOffset == null) { // Drag should not trigger scroll. - _scrolling = false; + stopAutoScroll(); return; } double delta = newOffset - currentPixels; if (delta.abs() < _minimumAutoScrollDelta) { if (delta.abs() <= precisionErrorTolerance) { - _scrolling = false; + stopAutoScroll(); return; } @@ -360,7 +388,7 @@ class EdgeDraggingAutoScroller { newOffset = target.toDouble(); delta = newOffset - currentPixels; if (delta.abs() <= precisionErrorTolerance) { - _scrolling = false; + stopAutoScroll(); return; } @@ -386,16 +414,17 @@ class EdgeDraggingAutoScroller { // the precision tolerance, and the scroll extent is finite. if ((scrollable.position.pixels - pixelsBeforeMove).abs() <= precisionErrorTolerance) { - _scrolling = false; + stopAutoScroll(); return; } - if (_scrolling) { - await _scroll(); + if (_repeatScroll) { + _scheduleScroll(); + } else { + stopAutoScroll(); } } catch (e) { debugPrint(e.toString()); - } finally { - _scrolling = false; + stopAutoScroll(); } } From d74d8fb07b83f5435dcbf56da6df301ef8845b24 Mon Sep 17 00:00:00 2001 From: Context Found Date: Tue, 30 Jun 2026 14:53:59 -0400 Subject: [PATCH 3/4] test: update auto-scroller override signature Co-authored-by: Cursor --- .../editor/editor_component/service/auto_scroller_test.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/editor/editor_component/service/auto_scroller_test.dart b/test/editor/editor_component/service/auto_scroller_test.dart index b0b8c0621..0a3cb3a26 100644 --- a/test/editor/editor_component/service/auto_scroller_test.dart +++ b/test/editor/editor_component/service/auto_scroller_test.dart @@ -10,7 +10,11 @@ class _CapturingAutoScroller extends AutoScroller { Rect? capturedRect; @override - void startAutoScrollIfNecessary(Rect dragTarget, {Duration? duration}) { + void startAutoScrollIfNecessary( + Rect dragTarget, { + Duration? duration, + bool repeat = true, + }) { capturedRect = dragTarget; // intentionally do not call super — we only want the rect } From a32155bff6e26e7c67a4c25e68935d47650ccae6 Mon Sep 17 00:00:00 2001 From: Context Found Date: Tue, 30 Jun 2026 15:10:33 -0400 Subject: [PATCH 4/4] fix: make web selection auto-scroll one-shot Co-authored-by: Cursor --- .../editor/editor_component/service/scroll_service_widget.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/editor/editor_component/service/scroll_service_widget.dart b/lib/src/editor/editor_component/service/scroll_service_widget.dart index 742f35034..78f6de57b 100644 --- a/lib/src/editor/editor_component/service/scroll_service_widget.dart +++ b/lib/src/editor/editor_component/service/scroll_service_widget.dart @@ -192,6 +192,7 @@ class _ScrollServiceWidgetState extends State edgeOffset: editorState.autoScrollEdgeOffset, direction: direction, duration: Duration.zero, + repeat: false, ); } });