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));
+ }
+ }
+}