From 0ab8b2fe58aebc670d6464b3b58568ddd26a5f62 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen Date: Sat, 25 Jul 2026 05:20:25 +1000 Subject: [PATCH] fix(grid): allow shared size groups to shrink (#21837) * test(grid): reproduce shared size group shrink failure Shared auto columns expand when grouped content becomes visible but keep the expanded width after that content is hidden. Cover the full zero, expanded, and shrunk layout sequence so the stale shared minimum cannot regress unnoticed. * fix(grid): allow shared size groups to shrink Shared auto definitions reused the previous group minimum while recomputing the next group minimum, so an expanded width became self-sustaining after content shrank. Aggregate each definition's intrinsic measured minimum instead, preserving the shared-size invariant and matching the canonical WPF algorithm. * test(grid): reproduce shared size group stuck on cyclic measure path A grid mixing an auto-column/star-row cell with a star-column/auto-row cell measures through Grid's cyclic dependency path, which saves and restores definition min sizes around the repeated measure. The saved value is the effective min size, so the group minimum is folded into the definition's own contribution and the group can never shrink below it. * fix(grid): restore raw min sizes on the cyclic measure path Grid.CacheMinSizes read DefinitionBase.MinSize, which already includes the shared group's minimum, but ApplyCachedMinSizes writes it back through SetMinSize, which assigns the definition's own _minSize. A short-pole definition therefore adopted the group minimum as its own intrinsic minimum, which reclassified it as a long pole on the next validation. Long poles with a valid measure are never remeasured, so the fabricated minimum became unreachable and pinned the group open. Add DefinitionBase.RawMinSize, the read counterpart of SetMinSize, and cache that instead so the save/restore pair round-trips the definition's own contribution. Matches the WPF fix in dotnet/wpf#2270. --- src/Avalonia.Controls/DefinitionBase.cs | 13 ++- src/Avalonia.Controls/Grid.cs | 4 +- .../Avalonia.Controls.UnitTests/GridTests.cs | 97 +++++++++++++++++++ 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/Avalonia.Controls/DefinitionBase.cs b/src/Avalonia.Controls/DefinitionBase.cs index 90211d8328..020f6996aa 100644 --- a/src/Avalonia.Controls/DefinitionBase.cs +++ b/src/Avalonia.Controls/DefinitionBase.cs @@ -269,6 +269,17 @@ namespace Avalonia.Controls } } + /// + /// Returns min size, never taking into account shared state. + /// + /// + /// This is the definition's own intrinsic contribution to its group. It is the counterpart + /// of : code that saves and restores a min size across a measure + /// must round-trip this value, because feeding the group minimum back into + /// would make the group unable to shrink. + /// + internal double RawMinSize => _minSize; + /// /// Offset. /// @@ -626,7 +637,7 @@ namespace Avalonia.Controls // accumulate min size of all participating definitions for (int i = 0, count = _registry.Count; i < count; ++i) { - sharedMinSize = Math.Max(sharedMinSize, _registry[i].MinSize); + sharedMinSize = Math.Max(sharedMinSize, _registry[i]._minSize); } bool sharedMinSizeChanged = !MathUtilities.AreClose(_minSize, sharedMinSize); diff --git a/src/Avalonia.Controls/Grid.cs b/src/Avalonia.Controls/Grid.cs index 3eb4eae98d..992cc38ecc 100644 --- a/src/Avalonia.Controls/Grid.cs +++ b/src/Avalonia.Controls/Grid.cs @@ -946,11 +946,11 @@ namespace Avalonia.Controls { if (isRows) { - minSizes[PrivateCells[i].RowIndex] = DefinitionsV[PrivateCells[i].RowIndex].MinSize; + minSizes[PrivateCells[i].RowIndex] = DefinitionsV[PrivateCells[i].RowIndex].RawMinSize; } else { - minSizes[PrivateCells[i].ColumnIndex] = DefinitionsU[PrivateCells[i].ColumnIndex].MinSize; + minSizes[PrivateCells[i].ColumnIndex] = DefinitionsU[PrivateCells[i].ColumnIndex].RawMinSize; } i = PrivateCells[i].Next; diff --git a/tests/Avalonia.Controls.UnitTests/GridTests.cs b/tests/Avalonia.Controls.UnitTests/GridTests.cs index a15b8d8c77..435bb79cf3 100644 --- a/tests/Avalonia.Controls.UnitTests/GridTests.cs +++ b/tests/Avalonia.Controls.UnitTests/GridTests.cs @@ -1212,6 +1212,103 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(10, grids[1].ColumnDefinitions[0].ActualWidth); } + [Fact] + public void Shared_Size_Group_Shrinks_When_Content_Is_Hidden() + { + var grids = new[] + { + CreateGrid(("A", GridLength.Auto)), + CreateGrid(("A", GridLength.Auto)), + }; + var content = new Border + { + Width = 50, + IsVisible = false, + }; + grids[1].Children.Add(content); + + var scope = new StackPanel + { + [Grid.IsSharedSizeScopeProperty] = true, + Children = + { + grids[0], + grids[1], + }, + }; + var root = new TestRoot(scope); + void ExecuteSharedSizeLayoutPass() + { + // Shared groups validate after layout and apply any resulting invalidation on the next pass. + root.LayoutManager.ExecuteLayoutPass(); + root.LayoutManager.ExecuteLayoutPass(); + } + + root.ExecuteInitialLayoutPass(); + Assert.All(grids, grid => Assert.Equal(0, grid.ColumnDefinitions[0].ActualWidth)); + + content.IsVisible = true; + ExecuteSharedSizeLayoutPass(); + Assert.All(grids, grid => Assert.Equal(50, grid.ColumnDefinitions[0].ActualWidth)); + + content.IsVisible = false; + ExecuteSharedSizeLayoutPass(); + Assert.All(grids, grid => Assert.Equal(0, grid.ColumnDefinitions[0].ActualWidth)); + } + + [Fact] + public void Shared_Size_Group_Shrinks_When_Participant_Uses_Cyclic_Measure_Path() + { + // A grid mixing an auto-column/star-row cell with a star-column/auto-row cell cannot + // resolve stars in either direction up front, so Grid measures it through its cyclic + // dependency path. That path saves definition min sizes before the repeated measure and + // restores them afterwards. Saving the effective min size folds the group minimum into + // the definition's own contribution, which then reclassifies it as a long pole - and + // long poles are deliberately never remeasured, so the group stays pinned open. + var cyclicGrid = CreateGrid(("A", GridLength.Auto), (null, new GridLength(1, GridUnitType.Star))); + cyclicGrid.Height = 100; // star rows collapse to auto under an infinite constraint. + cyclicGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + cyclicGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + + var autoColumnStarRowChild = new Border { Width = 10, Height = 10 }; + Grid.SetColumn(autoColumnStarRowChild, 0); + Grid.SetRow(autoColumnStarRowChild, 1); + + var starColumnAutoRowChild = new Border { Width = 10, Height = 10 }; + Grid.SetColumn(starColumnAutoRowChild, 1); + Grid.SetRow(starColumnAutoRowChild, 0); + + cyclicGrid.Children.Add(autoColumnStarRowChild); + cyclicGrid.Children.Add(starColumnAutoRowChild); + + var plainGrid = CreateGrid(("A", GridLength.Auto), (null, new GridLength(1, GridUnitType.Star))); + var wideContent = new Border { Width = 50, Height = 10 }; + plainGrid.Children.Add(wideContent); + + var scope = new StackPanel + { + [Grid.IsSharedSizeScopeProperty] = true, + Children = { cyclicGrid, plainGrid }, + }; + var root = new TestRoot(scope); + void ExecuteSharedSizeLayoutPass() + { + // Shared groups validate after layout and apply any resulting invalidation on the next pass. + root.LayoutManager.ExecuteLayoutPass(); + root.LayoutManager.ExecuteLayoutPass(); + } + + root.ExecuteInitialLayoutPass(); + ExecuteSharedSizeLayoutPass(); + Assert.Equal(50, cyclicGrid.ColumnDefinitions[0].ActualWidth); + Assert.Equal(50, plainGrid.ColumnDefinitions[0].ActualWidth); + + wideContent.IsVisible = false; + ExecuteSharedSizeLayoutPass(); + Assert.Equal(10, cyclicGrid.ColumnDefinitions[0].ActualWidth); + Assert.Equal(10, plainGrid.ColumnDefinitions[0].ActualWidth); + } + [Fact] public void Collection_Changes_Are_Tracked() {