Skip to content

RichTextBox reports line positions for text removed while the form was minimized, and paints garbled rows after the restore #14830

Description

@lxop

.NET version

10

Did it work in .NET Framework?

No

Did it work in any of the earlier releases of .NET Core or .NET 5+?

Framework Result
.NET 10.0.10 (net10.0-windows), x64 reproduced
.NET 8.0.28 (net8.0-windows), x64 reproduced
.NET Framework 4.8.9325.0 (net48), x64 reproduced

Issue description

If text is removed from a RichTextBox while the owning Form is minimized, the control goes on reporting the line positions the text had before the removal, and it paints its rows at those positions once the form is restored. The result is visibly garbled: log entries run together and line breaks fall in the middle of words.

RichTextBox.Text is completely intact the whole time. Only the control's idea of where its lines start is wrong, so nothing an application can check about its own text will detect this.

The control's own public API contradicts itself at that point:

box.TextLength                         = 1365
box.Text really contains               = 40    lines
box.GetFirstCharIndexFromLine(1)       = 15    <-- inside line 0, not a line start
box.GetFirstCharIndexFromLine(40)      = 1380  <-- 15 characters past the end of Text
box.GetFirstCharIndexFromLine(41)      = -1    <-- so the control believes it has 41 lines
box.GetLineFromCharIndex(1364)         = 39    <-- while its own line lookup says the text ends on line 39

Restoring the form does not correct it, and neither does repainting or continued appending. Only a genuine size change afterwards does — which is why, to a user, the window appears to "heal itself" the moment they drag its edge.

Insertions made in the same state are tracked correctly. Only removals are lost.

Why this is filed against WinForms

The underlying defect looks like it lives in RichEdit (msftedit.dll) — we also have a ~230-line pure-Win32 reproduction with no .NET involved, as reported here; I can attach it here if feedback hub is too annoying. The bug is filed here because WinForms is what makes ordinary applications hit it:

  • WinForms drives the control into the state that breaks it, by default. Minimizing the form makes WinForms' layout resize a Docked or Anchored child to the minimized client size (0x0 — step 2 of the output below prints it) and back again on restore. That resize-to-nothing and back is a necessary part of the failure: with --nodock, where the control's size is never touched, the same edit is handled correctly. A native application chooses whether to resize its child while iconic; a WinForms application with Dock = Fill does not.
  • The damage surfaces through public WinForms API. GetFirstCharIndexFromLine returns an index 15 characters past the end of Text. The reproduction below reads it back with nothing but that method — no SendMessage, no EM_LINEINDEX.
  • It reaches a widely used library. NLog.Windows.Forms' RichTextBoxTarget.MaxLines trimming performs exactly this removal, so any NLog-backed WinForms log window is exposed whenever it is minimized.

Steps to reproduce

Attached: StaleLines.cs.txt + StaleLines.csproj.txt (both renamed to allow upload here)

dotnet run --project StaleLines.csproj

Exit code 1 = reproduced, 0 = the control stayed consistent, 2 = Text itself was damaged (it never is), 9 = harness failure. Use --nopause for an unattended run.

The program:

  1. creates a Form with a RichTextBox at Dock = Fill, ReadOnly = true, WordWrap = false — an ordinary log view. Wrapping is off so that one line of Text must be exactly one row, which is what makes the check below exact;
  2. appends 40 lines with AppendText, deliberately of differing lengths so that a wrong line position shows up as split words rather than coincidentally landing on a real line start;
  3. sets WindowState = Minimized;
  4. removes the first line: Select(0, n) then SelectedRtf = "{\rtf1\ansi}", with n read from Text rather than assumed;
  5. sets WindowState = Normal;
  6. after each step, compares Text against every line position the control reports. With wrapping off, GetFirstCharIndexFromLine(i) must be 0 or land immediately after a '\n' in Text.

It runs on a real message loop (Application.Run, awaiting Task.Delay between steps) — no DoEvents, so the minimize and restore are processed normally.

Expected behaviour

After step 5 the control holds 1365 characters and 40 lines, so it should report 40 lines and every GetFirstCharIndexFromLine(i) should land on a line boundary — which is what step 1 shows it doing correctly before the minimize, and what step 5 shows it doing again after any real size change.

Actual behaviour

The control reports 41 lines, one line position is 15 characters past the end of Text, and 35 of the 41 land in the middle of a line. It paints accordingly.

.NET 10.0.10, x64
RichTextBox line positions vs Text (deleting 1 line, removed with SelectedRtf = "{\rtf1\ansi}")

  1. after 40 appends            control 820x470   Text:  1380 chars,  41 lines | control reports  41 lines,   0 start mid-line,   0 start past the end of Text
  2. minimized                   control 0x0       Text:  1380 chars,  41 lines | control reports  41 lines,   0 start mid-line,   0 start past the end of Text
  3. still minimized, deleted    control 0x0       Text:  1365 chars,  40 lines | control reports  41 lines,  35 start mid-line,   1 start past the end of Text
                                 first offender: GetFirstCharIndexFromLine(1) = 15  <-- not a line start
  4. restored                    control 820x470   Text:  1365 chars,  40 lines | control reports  41 lines,  35 start mid-line,   1 start past the end of Text
                                 first offender: GetFirstCharIndexFromLine(1) = 15  <-- not a line start

  Public API on that control, all at the same moment:
    box.TextLength                         = 1365
    box.Text really contains               = 40    lines
    box.GetFirstCharIndexFromLine(1)       = 15    <-- inside line 0, not a line start
    box.GetFirstCharIndexFromLine(40)      = 1380  <-- 15 characters past the end of Text
    box.GetFirstCharIndexFromLine(41)      = -1    <-- so the control believes it has 41 lines
    box.GetLineFromCharIndex(1364)         = 39    <-- while its own line lookup says the text ends on line 39

  The control reports these line positions, so this is what it paints:
    row  0 |line 01: the qu|
    row  1 |ick brown fox jumps over the lazy dog\nline 02: openin|   <-- does not start on a line boundary
    row  2 |g\nline 03: collec|   <-- does not start on a line boundary
    row  3 |ting frames\nline 04: a much|   <-- does not start on a line boundary
    row  4 | longer line of text so the rows are clearly of different lengths\nline 05: done\nl|   <-- does not start on a line boundary
    row  5 |ine 06: moving|   <-- does not start on a line boundary
    row  6 | to the next position and waiting for it to settle\nline 07: idle\nl|   <-- does not start on a line boundary
    row  7 |ine 08: reticu|   <-- does not start on a line boundary

  5. after a 1px form resize     control 820x470   Text:  1365 chars,  40 lines | control reports  40 lines,   0 start mid-line,   0 start past the end of Text

  The control reports these line positions, so this is what it paints:
    row  0 |line 01: the quick brown fox jumps over the lazy dog|
    row  1 |line 02: opening|
    row  2 |line 03: collecting frames|
    row  3 |line 04: a much longer line of text so the rows are clearly of different lengths|
    row  4 |line 05: done|
    row  5 |line 06: moving to the next position and waiting for it to settle|
    row  6 |line 07: idle|
    row  7 |line 08: reticulating splines|

FAIL: after the restore the control still described the deleted text.

Those "as painted" rows are not a reconstruction: they are the current Text cut at the positions the control reports, and they match the window on screen character for character (garbled-window.png; healed-window.png is the same control one 1px form resize later). \n marks a line break falling inside a row — two entries painted as one.

Garbled window Healed window

What narrows it down

Every row below is from the attached program on .NET 10; --insert, --anchor and --nodock were each also confirmed on .NET 8 and .NET Framework 4.8.

Variant Result What it shows
(default) removes one whole line fails
--chars removes 5 characters from inside a line fails No line boundary is touched, so the line count does not even change — yet 39 of 41 positions end up 5 characters off
--insert inserts 5 lines in the same state passes Specific to removal; insertions are tracked
--anchor sizes the control by Anchor instead of Dock fails Not a Dock peculiarity — any control WinForms resizes with the form is exposed
--nodock control keeps its size across the minimize passes The resize to the minimized client size and back is necessary
--api=rtf (default) SelectedRtf = "{\rtf1\ansi}" fails
--api=text SelectedText = "" with ReadOnly lifted fails
--api=clear Clear() then AppendText(remainder) fails
--api=settext Text = remainder fails

The four removal APIs fail identically — same character counts, same 35 bad positions — so this is below the public API surface, not a quirk of one of them.

From earlier narrowing with a larger harness (not attached, available on request):

  • Two lines and one removal are enough; document size is irrelevant.
  • The removed range may be anywhere — first, last or middle line, on screen or scrolled out.
  • Hide() does not trigger it, and neither does resizing the control to 0x0 while the form stays visible. It specifically needs the form to be minimized across the removal.

Workarounds we tried

While still minimized, immediately after the removal:

Attempt Effect
Invalidate() + Update(), WM_SETREDRAW off/on, EM_SCROLLCARET, re-querying the line positions none — the stale values persist
SetWindowPos on the control's handle none — size changes appear to be ignored while the form is iconic
EM_SETTARGETDEVICE(NULL, 0) corrects it, but a line width of 0 means "wrap to window", so it silently turns WordWrap back on. This is the same message WinForms uses to implement WordWrap = false (with width 1), and with width 1 it corrects nothing
EM_SETRECT with the client rect or the last-visible rect corrects it, but the rectangle becomes the control's explicit formatting rectangle and survives the restore, after which the control paints almost nothing
EM_SETRECT with a NULL rect none

After the restore:

  • A real size change on the control's window corrects it reliably. This is what we ship. Note that richTextBox.Width += 1 on a docked control is not such a change — WinForms' layout restores the docked bounds before RichEdit ever sees a resize, which cost us a release. It has to be either a size change on the form (what the attached program does at step 5) or SetWindowPos on richTextBox.Handle directly:
GetClientRect(box.Handle, out RECT rc);
int w = rc.Right - rc.Left, h = rc.Bottom - rc.Top;
SetWindowPos(box.Handle, IntPtr.Zero, 0, 0, w + 1, h, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
SetWindowPos(box.Handle, IntPtr.Zero, 0, 0, w,     h, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);

Every application with a RichTextBox log or transcript view that trims itself needs this, and there is nothing to tell them so.

Impact

We hit this in a production industrial inspection application. The log window renders garbled after the application has been minimized, operators cannot read it until they resize the window, and because Text is intact there is nothing in the application that can notice. It took a long investigation to establish that the log data was never actually corrupt.

Environment

  • OS: Windows 11 Pro, build 10.0.26200, x64
  • msftedit.dll: C:\Windows\System32\msftedit.dll, 3,284,992 bytes, SHA256 DEA2655F6F7F7CD4A89BAC75ECE47056761010F975F9B8B9CB8CFAFF8286789C. Reports version 10.0.26100.8521 when queried at a copied path; the same bytes report 10.0.26100.8875 at the System32 path (the version APIs merge in the MUI resource there) — please trust the SHA256
  • Process: x64. A 32-bit process loads SysWOW64's msftedit.dll, a different binary; the attached project sets PlatformTarget=x64 for that reason
  • Display: single 2560×1440, 96 DPI. Reproduces both DPI-unaware (the attached project, which declares nothing) and PerMonitorV2 (our earlier harness), so DPI awareness does not appear to matter
  • The occurrences that led us here were on a second machine on build 26100 with msftedit.dll 10.0.26100.7705.

Metadata

Metadata

Assignees

No one assigned

    Labels

    untriagedThe team needs to look at this issue in the next triage

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions