From cd1ec754a6809569ec38db59e03dc90975fcbf75 Mon Sep 17 00:00:00 2001 From: Rainer Mager Date: Mon, 27 Apr 2026 14:18:18 +0900 Subject: [PATCH] Fix iOS infinite layout invalidation loop from bit-exact Frame equality VisualElement.Frame's setter uses Rect's bit-exact equality (Rect.Equals on doubles) to decide whether to enter UpdateBoundsComponents. On iOS, ULP-level non-determinism from UILabel.SizeThatFits / CoreText propagates through VerticalStackLayout accumulation and Grid("*","Auto") star-row arithmetic into a child's Frame, producing a Rect that differs from the cached one by ~10-22 ULP. Bit-exact equality treats the rects as different, fires Width/Height PropertyChanged + SizeChanged, layout invalidates, UIKit reschedules layoutSubviews, and the cycle repeats without converging. The UI thread becomes permanently stuck. Add Rect.EqualsApproximately(other, epsilon) and use it in the Frame setter with epsilon = 1e-9 - well above ~10^-13 ULP at typical layout magnitudes and well below sub-pixel resolution on any current iOS device. Mirrors the precedent set by SafeAreaPadding.EqualsAtPixelLevel (introduced for issues #32586 and #33934). Adds two test files: - VisualElementTests.FrameAssignmentIgnoresSubPixelDifferences: Regression test using the actual border heights captured in the repro trace (556.00000063578295 vs 556.00000063578273); fails on pre-fix main, passes after the change. - RectTests: focused coverage of Rect.EqualsApproximately. Fixes #35142 --- .../src/Core/VisualElement/VisualElement.cs | 13 ++++- .../Core.UnitTests/VisualElementTests.cs | 39 +++++++++++++++ .../PublicAPI/net/PublicAPI.Unshipped.txt | 1 + .../netstandard/PublicAPI.Unshipped.txt | 1 + src/Graphics/src/Graphics/Rect.cs | 16 +++++++ .../tests/Graphics.Tests/RectTests.cs | 47 +++++++++++++++++++ 6 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 src/Graphics/tests/Graphics.Tests/RectTests.cs diff --git a/src/Controls/src/Core/VisualElement/VisualElement.cs b/src/Controls/src/Core/VisualElement/VisualElement.cs index 9c9b21192aa7..88517f98b88f 100644 --- a/src/Controls/src/Core/VisualElement/VisualElement.cs +++ b/src/Controls/src/Core/VisualElement/VisualElement.cs @@ -1866,6 +1866,17 @@ void UpdateBoundsComponents(Rect bounds) EventHandler? _unloaded; bool _watchingPlatformLoaded; Rect _frame = new Rect(0, 0, -1, -1); + + // Tolerance used by the Frame setter to absorb ULP-level non-determinism in measured/arranged + // bounds (e.g. iOS UILabel.SizeThatFits returning bit-different doubles across consecutive + // passes). Without this tolerance, sub-ULP differences propagate as Width/Height + // PropertyChanged and SizeChanged events, which can drive iOS layoutSubviews into a + // non-converging 2-cycle. See https://github.com/dotnet/maui/issues/35142. The threshold is + // well above ~10⁻¹³ ULP at typical layout magnitudes and well below sub-pixel resolution on + // any current device (a 3× display's pixel is ~0.333pt). Mirrors the precedent set by + // SafeAreaPadding.EqualsAtPixelLevel for issues #32586 and #33934. + const double FrameEqualityEpsilon = 1e-9; + event EventHandler? _windowChanged; event EventHandler? _platformContainerViewChanged; @@ -1878,7 +1889,7 @@ public Rect Frame get => _frame; set { - if (_frame == value) + if (_frame.EqualsApproximately(value, FrameEqualityEpsilon)) return; UpdateBoundsComponents(value); diff --git a/src/Controls/tests/Core.UnitTests/VisualElementTests.cs b/src/Controls/tests/Core.UnitTests/VisualElementTests.cs index 73ccf71b8fd2..ff3777995a54 100644 --- a/src/Controls/tests/Core.UnitTests/VisualElementTests.cs +++ b/src/Controls/tests/Core.UnitTests/VisualElementTests.cs @@ -307,5 +307,44 @@ public void WidthAndHeightRequestPropagateToHandler() Assert.Equal(2, heightMapperCalled); Assert.Equal(2, widthMapperCalled); } + + // Regression test for dotnet/maui#35142: on iOS, ULP-level non-determinism in + // VerticalStackLayout/Label measurement (CoreText subpixel rounding) propagates through + // Grid("*,Auto") star-row arithmetic into a child's Frame. The bit-exact Rect equality + // in the Frame setter then re-runs UpdateBoundsComponents, which fires Width/Height + // PropertyChanged on every ~10 ULP delta. UIKit reschedules layoutSubviews and the loop + // never converges. The values below are the actual border heights captured in the repro + // trace (see https://github.com/dotnet/maui/issues/35142). + [Fact] + public void FrameAssignmentIgnoresSubPixelDifferences() + { + var rectA = new Rect(0, 0, 390, 556.00000063578295); + var rectB = new Rect(0, 0, 390, 556.00000063578273); // ~22 ULP different from rectA + + var element = new Label(); + element.Frame = rectA; + + int sizeChangedCount = 0; + int heightPropertyChangedCount = 0; + int widthPropertyChangedCount = 0; + element.SizeChanged += (_, _) => sizeChangedCount++; + element.PropertyChanged += (_, e) => + { + if (e.PropertyName == VisualElement.HeightProperty.PropertyName) + { + heightPropertyChangedCount++; + } + else if (e.PropertyName == VisualElement.WidthProperty.PropertyName) + { + widthPropertyChangedCount++; + } + }; + + element.Frame = rectB; + + Assert.Equal(0, sizeChangedCount); + Assert.Equal(0, heightPropertyChangedCount); + Assert.Equal(0, widthPropertyChangedCount); + } } } diff --git a/src/Graphics/src/Graphics/PublicAPI/net/PublicAPI.Unshipped.txt b/src/Graphics/src/Graphics/PublicAPI/net/PublicAPI.Unshipped.txt index 7dc5c58110bf..491f6f92f766 100644 --- a/src/Graphics/src/Graphics/PublicAPI/net/PublicAPI.Unshipped.txt +++ b/src/Graphics/src/Graphics/PublicAPI/net/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +Microsoft.Maui.Graphics.Rect.EqualsApproximately(Microsoft.Maui.Graphics.Rect other, double epsilon) -> bool diff --git a/src/Graphics/src/Graphics/PublicAPI/netstandard/PublicAPI.Unshipped.txt b/src/Graphics/src/Graphics/PublicAPI/netstandard/PublicAPI.Unshipped.txt index 7dc5c58110bf..491f6f92f766 100644 --- a/src/Graphics/src/Graphics/PublicAPI/netstandard/PublicAPI.Unshipped.txt +++ b/src/Graphics/src/Graphics/PublicAPI/netstandard/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +Microsoft.Maui.Graphics.Rect.EqualsApproximately(Microsoft.Maui.Graphics.Rect other, double epsilon) -> bool diff --git a/src/Graphics/src/Graphics/Rect.cs b/src/Graphics/src/Graphics/Rect.cs index a00bbeef185e..a35e321d9a00 100644 --- a/src/Graphics/src/Graphics/Rect.cs +++ b/src/Graphics/src/Graphics/Rect.cs @@ -76,6 +76,22 @@ public bool Equals(Rect other) return X.Equals(other.X) && Y.Equals(other.Y) && Width.Equals(other.Width) && Height.Equals(other.Height); } + /// + /// Determines whether each component of this rectangle is within + /// of the corresponding component of . Use this to compare rectangles + /// produced by floating-point arithmetic where bit-exact equality would treat ULP-level + /// differences as material changes. + /// + /// The rectangle to compare against. + /// The maximum absolute difference, per component, that is treated as equal. + public bool EqualsApproximately(Rect other, double epsilon) + { + return Math.Abs(X - other.X) <= epsilon + && Math.Abs(Y - other.Y) <= epsilon + && Math.Abs(Width - other.Width) <= epsilon + && Math.Abs(Height - other.Height) <= epsilon; + } + public override bool Equals(object obj) { if (obj is null) diff --git a/src/Graphics/tests/Graphics.Tests/RectTests.cs b/src/Graphics/tests/Graphics.Tests/RectTests.cs new file mode 100644 index 000000000000..159075441d71 --- /dev/null +++ b/src/Graphics/tests/Graphics.Tests/RectTests.cs @@ -0,0 +1,47 @@ +using Xunit; + +namespace Microsoft.Maui.Graphics.Tests +{ + public class RectTests + { + [Fact] + public void EqualsApproximatelyReturnsTrueForIdenticalRects() + { + var rect = new Rect(10, 20, 30, 40); + Assert.True(rect.EqualsApproximately(new Rect(10, 20, 30, 40), epsilon: 1e-9)); + } + + [Fact] + public void EqualsApproximatelyAbsorbsUlpDifferences() + { + // Values from the dotnet/maui#35142 trace: border heights captured on consecutive + // iOS layoutSubviews passes that differ by ~22 ULP. + var a = new Rect(0, 0, 390, 556.00000063578295); + var b = new Rect(0, 0, 390, 556.00000063578273); + + Assert.False(a.Equals(b)); // bit-exact equality treats them as different + Assert.True(a.EqualsApproximately(b, epsilon: 1e-9)); + } + + [Fact] + public void EqualsApproximatelyReturnsFalseWhenAnyComponentExceedsEpsilon() + { + var rect = new Rect(0, 0, 100, 100); + const double epsilon = 1e-9; + + Assert.False(rect.EqualsApproximately(new Rect(0 + 2 * epsilon, 0, 100, 100), epsilon)); + Assert.False(rect.EqualsApproximately(new Rect(0, 0 + 2 * epsilon, 100, 100), epsilon)); + Assert.False(rect.EqualsApproximately(new Rect(0, 0, 100 + 2 * epsilon, 100), epsilon)); + Assert.False(rect.EqualsApproximately(new Rect(0, 0, 100, 100 + 2 * epsilon), epsilon)); + } + + [Fact] + public void EqualsApproximatelyTreatsHalfEpsilonDifferenceAsEqual() + { + var rect = new Rect(0, 0, 100, 100); + const double epsilon = 1e-9; + + Assert.True(rect.EqualsApproximately(new Rect(0, 0, 100, 100 + epsilon * 0.5), epsilon)); + } + } +}