Thread Safety
The RichTextEditor architecture uses immutable snapshots for background-safe serialization while requiring UI thread access for live document operations. This guide explains the threading model and safe patterns.
This control is available as part of Avalonia Pro or higher.
Threading model
UI thread required
These operations must run on the UI thread:
- Document editing (
TextDocument,TextPointer,TextRange) - Rendering (
TextViewBase,InteractiveTextView,ITextView) - User interaction (
TextSelection, components) - Undo/redo operations
- Element access (
FlowDocument,RichTextElement, and any child elements — they are AvaloniaStyledElements)
Background thread safe
These operations can run on background threads:
- Serialization via
DocumentSnapshot(immutable) IDocumentSerializer.SerializeAsync/DeserializeAsync— async-only by interface contract- RTF tokenization (streaming)
DocumentSnapshotconsumption —TextDocument.CreateSnapshot()must be called on the UI thread, but the returned object is safe to read from any thread
Not thread-safe
- Live
TextDocument— no concurrent modification - UI element access — Avalonia controls are not thread-safe
TextPointer/TextRange— tied to UI-thread documentFlowDocumentBuilder— UI-thread only (the resultingFlowDocumentis itself aStyledElement)
Safe patterns
Background serialization
async Task SaveAsync(string path)
{
// SaveAsync snapshots internally and serializes on background thread
await using var stream = File.Create(path);
await editor.SaveAsync(stream, new RtfSerializer());
}
If you need manual control over the snapshot (e.g., for a custom format):
async Task SaveManualAsync(string path)
{
// UI thread: snapshot the document through FlowDocument
var doc = editor.Document;
if (doc == null) return;
await using var stream = File.Create(path);
await doc.SaveAsync(stream, new RtfSerializer());
}
Background deserialization
async Task LoadAsync(string path)
{
// LoadAsync deserializes and builds the document
await using var stream = File.OpenRead(path);
await editor.LoadAsync(stream, new RtfSerializer());
}
Or load independently of the editor:
async Task LoadStandaloneAsync(string path)
{
await using var stream = File.OpenRead(path);
var document = await FlowDocument.LoadAsync(stream, new RtfSerializer());
// Assign on UI thread
editor.Document = document;
}
Background document processing
async Task<string> ExtractPlainTextAsync()
{
// Read text through the public TextRange API (UI thread)
string? text = editor.Document?.ContentRange?.Text;
if (text == null) return string.Empty;
// Background thread: Process the extracted text
return await Task.Run(() =>
{
return ProcessText(text);
});
}
Unsafe patterns
Don't access live document from background thread
// WRONG — will throw
await Task.Run(() =>
{
var doc = editor.Document?.TextDocument;
string? text = editor.Document?.ContentRange?.Text; // Exception
});
Don't modify document from background thread
// WRONG — will throw
await Task.Run(() =>
{
document.ContentStart.InsertText("Hello"); // Exception
});
Don't access UI elements from background thread
// WRONG — will throw
await Task.Run(() =>
{
// FlowDocument and RichTextElement are Avalonia StyledElements;
// touching their properties from a background thread throws.
var firstParagraph = flowDocument.Blocks.FirstOrDefault();
var background = firstParagraph?.Background; // Exception
});
DocumentSnapshot design
Immutable structure
DocumentSnapshot is designed for thread safety:
- Immutable — cannot be modified after creation
- No UI references — pure data structure
- Shared nodes — efficient memory sharing with live document
- Self-contained — all data copied from live document
Snapshot hierarchy
DocumentSnapshot (thread-safe)
├─ BlockSnapshotNode
│ ├─ InlineSnapshotNode
│ └─ InlineSnapshotNode
└─ BlockSnapshotNode
└─ InlineSnapshotNode
All nodes are immutable value types or readonly structures.
Creating snapshots
Snapshot creation is an internal mechanism used by serializers. The public API for background serialization is through SaveAsync/LoadAsync:
// Save using the async API (handles snapshot internally)
await using var stream = File.Create("output.rtf");
await editor.SaveAsync(stream, new RtfSerializer());
FlowDocumentBuilder
FlowDocumentBuilder provides a fluent API for constructing documents. It runs on the UI thread:
var builder = FlowDocumentBuilder.Create();
builder.AddParagraph("First paragraph");
builder.AddParagraph("Second paragraph");
var document = builder.Build();
editor.Document = document;
Synchronization strategies
Dispatcher pattern
async Task UpdateFromBackgroundAsync()
{
// Background work
var data = await FetchDataAsync();
// Switch to UI thread
await Dispatcher.UIThread.InvokeAsync(() =>
{
UpdateDocument(data);
});
}
async/await pattern
async Task SaveAndProcessAsync(string path)
{
// Save on background via async serializer
await using var stream = File.Create(path);
await editor.SaveAsync(stream, new RtfSerializer());
// Automatically back on UI thread after await
ShowSaveComplete();
}
Common scenarios
Spell check on background thread
class SpellChecker
{
public async Task<List<SpellError>> CheckAsync()
{
// UI thread: Get full text
string? text = editor.Document?.ContentRange?.Text;
if (string.IsNullOrEmpty(text)) return new List<SpellError>();
// Background: Check spelling
return await Task.Run(() =>
{
var errors = new List<SpellError>();
// Run spell check algorithm on the extracted text
return errors;
});
}
public async Task ApplyCorrectionsAsync(
TextDocument document,
List<SpellError> errors)
{
// UI thread: Apply corrections
await Dispatcher.UIThread.InvokeAsync(() =>
{
using (document.BeginChange())
{
foreach (var error in errors)
{
var start = document.ContentStart.CreatePointer(error.Offset);
var end = document.ContentStart.CreatePointer(error.Offset + error.Length);
var range = new TextRange(start, end);
range.Text = error.Correction;
}
}
});
}
}
Word count in background
async Task<int> CountWordsAsync()
{
// UI thread: Get text through public API
string? text = editor.Document?.ContentRange?.Text;
if (string.IsNullOrEmpty(text)) return 0;
// Background: Count
return await Task.Run(() =>
{
return text.Split(new[] { ' ', '\n', '\r', '\t' },
StringSplitOptions.RemoveEmptyEntries).Length;
});
}
Export to PDF on background thread
async Task ExportPdfAsync(string path)
{
// UI thread: Get full document text
string? text = editor.Document?.ContentRange?.Text;
if (text == null) return;
// Background: Generate PDF
await Task.Run(() =>
{
var generator = new PdfGenerator();
generator.GenerateFromText(text, path);
});
}
Weak references and thread safety
Why weak references?
TextDocumentNode uses weak references to UI elements:
- Prevents memory leaks
- Allows garbage collection
- No strong coupling
Thread safety implications
Node-to-element lookups are managed internally. From the public API, always access FlowDocument elements on the UI thread:
// Must be on UI thread
var firstBlock = flowDocument.Blocks.FirstOrDefault();
if (firstBlock != null)
{
var background = firstBlock.Background;
}
Best practices
Do's
- Create snapshots on UI thread — fast operation
- Process snapshots on background threads — safe and efficient
- Return to UI thread for document updates — use Dispatcher
- Check thread before UI operations — defensive programming
- Use async/await for clean code — natural thread switching
Don'ts
- Don't access live document from background threads
- Don't modify document from background threads
- Don't access UI elements from background threads
- Don't assume snapshots auto-update — they're immutable
- Don't hold long-lived references to UI elements — use weak refs
Performance considerations
Snapshot creation cost
- Small documents (<10KB): ~1ms
- Large documents (1MB): ~10ms
- Impact: Negligible for background operations
Thread switching cost
- Dispatcher invoke: ~1-2ms overhead
- Recommendation: Batch UI updates, don't switch per character
Optimal pattern
// Bad: Too many thread switches
await Task.Run(async () =>
{
for (int i = 0; i < 1000; i++)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
UpdateUI(i); // 1000 dispatches
});
}
});
// Good: One switch
var results = await Task.Run(() =>
{
var items = new List<Item>();
for (int i = 0; i < 1000; i++)
{
items.Add(ProcessItem(i));
}
return items;
});
await Dispatcher.UIThread.InvokeAsync(() =>
{
UpdateUIBatch(results); // 1 dispatch
});