.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:
- 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;
- 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;
- sets
WindowState = Minimized;
- removes the first line:
Select(0, n) then SelectedRtf = "{\rtf1\ansi}", with n read from Text rather than assumed;
- sets
WindowState = Normal;
- 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.
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.
.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+?
net10.0-windows), x64net8.0-windows), x64net48), x64Issue description
If text is removed from a
RichTextBoxwhile the owningFormis 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.Textis 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:
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:Docked orAnchored 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 withDock = Filldoes not.GetFirstCharIndexFromLinereturns an index 15 characters past the end ofText. The reproduction below reads it back with nothing but that method — noSendMessage, noEM_LINEINDEX.NLog.Windows.Forms'RichTextBoxTarget.MaxLinestrimming 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)
Exit code 1 = reproduced, 0 = the control stayed consistent, 2 =
Textitself was damaged (it never is), 9 = harness failure. Use--nopausefor an unattended run.The program:
Formwith aRichTextBoxatDock = Fill,ReadOnly = true,WordWrap = false— an ordinary log view. Wrapping is off so that one line ofTextmust be exactly one row, which is what makes the check below exact;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;WindowState = Minimized;Select(0, n)thenSelectedRtf = "{\rtf1\ansi}", withnread fromTextrather than assumed;WindowState = Normal;Textagainst every line position the control reports. With wrapping off,GetFirstCharIndexFromLine(i)must be 0 or land immediately after a'\n'inText.It runs on a real message loop (
Application.Run, awaitingTask.Delaybetween steps) — noDoEvents, 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.Those "as painted" rows are not a reconstruction: they are the current
Textcut at the positions the control reports, and they match the window on screen character for character (garbled-window.png;healed-window.pngis the same control one 1px form resize later).\nmarks a line break falling inside a row — two entries painted as one.What narrows it down
Every row below is from the attached program on .NET 10;
--insert,--anchorand--nodockwere each also confirmed on .NET 8 and .NET Framework 4.8.--charsremoves 5 characters from inside a line--insertinserts 5 lines in the same state--anchorsizes the control byAnchorinstead ofDockDockpeculiarity — any control WinForms resizes with the form is exposed--nodockcontrol keeps its size across the minimize--api=rtf(default)SelectedRtf = "{\rtf1\ansi}"--api=textSelectedText = ""withReadOnlylifted--api=clearClear()thenAppendText(remainder)--api=settextText = remainderThe 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):
Hide()does not trigger it, and neither does resizing the control to0x0while 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:
Invalidate()+Update(),WM_SETREDRAWoff/on,EM_SCROLLCARET, re-querying the line positionsSetWindowPoson the control's handleEM_SETTARGETDEVICE(NULL, 0)WordWrapback on. This is the same message WinForms uses to implementWordWrap = false(with width 1), and with width 1 it corrects nothingEM_SETRECTwith the client rect or the last-visible rectEM_SETRECTwith aNULLrectAfter the restore:
richTextBox.Width += 1on 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) orSetWindowPosonrichTextBox.Handledirectly:Every application with a
RichTextBoxlog 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
Textis 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
msftedit.dll:C:\Windows\System32\msftedit.dll, 3,284,992 bytes, SHA256DEA2655F6F7F7CD4A89BAC75ECE47056761010F975F9B8B9CB8CFAFF8286789C. Reports version 10.0.26100.8521 when queried at a copied path; the same bytes report 10.0.26100.8875 at theSystem32path (the version APIs merge in the MUI resource there) — please trust the SHA256msftedit.dll, a different binary; the attached project setsPlatformTarget=x64for that reasonmsftedit.dll10.0.26100.7705.