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;