Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed — memory held by paragraphs and pictures an edit removed (2026-09-28)

The per-paragraph layout cache pruned only once it passed 10,000 entries, and it is keyed by paragraph, so a
paragraph that editing took out of the document stayed reachable — with its text layout (~30 KB), and through
the layout's picture callback any inline picture's bytes — until then. Measured with the new
`LayoutCacheProbe` (`tests/AvaloniaRichEditor.Tests.Render`, `RICHEDITOR_PERF=1`, real Skia):

- A 2,000-paragraph document rewritten by "select all + type" and a paste, four times over: managed heap
141 → 210 → 278 → **346 MB**, back to 76 MB only when the cap finally pruned. Now **81 MB flat**.
- 20 inline pictures (40 MB) deleted by editing, undo history aside: **39.5 MB stayed alive**. Now 5.7 MB.

Every content edit now schedules a prune one second after the last one. The walk costs ~1–2 ms at 2,000
paragraphs and ~3–5 ms at 8,000 — too much per keystroke, nothing once per pause — and it only drops entries
for paragraphs no longer in the document, so nothing reshapes. Found by measuring what the WinUI port asked
about after bounding its own caches.

Not changed here: the cache still holds a layout for EVERY paragraph of the document (Measure, Render and
hit-testing share it) — ~20–40 KB each, 6–9× the document model; 8,000 paragraphs ≈ 240–300 MB. Bounding that
needs measurement to stop using the cache (the port measures with throwaway layouts and caches heights).

### Fixed — found by the WinUIRichEditor port's audit (2026-09-24)

The port audited what it had taken in since its last release (these files among it); each defect below
Expand Down
1 change: 1 addition & 0 deletions src/AvaloniaRichEditor/Controls/RichEditor.Rendering.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ public override void Render(DrawingContext context)
bool contentChanged = _textChangedPending;
// Flush change events after the edit/caret move that scheduled this paint (off the render stack).
RaisePendingChangeEvents();
if (contentChanged) ScheduleLayoutCachePrune(); // an edit may have dropped paragraphs (see there)
// Transparent fill makes the whole control hit-testable (clicks on empty space below the
// text must still reach OnPointerPressed). Must cover the full bounds, not a fixed height.
context.FillRectangle(Brushes.Transparent, new Rect(Bounds.Size));
Expand Down
30 changes: 30 additions & 0 deletions src/AvaloniaRichEditor/Controls/RichEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1612,6 +1612,36 @@ private Avalonia.Media.TextFormatting.TextLayout PreeditAwareLayout(Paragraph p,
? BuildTextLayout(p, width, _caretPosition.Offset, _preeditText)
: BuildTextLayout(p, width);

// The 10,000-entry cap above was the only thing that ever pruned, and a Dictionary keyed by paragraph keeps a
// paragraph an edit removed — with its TextLayout, ~30 KB each, and through the layout's picture callback its
// InlineImage's bytes — until then. Measured 2026-09-28 (Tests.Render LayoutCacheProbe, real Skia): a
// 2,000-paragraph document rewritten by "select all + type" and a paste, four times, held 346 MB of managed
// heap, 76 MB after the prune finally ran; 40 MB of inline pictures deleted by editing stayed alive (undo
// history dropped) until the cache was cleared. Now every content edit also schedules a prune, one second
// after the last one: the walk costs ~2 ms at 2,000 paragraphs and ~3.4 ms at 8,000, too much per keystroke
// and nothing once per pause. A pruned entry was, by definition, not in the document, so nothing reshapes.
private DispatcherTimer? _layoutPruneTimer;

private void ScheduleLayoutCachePrune()
{
if (_layoutPruneTimer == null)
{
_layoutPruneTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_layoutPruneTimer.Tick += (_, _) => RunScheduledLayoutCachePrune();
}
_layoutPruneTimer.Stop();
_layoutPruneTimer.Start();
}

// The timer's tick; internal so a test can fire it without waiting a second of real time.
internal void RunScheduledLayoutCachePrune()
{
_layoutPruneTimer?.Stop();
PruneLayoutCaches();
}

internal bool LayoutCachePruneScheduled => _layoutPruneTimer?.IsEnabled == true; // test hook

// Drops cache entries for paragraphs/tables no longer in the document (e.g. deleted while editing),
// keeping the live ones so nothing reshapes on the next frame.
private void PruneLayoutCaches()
Expand Down
172 changes: 172 additions & 0 deletions tests/AvaloniaRichEditor.Tests.Render/LayoutCacheProbeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
using System;
using System.Collections;
using System.Diagnostics;
using System.Reflection;
using System.Text;
using Avalonia;
using Avalonia.Headless.XUnit;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using AvaloniaRichEditor.Controls;
using AvaloniaRichEditor.Documents;
using AvaloniaRichEditor.Formatters;
using Xunit;

namespace AvaloniaRichEditor.Tests.Render;

// What the per-paragraph TextLayout cache holds — the WinUI peer's question (2026-09-28). There the render
// path caches only what it DRAWS and measurement builds throwaway layouts; here Measure, Render and hit-testing
// share one cache, so it holds every paragraph of the document, pruned only past 10,000 entries — and a
// Dictionary<Paragraph, …> keeps a paragraph an edit removed until then.
// Real Skia (this project), because the no-op backend's shaping would say nothing about a TextLayout's size.
// A measuring harness: skipped unless RICHEDITOR_PERF=1. Also appended to %TEMP%\richeditor_layout_probe.txt.
// $env:RICHEDITOR_PERF=1; dotnet test tests/AvaloniaRichEditor.Tests.Render -c Release --filter LayoutCacheProbe
public class LayoutCacheProbeTests(ITestOutputHelper output)
{
private static bool Enabled => Environment.GetEnvironmentVariable("RICHEDITOR_PERF") == "1";
private const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance;
private const double Mb = 1024 * 1024;

private void Log(string line)
{
output.WriteLine(line);
System.IO.File.AppendAllText(System.IO.Path.Combine(System.IO.Path.GetTempPath(), "richeditor_layout_probe.txt"), line + "\n");
}

// The same paragraph shape as the peer's MemBaseline harness: mixed Hangul/Latin, inline formatting, wraps.
private static string BigHtml(int paras, int from = 0)
{
var sb = new StringBuilder();
for (int i = from; i < from + paras; i++)
sb.Append("<p>문단 ").Append(i)
.Append(" — <b>굵게</b> <i>기울임</i> <u>밑줄</u> <s>취소선</s> ")
.Append("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod ")
.Append("tempor incididunt ut labore et dolore magna aliqua. ")
.Append("한글과 영문이 충분히 섞인 긴 문장으로 컨트롤 폭에서 자동 줄바꿈을 유발합니다.</p>");
return sb.ToString();
}

// Measure + arrange + one real render of an 800x600 viewport, then the dispatcher's queue.
private static void Settle(RichEditor ed)
{
ed.Measure(new Size(800, double.PositiveInfinity));
ed.Arrange(new Rect(0, 0, 800, Math.Max(600, ed.DesiredSize.Height)));
using (var rtb = new RenderTargetBitmap(new PixelSize(800, 600))) rtb.Render(ed);
Dispatcher.UIThread.RunJobs();
}

private static long Managed()
{
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
return GC.GetTotalMemory(true);
}

private static IDictionary Cache(RichEditor ed) => (IDictionary)typeof(RichEditor).GetField("_layoutCache", NP)!.GetValue(ed)!;
private static IDictionary TableCache(RichEditor ed) => (IDictionary)typeof(RichEditor).GetField("_tableLayoutCache", NP)!.GetValue(ed)!;
private static void Invoke(RichEditor ed, string name) => typeof(RichEditor).GetMethod(name, NP, Type.EmptyTypes)!.Invoke(ed, null);

// Taken in their own frames: a temporary in the test method's frame kept the target reachable (the peer's
// LayoutCostTests met the same trap), and "still alive" would then measure the test, not the editor.
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
private static WeakReference WeakBlock(RichEditor ed, int index) => new(ed.Document!.Blocks[index]);

[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
private static WeakReference WeakPictureBytes(RichEditor ed, int index)
=> new(((InlineImage)((Paragraph)ed.Document!.Blocks[index]).Inlines[1]).RawBytes);

private static long Priv() { var p = Process.GetCurrentProcess(); p.Refresh(); return p.PrivateMemorySize64; }

[AvaloniaFact]
public void A_WhatTheCacheCosts_ByDocumentSize()
{
if (!Enabled) return;
Log($"=== [upstream] layout cache, {DateTime.Now:yyyy-MM-dd HH:mm}");
foreach (int n in new[] { 200, 2000, 8000 })
{
long empty = Managed();
var doc = HtmlDocumentFormatter.ParseHtml(BigHtml(n));
long model = Managed() - empty;
var ed = new RichEditor { Document = doc };
Settle(ed);
long withCache = Managed();
long privWith = Priv();
int count = Cache(ed).Count;
Cache(ed).Clear();
TableCache(ed).Clear();
long without = Managed();
long cache = withCache - without;
Settle(ed); // refill, then time the prune walk with nothing to drop — what an edit would pay
var prune = typeof(RichEditor).GetMethod("PruneLayoutCaches", NP)!;
prune.Invoke(ed, null);
var sw = Stopwatch.StartNew();
for (int k = 0; k < 20; k++) prune.Invoke(ed, null);
double pruneMs = sw.Elapsed.TotalMilliseconds / 20;
Log($" {n,5} paras: model {model / Mb,6:F1} MB | cache holds {count,5} layouts = {cache / Mb,6:F1} MB " +
$"({cache / (double)n / 1024:F1} KB/para, {cache / (double)Math.Max(1, model):F1}x the model) | Priv {privWith / Mb:F0} MB | prune walk {pruneMs:F2} ms");
GC.KeepAlive(ed);
}
}

[AvaloniaFact]
public void B_ParagraphsAnEditRemoved_StayInTheCache()
{
if (!Enabled) return;
const int n = 2000, cycles = 6;
var ed = new RichEditor { Document = HtmlDocumentFormatter.ParseHtml(BigHtml(n)) };
Settle(ed);
// Blocks[1]: select all + type keeps the FIRST paragraph (it takes the "x"), so it is the second that leaves.
var firstWeak = WeakBlock(ed, 1);
Log($" graveyard: {n} paras, each cycle = select all + type 'x' (same document instance) + insert {n} new paras");
for (int c = 0; c < cycles; c++)
{
Invoke(ed, "SelectAll");
ed.InsertText("x");
Settle(ed);
ed.InsertHtml(BigHtml(n, (c + 1) * n));
Settle(ed);
ed.RunScheduledLayoutCachePrune(); // the pause after the edit: what the 1 s timer does
long m = Managed();
int live = ed.Document!.Blocks.Count;
Log($" cycle {c}: blocks in doc {live,5} | cache {Cache(ed).Count,5} | managed {m / Mb,6:F1} MB | removed paragraph alive: {firstWeak.IsAlive}");
}
long before = Managed();
typeof(RichEditor).GetMethod("PruneLayoutCaches", NP)!.Invoke(ed, null);
long after = Managed();
Log($" PruneLayoutCaches now: cache {Cache(ed).Count} | frees {(before - after) / Mb:F1} MB | removed paragraph alive: {firstWeak.IsAlive}");
}

[AvaloniaFact]
public void C_PicturesAnEditRemoved_AreHeldThroughTheCachedLayout()
{
if (!Enabled) return;
const int pictures = 20, size = 2 * 1024 * 1024;
var doc = new FlowDocument();
var rnd = new Random(3);
for (int i = 0; i < pictures; i++)
{
var raw = new byte[size];
rnd.NextBytes(raw);
var img = new InlineImage { Width = 40, Height = 30 };
img.SetImageData(raw, "image/jpeg"); // undecodable on purpose: the retention is of the BYTES
doc.Blocks.Add(new Paragraph { Inlines = { new Run { Text = $"picture {i} " }, img } });
}
var ed = new RichEditor { Document = doc };
Settle(ed);
var weakBytes = WeakPictureBytes(ed, 10);
long loaded = Managed();

Invoke(ed, "SelectAll");
ed.InsertText("x");
Settle(ed);
ed.RunScheduledLayoutCachePrune(); // the pause after the edit
typeof(RichEditor).GetField("_undoManager", NP)!.SetValue(ed, Activator.CreateInstance(
typeof(RichEditor).GetField("_undoManager", NP)!.FieldType)); // history is budgeted separately; take it out of the picture
long deleted = Managed();
bool aliveAfterEdit = weakBytes.IsAlive;

Cache(ed).Clear();
long cleared = Managed();
Log($" pictures: {pictures} x {size / Mb:F0} MB inline, removed by select all + type (undo history dropped)");
Log($" loaded {loaded / Mb,6:F1} MB | after the edit {deleted / Mb,6:F1} MB (bytes alive: {aliveAfterEdit}) | cache cleared {cleared / Mb,6:F1} MB (bytes alive: {weakBytes.IsAlive})");
}
}
131 changes: 131 additions & 0 deletions tests/AvaloniaRichEditor.Tests/LayoutCachePruneTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
using System;
using System.Collections;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using Avalonia;
using Avalonia.Headless.XUnit;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using AvaloniaRichEditor.Controls;
using AvaloniaRichEditor.Documents;
using Xunit;

namespace AvaloniaRichEditor.Tests;

// Paragraphs an edit removed stayed in the layout cache — with their TextLayouts, and through a layout's picture
// callback their InlineImages — until the cache passed 10,000 entries. Measured 2026-09-28 with the Tests.Render
// LayoutCacheProbe: a 2,000-paragraph document rewritten four times held 346 MB (76 MB after the prune), and
// 40 MB of pictures deleted by editing stayed alive. Every content edit now schedules a prune a second after the
// last one; the tests fire the timer's tick directly instead of waiting.
public class LayoutCachePruneTests
{
private const BindingFlags NP = BindingFlags.NonPublic | BindingFlags.Instance;

private static void Render(RichEditor ed, double w = 400)
{
ed.Measure(new Size(w, double.PositiveInfinity));
ed.Arrange(new Rect(0, 0, w, ed.DesiredSize.Height));
using var rtb = new RenderTargetBitmap(new PixelSize((int)w, (int)Math.Max(1, Math.Min(4000, ed.DesiredSize.Height))));
rtb.Render(ed);
Dispatcher.UIThread.RunJobs();
}

private static IDictionary Cache(RichEditor ed) => (IDictionary)typeof(RichEditor).GetField("_layoutCache", NP)!.GetValue(ed)!;
private static void SelectAll(RichEditor ed) => typeof(RichEditor).GetMethod("SelectAll", NP, Type.EmptyTypes)!.Invoke(ed, null);

private static RichEditor Editor(int paragraphs, bool withPicture = false)
{
var doc = new FlowDocument();
for (int i = 0; i < paragraphs; i++)
{
var p = new Paragraph { Inlines = { new Run { Text = $"paragraph {i} with enough words to be worth a layout" } } };
if (withPicture && i == 5)
{
var img = new InlineImage { Width = 20, Height = 20 };
img.SetImageData(new byte[] { 1, 2, 3, 4 }, "image/png");
p.Inlines.Add(img);
}
doc.Blocks.Add(p);
}
var ed = new RichEditor { Document = doc };
Render(ed);
ed.RunScheduledLayoutCachePrune(); // loading is not what these tests are about
return ed;
}

// Own frames, so no temporary in the test's frame keeps the target reachable.
[MethodImpl(MethodImplOptions.NoInlining)]
private static WeakReference WeakBlock(RichEditor ed, int index) => new(ed.Document!.Blocks[index]);

[MethodImpl(MethodImplOptions.NoInlining)]
private static WeakReference WeakPicture(RichEditor ed, int index)
=> new(((Paragraph)ed.Document!.Blocks[index]).Inlines.OfType<InlineImage>().Single());

private static void FullGc() { GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); }

[AvaloniaFact]
public void ParagraphsAnEditRemoved_LeaveTheCache_OnTheScheduledPrune()
{
var ed = Editor(50);
Assert.Equal(50, Cache(ed).Count);
var removed = WeakBlock(ed, 7); // select all + type keeps paragraph 0 (it takes the text); 7 leaves

SelectAll(ed);
ed.InsertText("x");
Render(ed);

Assert.True(ed.LayoutCachePruneScheduled, "an edit's render did not schedule the prune");
Assert.True(Cache(ed).Count > ed.Document!.Blocks.Count, "the edit left no dead entries — nothing to test");

ed.RunScheduledLayoutCachePrune();
FullGc();

Assert.Equal(ed.Document!.Blocks.Count, Cache(ed).Count);
Assert.False(removed.IsAlive, "a paragraph the edit removed is still reachable");
Assert.False(ed.LayoutCachePruneScheduled);
}

[AvaloniaFact]
public void APictureAnEditRemoved_IsNoLongerHeldByACachedLayout()
{
var ed = Editor(10, withPicture: true);
var picture = WeakPicture(ed, 5);

SelectAll(ed);
ed.InsertText("x");
Render(ed);
ed.RunScheduledLayoutCachePrune();
FullGc();

// The undo snapshot holds a CLONE of the picture (sharing its bytes by design); the original element,
// which the cached layout's picture callback captured, is what must go.
Assert.False(picture.IsAlive, "the removed InlineImage is still reachable");
}

[AvaloniaFact]
public void ThePrune_KeepsTheLiveLayouts_SoNothingReshapes()
{
var ed = Editor(20);
var keep = (Paragraph)ed.Document!.Blocks[3];
var before = Cache(ed)[keep];

ed.Document!.Blocks.RemoveAt(10);
ed.InsertText("y"); // an edit elsewhere (the caret sits in paragraph 0)
Render(ed);
ed.RunScheduledLayoutCachePrune();

Assert.True(Cache(ed).Contains(keep));
Assert.Equal(before, Cache(ed)[keep]); // the same entry — same TextLayout instance
}

[AvaloniaFact]
public void ARenderWithoutAnEdit_SchedulesNothing()
{
var ed = Editor(5);
Assert.False(ed.LayoutCachePruneScheduled);
ed.InvalidateVisual();
Render(ed); // a caret blink or a scroll repaint
Assert.False(ed.LayoutCachePruneScheduled);
}
}
Loading