diff --git a/src/Avalonia.Controls/TextBlock.cs b/src/Avalonia.Controls/TextBlock.cs
index 4d1153dfa33..5f2914a518e 100644
--- a/src/Avalonia.Controls/TextBlock.cs
+++ b/src/Avalonia.Controls/TextBlock.cs
@@ -186,7 +186,31 @@ public TextBlock()
///
/// Gets the used to render the text.
///
- public TextLayout TextLayout => _textLayout ??= CreateTextLayout(Text);
+ public TextLayout TextLayout => _textLayout ??= CreateTextLayoutCore();
+
+ private TextLayout CreateTextLayoutCore()
+ {
+ // MeasureOverride normally builds the text runs before the layout is created. When the
+ // layout is requested outside of a measure pass - for example by a render pass that runs
+ // before a queued measure - the runs are still null and CreateTextLayout would silently
+ // fall back to Text, shaping the wrong (usually empty) content. The shaped result is
+ // stored in the TextRunCache keyed only by text source index, so that wrong content would
+ // then be reused by every later layout until the cache is invalidated.
+ if (_textRuns == null && HasComplexContent)
+ {
+ var textRuns = new List();
+ var constraint = GetMaxSizeFromConstraint();
+
+ foreach (var inline in Inlines!)
+ {
+ inline.BuildTextRun(textRuns, constraint);
+ }
+
+ _textRuns = textRuns;
+ }
+
+ return CreateTextLayout(Text);
+ }
///
/// Gets or sets the padding to place around the .
diff --git a/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs b/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs
index df2e5c78b50..ca25dacf98f 100644
--- a/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/TextBlockTests.cs
@@ -614,6 +614,68 @@ public void Measure_And_Arrange_Should_Use_WidthIncludingTrailingWhitespace_For_
Assert.Equal(new Rect(default, expectedSize), target.Bounds);
}
+ [Fact]
+ public void Reading_TextLayout_Before_Measure_Should_Not_Poison_Inlines_Shaping()
+ {
+ using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface);
+
+ var target = new TextBlock
+ {
+ Inlines = new InlineCollection { new Run("Hello World") }
+ };
+
+ // Something outside of the layout pass (e.g. a render pass that runs before the
+ // queued measure) reads TextLayout while _textRuns has not been built yet.
+ _ = target.TextLayout;
+
+ target.Measure(new Size(1000, 1000));
+
+ Assert.True(target.DesiredSize.Width > 0, $"DesiredSize was {target.DesiredSize}");
+ }
+
+ [Fact]
+ public void Reading_TextLayout_Between_Invalidation_And_Measure_Should_Not_Poison_Inlines_Shaping()
+ {
+ using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface);
+
+ // Starts out empty, exactly like a title bound through a converter that yields an
+ // empty InlineCollection until the view model delivers the real value.
+ var target = new TextBlock { Inlines = new InlineCollection() };
+
+ target.Measure(new Size(1000, 1000));
+ target.Arrange(new Rect(0, 0, 1000, 1000));
+
+ target.Inlines = new InlineCollection { new Run("Hello World") };
+
+ // Render runs before the queued measure is processed.
+ _ = target.TextLayout;
+
+ target.Measure(new Size(1000, 1000));
+
+ Assert.True(target.DesiredSize.Width > 0, $"DesiredSize was {target.DesiredSize}");
+ }
+
+ [Fact]
+ public void Reading_TextLayout_Before_Measure_Should_Not_Poison_TextRunCache_Across_Constraints()
+ {
+ using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface);
+
+ var target = new TextBlock { Inlines = new InlineCollection() };
+
+ target.Measure(new Size(1000, 1000));
+ target.Arrange(new Rect(0, 0, 1000, 1000));
+
+ target.Inlines = new InlineCollection { new Run("Hello World") };
+
+ _ = target.TextLayout;
+
+ // A *different* constraint forces MeasureOverride to drop _textLayout, so anything
+ // still wrong here comes from the TextRunCache rather than the stale layout.
+ target.Measure(new Size(900, 1000));
+
+ Assert.True(target.DesiredSize.Width > 0, $"DesiredSize was {target.DesiredSize}");
+ }
+
private class TestTextBlock : TextBlock
{
public Size Constraint => _constraint;