From 04b240d3bd1f6a85c06aa340a450df5d83dc3a6d Mon Sep 17 00:00:00 2001 From: VAUsoltsev Date: Thu, 20 Aug 2026 17:30:07 +0300 Subject: [PATCH] Keep layout from looping and the scroll offset from leaving its range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reports from our users, one root: `_updateDisplayRenderParagraphs` calls itself when layout has corrected the scroll offset, and that recursion had no limit. It does not always converge. `applyContentDimensions` starts a ballistic activity while the offset is out of range, the activity moves the offset, and the next cycle sees the same mismatch. Paging through a file froze the application hard; the stack taken from the running process was thousands of frames of this one method. The estimate behind it cannot be made exact: lines outside the viewport are counted as one grid line each, while a wrapped line takes more. So the number of cycles is bounded, the way `RenderViewport` bounds itself with `_maxLayoutCycles`. Stopping early costs at most one imprecise frame. The rebuild is also skipped when there is nothing above to build. A gap at the top appears when the offset is merely out of range — a hair above the first line — and rebuilding cannot close it if line 0 is already the first one shown. Layout kept asking for layout, the ballistic activity that would have returned the offset to zero was restarted by every frame, and the editor shuddered at the top of the file. Finally, `makePositionVisible` no longer leaves the scrollable range, and the distance to a line below the viewport is counted from the last displayed line rather than the first — counting from `first` overshot by a whole screen. Landing out of range is what hands the offset to the spring simulation in the first place. --- lib/src/_code_field.dart | 63 +++++++++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/lib/src/_code_field.dart b/lib/src/_code_field.dart index f5d4ff9..e9deafd 100644 --- a/lib/src/_code_field.dart +++ b/lib/src/_code_field.dart @@ -616,17 +616,30 @@ class _CodeFieldRender extends RenderBox implements MouseTrackerAnnotation { return _selectWord(localPosition); } + /// Scrolls vertically without leaving the scrollable range. + /// + /// Landing outside it is not harmless: the offset is then out of range, the + /// physics start a spring simulation to bring it back, and the reader sees a + /// bounce where the caller merely asked to show a line. + void _jumpVerticallyTo(double offset) { + final double? maxOffset = _verticalViewportSize; + _verticalViewport.jumpTo(maxOffset == null ? max(0, offset) : min(max(0, offset), maxOffset)); + } + void makePositionVisible(CodeLinePosition position, [int tryCount = 0]) { final Offset? offset = calculateTextPositionViewportOffset(position); if (offset == null) { if (_displayParagraphs.isNotEmpty) { + // The distance to a line below the viewport is counted from the last + // displayed line rather than the first: counting from `first` overshot + // by a whole screen. final CodeLineRenderParagraph first = _displayParagraphs.first; if (position.index < first.index) { - _verticalViewport.jumpTo(first.top + _preferredLineHeight * (position.index - first.index)); + _jumpVerticallyTo(first.top + _preferredLineHeight * (position.index - first.index)); } final CodeLineRenderParagraph last = _displayParagraphs.last; if (position.index > last.index) { - _verticalViewport.jumpTo(max(0, last.bottom - size.height + _preferredLineHeight * (position.index - first.index))); + _jumpVerticallyTo(last.bottom - size.height + _preferredLineHeight * (position.index - last.index)); } } if (tryCount < 10) { @@ -642,9 +655,9 @@ class _CodeFieldRender extends RenderBox implements MouseTrackerAnnotation { return; } if (offset.dy < 0) { - _verticalViewport.jumpTo(_verticalViewport.pixels + offset.dy); + _jumpVerticallyTo(_verticalViewport.pixels + offset.dy); } else if (offset.dy > size.height - _preferredLineHeight) { - _verticalViewport.jumpTo(_verticalViewport.pixels + offset.dy - (size.height - _preferredLineHeight)); + _jumpVerticallyTo(_verticalViewport.pixels + offset.dy - (size.height - _preferredLineHeight)); } if (_horizontalViewport != null) { if (offset.dx < 0) { @@ -1007,7 +1020,28 @@ class _CodeFieldRender extends RenderBox implements MouseTrackerAnnotation { return null; } - void _updateDisplayRenderParagraphs() { + /// Bounds the number of layout cycles, the way `RenderViewport` does with + /// `_maxLayoutCycles`. + static const int _kMaxLayoutCycles = 10; + + /// [cycle] counts how many times this layout has already restarted itself. + /// + /// Laying out may correct the scroll offset, and a corrected offset asks for + /// another layout. That recursion had no limit and did not always converge: + /// `applyContentDimensions` below starts a ballistic activity when the offset + /// is out of range, the activity moves the offset again, and the next cycle + /// sees the same mismatch. The application froze hard — the stack was + /// thousands of frames of this method. + /// + /// The estimate that drives it cannot be exact in principle: the height of + /// the lines outside the viewport is guessed as one grid line each, while a + /// wrapped line takes more. So the loop needs a bound rather than a better + /// guess. Stopping early costs at most one imprecise frame: the next layout + /// starts from the corrected offset and settles. + void _updateDisplayRenderParagraphs([int cycle = 0]) { + if (cycle > _kMaxLayoutCycles) { + return; + } final double effectiveWidth; if (_horizontalViewport == null) { effectiveWidth = constraints.maxWidth - _padding.horizontal; @@ -1027,7 +1061,7 @@ class _CodeFieldRender extends RenderBox implements MouseTrackerAnnotation { } else { if (_codes.length <= _displayParagraphs.first.index) { _displayParagraphs.clear(); - _updateDisplayRenderParagraphs(); + _updateDisplayRenderParagraphs(cycle + 1); return; } if (target < _displayParagraphs.first.top) { @@ -1073,7 +1107,7 @@ class _CodeFieldRender extends RenderBox implements MouseTrackerAnnotation { } // The codes length maybe changed, this will make the displayParagraphs empty. if (_displayParagraphs.isEmpty) { - _updateDisplayRenderParagraphs(); + _updateDisplayRenderParagraphs(cycle + 1); return; } final double totalHeight = _displayParagraphs.last.bottom + (_codes.length - (_displayParagraphs.last.index + 1)) * _preferredLineHeight + paddingBottom; @@ -1087,9 +1121,18 @@ class _CodeFieldRender extends RenderBox implements MouseTrackerAnnotation { _horizontalViewportSize = max(0, maxWidth + _padding.horizontal - size.width); _horizontalViewport!.applyContentDimensions(0, _horizontalViewportSize!); } - // applyContentDimensions will change the _verticalViewport.pixels, we should rebuild. - if (_displayParagraphs.first.offset.dy > _verticalViewport.pixels + paddingTop) { - _updateDisplayRenderParagraphs(); + // applyContentDimensions will change the _verticalViewport.pixels, we should + // rebuild — but only while there is something above to build. + // + // The gap at the top also appears when the offset is simply out of range, a + // hair above the first line, left by physics that have not settled yet. + // Rebuilding cannot close that gap: line 0 is already the first one shown. + // Layout then asked for layout, and the ballistic activity that would have + // pulled the offset back to zero was restarted by every frame and never got + // to run. The offset stayed a fraction of a point above the top and the + // editor shuddered there for as long as the file was open. + if (_displayParagraphs.first.index > 0 && _displayParagraphs.first.offset.dy > _verticalViewport.pixels + paddingTop) { + _updateDisplayRenderParagraphs(cycle + 1); return; }