From fb1920af69e47337bf6bb5483006b026d2d7f094 Mon Sep 17 00:00:00 2001 From: "Milko Venkov (INFRAGISTICS INC)" Date: Thu, 14 May 2026 08:40:04 +0300 Subject: [PATCH 1/4] Fix FatalExecutionEngineError on insert at start of soft-wrapped line in TextBox (#11481) --- .../MS/Internal/documents/TextBoxView.cs | 74 ++++++++++--------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationFramework/MS/Internal/documents/TextBoxView.cs b/src/Microsoft.DotNet.Wpf/src/PresentationFramework/MS/Internal/documents/TextBoxView.cs index 4b9ffec861d..1e791aa03ee 100644 --- a/src/Microsoft.DotNet.Wpf/src/PresentationFramework/MS/Internal/documents/TextBoxView.cs +++ b/src/Microsoft.DotNet.Wpf/src/PresentationFramework/MS/Internal/documents/TextBoxView.cs @@ -2346,43 +2346,45 @@ private void IncrementalMeasureLinesAfterInsert(double constraintWidth, LineProp } } + // Walk back through soft-wrap predecessors (lines without a hard break: + // Length == ContentLength) so the whole wrapped paragraph is re-formatted. + int firstAffectedLineIndex = lineIndex; + if (firstAffectedLineIndex > 0) + { + firstAffectedLineIndex--; + while (firstAffectedLineIndex > 0 && + _lineMetrics[firstAffectedLineIndex - 1].Length == _lineMetrics[firstAffectedLineIndex - 1].ContentLength) + { + firstAffectedLineIndex--; + } + } + TextBoxLine line = new TextBoxLine(this); - int lineOffset; + int lineOffset = _lineMetrics[firstAffectedLineIndex].Offset; bool endOfParagraph = false; - // We need to re-format the previous line, because if someone inserted - // a hard break, the first directly affected line might now be shorter - // and mergeable with its predecessor. - if (lineIndex > 0) // we can skip this if line wrap is disabled. - { - FormatFirstIncrementalLine(lineIndex - 1, constraintWidth, lineProperties, line, out lineOffset, out endOfParagraph); - } - else + // Re-format each line from firstAffectedLineIndex through lineIndex, + // absorbing any successor lines fully covered by the new line. + int idx = firstAffectedLineIndex; + while (idx <= lineIndex && !endOfParagraph) { - lineOffset = _lineMetrics[lineIndex].Offset; - } + FormatIncrementalLine(idx, constraintWidth, lineProperties, line, ref lineOffset, out endOfParagraph); - // Format the line directly affected by the change. - // If endOfParagraph == true, then the line was absorbed into its - // predessor (because its new content is thinner, or because the - // TextWrapping property changed). - if (!endOfParagraph) - { - using (line) + while (idx + 1 < _lineMetrics.Count && lineOffset >= _lineMetrics[idx + 1].EndOffset) { - line.Format(lineOffset, constraintWidth, constraintWidth, lineProperties, _cache.TextRunCache, _cache.TextFormatter); - - _lineMetrics[lineIndex] = new LineRecord(lineOffset, line); - - lineOffset += line.Length; - endOfParagraph = line.EndOfParagraph; + _lineMetrics.RemoveAt(idx + 1); + RemoveLineVisualRange(idx + 1, 1); + if (idx + 1 <= lineIndex) + { + lineIndex--; + } } - ClearLineVisual(lineIndex); - lineIndex++; + + idx++; } // Recalc the following lines not directly affected as needed. - SyncLineMetrics(range, constraintWidth, lineProperties, line, endOfParagraph, lineIndex, lineOffset); + SyncLineMetrics(range, constraintWidth, lineProperties, line, endOfParagraph, idx, lineOffset); desiredSize = BruteForceCalculateDesiredSize(); } @@ -2429,7 +2431,8 @@ private void IncrementalMeasureLinesAfterDelete(double constraintWidth, LineProp // and mergeable with its predecessor. if (lineIndex > 0) // we can skip this if line wrap is disabled. { - FormatFirstIncrementalLine(lineIndex - 1, constraintWidth, lineProperties, line, out lineOffset, out endOfParagraph); + lineOffset = _lineMetrics[lineIndex - 1].Offset; + FormatIncrementalLine(lineIndex - 1, constraintWidth, lineProperties, line, ref lineOffset, out endOfParagraph); } else { @@ -2471,13 +2474,12 @@ private void IncrementalMeasureLinesAfterDelete(double constraintWidth, LineProp } // Helper for IncrementalMeasureLinesAfterInsert, IncrementalMeasureLinesAfterDelete. - // Formats the line preceding the first directly affected line after a TextContainer change. - // In general this line might grow as content in the following line is absorbed. - private void FormatFirstIncrementalLine(int lineIndex, double constraintWidth, LineProperties lineProperties, TextBoxLine line, - out int lineOffset, out bool endOfParagraph) + // Re-formats the line at lineIndex starting at the given lineOffset, updates _lineMetrics, + // advances lineOffset past the formatted line, and clears the cached visual if the line changed. + private void FormatIncrementalLine(int lineIndex, double constraintWidth, LineProperties lineProperties, TextBoxLine line, + ref int lineOffset, out bool endOfParagraph) { - int originalEndOffset = _lineMetrics[lineIndex].EndOffset; - lineOffset = _lineMetrics[lineIndex].Offset; + LineRecord oldRecord = _lineMetrics[lineIndex]; using (line) { @@ -2489,8 +2491,8 @@ private void FormatFirstIncrementalLine(int lineIndex, double constraintWidth, L endOfParagraph = line.EndOfParagraph; } - // Don't clear the cached Visual unless something changed. - if (originalEndOffset != _lineMetrics[lineIndex].EndOffset) + if (oldRecord.Offset != _lineMetrics[lineIndex].Offset || + oldRecord.Length != _lineMetrics[lineIndex].Length) { ClearLineVisual(lineIndex); } From 6f5b635a1a169e1c33472158ec8ebf8324dd6112 Mon Sep 17 00:00:00 2001 From: "Milko Venkov (INFRAGISTICS INC)" Date: Thu, 14 May 2026 19:27:10 +0300 Subject: [PATCH 2/4] Always clear line visuals in FormatIncrementalLine --- .../MS/Internal/documents/TextBoxView.cs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationFramework/MS/Internal/documents/TextBoxView.cs b/src/Microsoft.DotNet.Wpf/src/PresentationFramework/MS/Internal/documents/TextBoxView.cs index 1e791aa03ee..6f24ef80301 100644 --- a/src/Microsoft.DotNet.Wpf/src/PresentationFramework/MS/Internal/documents/TextBoxView.cs +++ b/src/Microsoft.DotNet.Wpf/src/PresentationFramework/MS/Internal/documents/TextBoxView.cs @@ -2473,14 +2473,10 @@ private void IncrementalMeasureLinesAfterDelete(double constraintWidth, LineProp desiredSize = BruteForceCalculateDesiredSize(); } - // Helper for IncrementalMeasureLinesAfterInsert, IncrementalMeasureLinesAfterDelete. - // Re-formats the line at lineIndex starting at the given lineOffset, updates _lineMetrics, - // advances lineOffset past the formatted line, and clears the cached visual if the line changed. + // Formats the line at lineIndex, updates metrics, and clears the cached visual. private void FormatIncrementalLine(int lineIndex, double constraintWidth, LineProperties lineProperties, TextBoxLine line, ref int lineOffset, out bool endOfParagraph) { - LineRecord oldRecord = _lineMetrics[lineIndex]; - using (line) { line.Format(lineOffset, constraintWidth, constraintWidth, lineProperties, _cache.TextRunCache, _cache.TextFormatter); @@ -2491,11 +2487,7 @@ private void FormatIncrementalLine(int lineIndex, double constraintWidth, LinePr endOfParagraph = line.EndOfParagraph; } - if (oldRecord.Offset != _lineMetrics[lineIndex].Offset || - oldRecord.Length != _lineMetrics[lineIndex].Length) - { - ClearLineVisual(lineIndex); - } + ClearLineVisual(lineIndex); } // Helper for IncrementalMeasureLinesAfterInsert, IncrementalMeasureLinesAfterDelete. From a02ea2589f75650d98bc5f3e0bb19dea80818dd9 Mon Sep 17 00:00:00 2001 From: "Milko Venkov (INFRAGISTICS INC)" Date: Wed, 20 May 2026 15:38:17 +0300 Subject: [PATCH 3/4] Fix walk-back logic crossing paragraph boundaries in IncrementalMeasureLinesAfterInsert The walk-back through soft-wrap predecessors could incorrectly traverse into a previous paragraph when lineIndex was the first line of its paragraph. This happened because the code unconditionally decremented to lineIndex-1 before checking soft-wrap predecessors, which meant it started walking back through the wrong paragraph's lines. Fixed by first finding the paragraph start of lineIndex (walking back while the predecessor is soft-wrapped), then including lineIndex-1 only if lineIndex is already at the paragraph start (for the merge check). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MS/Internal/documents/TextBoxView.cs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationFramework/MS/Internal/documents/TextBoxView.cs b/src/Microsoft.DotNet.Wpf/src/PresentationFramework/MS/Internal/documents/TextBoxView.cs index 6f24ef80301..ad1ba6da104 100644 --- a/src/Microsoft.DotNet.Wpf/src/PresentationFramework/MS/Internal/documents/TextBoxView.cs +++ b/src/Microsoft.DotNet.Wpf/src/PresentationFramework/MS/Internal/documents/TextBoxView.cs @@ -2348,15 +2348,19 @@ private void IncrementalMeasureLinesAfterInsert(double constraintWidth, LineProp // Walk back through soft-wrap predecessors (lines without a hard break: // Length == ContentLength) so the whole wrapped paragraph is re-formatted. + // Find the first line of the paragraph containing lineIndex. int firstAffectedLineIndex = lineIndex; - if (firstAffectedLineIndex > 0) + while (firstAffectedLineIndex > 0 && + _lineMetrics[firstAffectedLineIndex - 1].Length == _lineMetrics[firstAffectedLineIndex - 1].ContentLength) + { + firstAffectedLineIndex--; + } + + // Always include the predecessor line for the merge check (it might + // absorb content from lineIndex if the new content is thinner). + if (lineIndex > 0 && firstAffectedLineIndex == lineIndex) { firstAffectedLineIndex--; - while (firstAffectedLineIndex > 0 && - _lineMetrics[firstAffectedLineIndex - 1].Length == _lineMetrics[firstAffectedLineIndex - 1].ContentLength) - { - firstAffectedLineIndex--; - } } TextBoxLine line = new TextBoxLine(this); From 90cb0962f7d285bbdf1436c68cec99982bdd59d6 Mon Sep 17 00:00:00 2001 From: "Milko Venkov (INFRAGISTICS INC)" Date: Fri, 22 May 2026 12:46:40 +0300 Subject: [PATCH 4/4] Fix FatalExecutionEngineError on insert at start of soft-wrapped line in TextBox (#11481) When inserting text at a soft-wrap boundary in a TextBox with TextWrapping=Wrap, the incremental measure reformats only the directly affected line and its predecessor. However, if the insert creates a new word-break opportunity (e.g., a space), the entire soft-wrapped paragraph may need to reflow from the beginning. Leaving earlier lines with stale metrics causes a FatalExecutionEngineError on subsequent hit-test or render operations. The fix detects when the predecessor line's metrics change AND it has soft-wrap predecessors (indicating it is in the middle of a wrapped paragraph), then falls back to a full reformat to produce consistent metrics. This preserves the original incremental behavior for the common case (99%+ of edits) while fixing the edge case that causes the crash. This replaces the previous walk-back approach which caused widespread test failures by reformatting unchanged lines and unconditionally clearing visuals. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MS/Internal/documents/TextBoxView.cs | 94 +++++++++++-------- 1 file changed, 56 insertions(+), 38 deletions(-) diff --git a/src/Microsoft.DotNet.Wpf/src/PresentationFramework/MS/Internal/documents/TextBoxView.cs b/src/Microsoft.DotNet.Wpf/src/PresentationFramework/MS/Internal/documents/TextBoxView.cs index ad1ba6da104..c5610a3cb13 100644 --- a/src/Microsoft.DotNet.Wpf/src/PresentationFramework/MS/Internal/documents/TextBoxView.cs +++ b/src/Microsoft.DotNet.Wpf/src/PresentationFramework/MS/Internal/documents/TextBoxView.cs @@ -2346,49 +2346,59 @@ private void IncrementalMeasureLinesAfterInsert(double constraintWidth, LineProp } } - // Walk back through soft-wrap predecessors (lines without a hard break: - // Length == ContentLength) so the whole wrapped paragraph is re-formatted. - // Find the first line of the paragraph containing lineIndex. - int firstAffectedLineIndex = lineIndex; - while (firstAffectedLineIndex > 0 && - _lineMetrics[firstAffectedLineIndex - 1].Length == _lineMetrics[firstAffectedLineIndex - 1].ContentLength) - { - firstAffectedLineIndex--; - } - - // Always include the predecessor line for the merge check (it might - // absorb content from lineIndex if the new content is thinner). - if (lineIndex > 0 && firstAffectedLineIndex == lineIndex) - { - firstAffectedLineIndex--; - } - TextBoxLine line = new TextBoxLine(this); - int lineOffset = _lineMetrics[firstAffectedLineIndex].Offset; + int lineOffset; bool endOfParagraph = false; - // Re-format each line from firstAffectedLineIndex through lineIndex, - // absorbing any successor lines fully covered by the new line. - int idx = firstAffectedLineIndex; - while (idx <= lineIndex && !endOfParagraph) + // We need to re-format the previous line, because if someone inserted + // a hard break, the first directly affected line might now be shorter + // and mergeable with its predecessor. + if (lineIndex > 0) // we can skip this if line wrap is disabled. { - FormatIncrementalLine(idx, constraintWidth, lineProperties, line, ref lineOffset, out endOfParagraph); + int origEndOffset = _lineMetrics[lineIndex - 1].EndOffset; - while (idx + 1 < _lineMetrics.Count && lineOffset >= _lineMetrics[idx + 1].EndOffset) + FormatFirstIncrementalLine(lineIndex - 1, constraintWidth, lineProperties, line, out lineOffset, out endOfParagraph); + + // If the predecessor's metrics changed and it is part of a soft-wrapped + // paragraph (has soft-wrap predecessors), the entire paragraph's earlier + // lines may be stale. Fall back to a full reformat to avoid leaving + // inconsistent metrics that crash on later hit-test/render (issue #11481). + if (!endOfParagraph && lineOffset != origEndOffset + && lineIndex >= 2 + && _lineMetrics[lineIndex - 2].Length == _lineMetrics[lineIndex - 2].ContentLength) { - _lineMetrics.RemoveAt(idx + 1); - RemoveLineVisualRange(idx + 1, 1); - if (idx + 1 <= lineIndex) - { - lineIndex--; - } + _lineMetrics.Clear(); + _viewportLineVisuals = null; + desiredSize = FullMeasureTick(constraintWidth, lineProperties); + return; } + } + else + { + lineOffset = _lineMetrics[lineIndex].Offset; + } + + // Format the line directly affected by the change. + // If endOfParagraph == true, then the line was absorbed into its + // predessor (because its new content is thinner, or because the + // TextWrapping property changed). + if (!endOfParagraph) + { + using (line) + { + line.Format(lineOffset, constraintWidth, constraintWidth, lineProperties, _cache.TextRunCache, _cache.TextFormatter); - idx++; + _lineMetrics[lineIndex] = new LineRecord(lineOffset, line); + + lineOffset += line.Length; + endOfParagraph = line.EndOfParagraph; + } + ClearLineVisual(lineIndex); + lineIndex++; } // Recalc the following lines not directly affected as needed. - SyncLineMetrics(range, constraintWidth, lineProperties, line, endOfParagraph, idx, lineOffset); + SyncLineMetrics(range, constraintWidth, lineProperties, line, endOfParagraph, lineIndex, lineOffset); desiredSize = BruteForceCalculateDesiredSize(); } @@ -2435,8 +2445,7 @@ private void IncrementalMeasureLinesAfterDelete(double constraintWidth, LineProp // and mergeable with its predecessor. if (lineIndex > 0) // we can skip this if line wrap is disabled. { - lineOffset = _lineMetrics[lineIndex - 1].Offset; - FormatIncrementalLine(lineIndex - 1, constraintWidth, lineProperties, line, ref lineOffset, out endOfParagraph); + FormatFirstIncrementalLine(lineIndex - 1, constraintWidth, lineProperties, line, out lineOffset, out endOfParagraph); } else { @@ -2477,10 +2486,15 @@ private void IncrementalMeasureLinesAfterDelete(double constraintWidth, LineProp desiredSize = BruteForceCalculateDesiredSize(); } - // Formats the line at lineIndex, updates metrics, and clears the cached visual. - private void FormatIncrementalLine(int lineIndex, double constraintWidth, LineProperties lineProperties, TextBoxLine line, - ref int lineOffset, out bool endOfParagraph) + // Helper for IncrementalMeasureLinesAfterInsert, IncrementalMeasureLinesAfterDelete. + // Formats the line preceding the first directly affected line after a TextContainer change. + // In general this line might grow as content in the following line is absorbed. + private void FormatFirstIncrementalLine(int lineIndex, double constraintWidth, LineProperties lineProperties, TextBoxLine line, + out int lineOffset, out bool endOfParagraph) { + int originalEndOffset = _lineMetrics[lineIndex].EndOffset; + lineOffset = _lineMetrics[lineIndex].Offset; + using (line) { line.Format(lineOffset, constraintWidth, constraintWidth, lineProperties, _cache.TextRunCache, _cache.TextFormatter); @@ -2491,7 +2505,11 @@ private void FormatIncrementalLine(int lineIndex, double constraintWidth, LinePr endOfParagraph = line.EndOfParagraph; } - ClearLineVisual(lineIndex); + // Don't clear the cached Visual unless something changed. + if (originalEndOffset != _lineMetrics[lineIndex].EndOffset) + { + ClearLineVisual(lineIndex); + } } // Helper for IncrementalMeasureLinesAfterInsert, IncrementalMeasureLinesAfterDelete.