Skip to main content

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.

info

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 Avalonia StyledElements)

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)
  • DocumentSnapshot consumptionTextDocument.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 document
  • FlowDocumentBuilder — UI-thread only (the resulting FlowDocument is itself a StyledElement)

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

  1. Create snapshots on UI thread — fast operation
  2. Process snapshots on background threads — safe and efficient
  3. Return to UI thread for document updates — use Dispatcher
  4. Check thread before UI operations — defensive programming
  5. Use async/await for clean code — natural thread switching

Don'ts

  1. Don't access live document from background threads
  2. Don't modify document from background threads
  3. Don't access UI elements from background threads
  4. Don't assume snapshots auto-update — they're immutable
  5. 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
});

See also