Skip to content

Commit fbc5f44

Browse files
Fix #31939: CommandParameter TemplateBinding lost during reparenting (#32961)
When a Button inside a ControlTemplate has both Command and CommandParameter with TemplateBinding, the async binding application path can cause Command to be evaluated before CommandParameter resolves, resulting in CanExecute being called with null parameter. ## Solution Add a BindableProperty dependency mechanism via DependsOn() method. When CommandProperty.DependsOn(CommandParameterProperty) is registered, the CommandElement.GetCanExecute() forces the CommandParameter binding to apply before calling CanExecute. This ensures the parameter value is available. ## Changes - BindableProperty: Add Dependencies property and DependsOn() method - BindableObject: Add ForceBindingApply() to force a binding to apply immediately - CommandElement: Force dependency bindings to apply before calling CanExecute - ButtonElement, CheckBox, SearchBar, MenuItem, RefreshView, TextCell: Register Command -> CommandParameter dependency - All ICommandElement implementations: Pass CommandProperty to GetCanExecute() ## Testing Added unit tests that verify: 1. Initial template binding works correctly 2. CommandParameter is preserved after reparenting (the bug scenario)
1 parent 4310846 commit fbc5f44

File tree

16 files changed

+306
-31
lines changed

16 files changed

+306
-31
lines changed

src/Controls/src/Core/BindableObject.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,25 @@ internal bool GetIsBound(BindableProperty targetProperty)
432432
return bpcontext != null && bpcontext.Bindings.Count > 0;
433433
}
434434

435+
/// <summary>
436+
/// Forces the binding for the specified property to apply immediately.
437+
/// This is used when one property depends on another and needs the dependent
438+
/// property's binding to resolve before proceeding.
439+
/// See https://github.com/dotnet/maui/issues/31939
440+
/// </summary>
441+
internal void ForceBindingApply(BindableProperty targetProperty)
442+
{
443+
if (targetProperty == null)
444+
throw new ArgumentNullException(nameof(targetProperty));
445+
446+
BindablePropertyContext bpcontext = GetContext(targetProperty);
447+
if (bpcontext == null || bpcontext.Bindings.Count == 0)
448+
return;
449+
450+
// Force the binding to apply now
451+
ApplyBinding(bpcontext, fromBindingContextChanged: false);
452+
}
453+
435454
internal virtual void OnRemoveDynamicResource(BindableProperty property)
436455
{
437456
}

src/Controls/src/Core/BindableProperty.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,21 @@ public sealed class BindableProperty
252252

253253
internal ValidateValueDelegate ValidateValue { get; private set; }
254254

255+
// Properties that this property depends on - when getting this property's value,
256+
// if the dependency has a pending binding, return the default value instead.
257+
// This is used to fix timing issues where one property binding resolves before another.
258+
// See https://github.com/dotnet/maui/issues/31939
259+
internal BindableProperty[] Dependencies { get; private set; }
260+
261+
/// <summary>
262+
/// Registers a dependency on another BindableProperty. When this property's value is retrieved,
263+
/// if the dependency has a binding that hasn't resolved yet (value is null), return null.
264+
/// </summary>
265+
internal void DependsOn(params BindableProperty[] dependencies)
266+
{
267+
Dependencies = dependencies;
268+
}
269+
255270
/// <summary>Creates a new instance of the BindableProperty class.</summary>
256271
/// <param name="propertyName">The name of the BindableProperty.</param>
257272
/// <param name="returnType">The type of the property.</param>

src/Controls/src/Core/Button/Button.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,7 @@ void ICommandElement.CanExecuteChanged(object sender, EventArgs e) =>
465465
RefreshIsEnabledProperty();
466466

467467
protected override bool IsEnabledCore =>
468-
base.IsEnabledCore && CommandElement.GetCanExecute(this);
468+
base.IsEnabledCore && CommandElement.GetCanExecute(this, CommandProperty);
469469

470470
bool _wasImageLoading;
471471

src/Controls/src/Core/Button/ButtonElement.cs

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,27 @@ static class ButtonElement
1010
/// <summary>
1111
/// The backing store for the <see cref="ICommandElement.Command" /> bindable property.
1212
/// </summary>
13-
public static readonly BindableProperty CommandProperty = BindableProperty.Create(
14-
nameof(IButtonElement.Command), typeof(ICommand), typeof(IButtonElement), null,
15-
propertyChanging: CommandElement.OnCommandChanging, propertyChanged: CommandElement.OnCommandChanged);
13+
public static readonly BindableProperty CommandProperty;
1614

1715
/// <summary>
1816
/// The backing store for the <see cref="ICommandElement.CommandParameter" /> bindable property.
1917
/// </summary>
20-
public static readonly BindableProperty CommandParameterProperty = BindableProperty.Create(
21-
nameof(IButtonElement.CommandParameter), typeof(object), typeof(IButtonElement), null,
22-
propertyChanged: CommandElement.OnCommandParameterChanged);
18+
public static readonly BindableProperty CommandParameterProperty;
19+
20+
static ButtonElement()
21+
{
22+
CommandParameterProperty = BindableProperty.Create(
23+
nameof(IButtonElement.CommandParameter), typeof(object), typeof(IButtonElement), null,
24+
propertyChanged: CommandElement.OnCommandParameterChanged);
25+
26+
CommandProperty = BindableProperty.Create(
27+
nameof(IButtonElement.Command), typeof(ICommand), typeof(IButtonElement), null,
28+
propertyChanging: CommandElement.OnCommandChanging, propertyChanged: CommandElement.OnCommandChanged);
29+
30+
// Register dependency: Command depends on CommandParameter for CanExecute evaluation
31+
// See https://github.com/dotnet/maui/issues/31939
32+
CommandProperty.DependsOn(CommandParameterProperty);
33+
}
2334

2435
/// <summary>
2536
/// The string identifier for the pressed visual state of this control.

src/Controls/src/Core/Cells/TextCell.cs

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,28 @@ namespace Microsoft.Maui.Controls
1111
public class TextCell : Cell, ICommandElement
1212
{
1313
/// <summary>Bindable property for <see cref="Command"/>.</summary>
14-
public static readonly BindableProperty CommandProperty =
15-
BindableProperty.Create(nameof(Command), typeof(ICommand), typeof(TextCell),
16-
propertyChanging: CommandElement.OnCommandChanging,
17-
propertyChanged: CommandElement.OnCommandChanged);
14+
public static readonly BindableProperty CommandProperty;
1815

1916
/// <summary>Bindable property for <see cref="CommandParameter"/>.</summary>
20-
public static readonly BindableProperty CommandParameterProperty =
21-
BindableProperty.Create(nameof(CommandParameter),
17+
public static readonly BindableProperty CommandParameterProperty;
18+
19+
static TextCell()
20+
{
21+
CommandParameterProperty = BindableProperty.Create(nameof(CommandParameter),
2222
typeof(object),
2323
typeof(TextCell),
2424
null,
2525
propertyChanged: CommandElement.OnCommandParameterChanged);
2626

27+
CommandProperty = BindableProperty.Create(nameof(Command), typeof(ICommand), typeof(TextCell),
28+
propertyChanging: CommandElement.OnCommandChanging,
29+
propertyChanged: CommandElement.OnCommandChanged);
30+
31+
// Register dependency: Command depends on CommandParameter for CanExecute evaluation
32+
// See https://github.com/dotnet/maui/issues/31939
33+
CommandProperty.DependsOn(CommandParameterProperty);
34+
}
35+
2736
/// <summary>Bindable property for <see cref="Text"/>.</summary>
2837
public static readonly BindableProperty TextProperty = BindableProperty.Create(nameof(Text), typeof(string), typeof(TextCell), default(string));
2938

@@ -95,10 +104,7 @@ protected internal override void OnTapped()
95104

96105
void ICommandElement.CanExecuteChanged(object sender, EventArgs eventArgs)
97106
{
98-
if (Command is null)
99-
return;
100-
101-
IsEnabled = Command.CanExecute(CommandParameter);
107+
IsEnabled = CommandElement.GetCanExecute(this, CommandProperty);
102108
}
103109

104110
WeakCommandSubscription ICommandElement.CleanupTracker { get; set; }

src/Controls/src/Core/CheckBox/CheckBox.Mapper.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ namespace Microsoft.Maui.Controls
99
{
1010
public partial class CheckBox
1111
{
12-
static CheckBox() => RemapForControls();
12+
static CheckBox()
13+
{
14+
// Register dependency: Command depends on CommandParameter for CanExecute evaluation
15+
// See https://github.com/dotnet/maui/issues/31939
16+
CommandProperty.DependsOn(CommandParameterProperty);
17+
RemapForControls();
18+
}
1319

1420
private new static void RemapForControls()
1521
{

src/Controls/src/Core/CheckBox/CheckBox.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ void ICommandElement.CanExecuteChanged(object sender, EventArgs e) =>
145145
RefreshIsEnabledProperty();
146146

147147
protected override bool IsEnabledCore =>
148-
base.IsEnabledCore && CommandElement.GetCanExecute(this);
148+
base.IsEnabledCore && CommandElement.GetCanExecute(this, CommandProperty);
149149
public Paint Foreground => Color?.AsPaint();
150150

151151
bool ICheckBox.IsChecked

src/Controls/src/Core/CommandElement.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,23 @@ public static void OnCommandParameterChanged(BindableObject bo, object o, object
3737
commandElement.CanExecuteChanged(bo, EventArgs.Empty);
3838
}
3939

40-
public static bool GetCanExecute(ICommandElement commandElement)
40+
public static bool GetCanExecute(ICommandElement commandElement, BindableProperty? commandProperty = null)
4141
{
4242
if (commandElement.Command == null)
4343
return true;
4444

45+
// If there are dependencies (e.g., CommandParameter for Command), force their bindings
46+
// to apply before evaluating CanExecute. This fixes timing issues where Command binding
47+
// resolves before CommandParameter binding during reparenting.
48+
// See https://github.com/dotnet/maui/issues/31939
49+
if (commandProperty?.Dependencies is not null && commandElement is BindableObject bo)
50+
{
51+
foreach (var dependency in commandProperty.Dependencies)
52+
{
53+
bo.ForceBindingApply(dependency);
54+
}
55+
}
56+
4557
return commandElement.Command.CanExecute(commandElement.CommandParameter);
4658
}
4759
}

src/Controls/src/Core/ImageButton/ImageButton.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ bool IImageElement.IsAnimationPlaying
244244
bool IImageController.GetLoadAsAnimation() => false;
245245

246246
protected override bool IsEnabledCore =>
247-
base.IsEnabledCore && CommandElement.GetCanExecute(this);
247+
base.IsEnabledCore && CommandElement.GetCanExecute(this, CommandProperty);
248248

249249
void ICommandElement.CanExecuteChanged(object sender, EventArgs e) =>
250250
RefreshIsEnabledProperty();

src/Controls/src/Core/Menu/MenuItem.cs

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,26 @@ namespace Microsoft.Maui.Controls
1212
public partial class MenuItem : BaseMenuItem, IMenuItemController, ICommandElement, IMenuElement, IPropertyPropagationController
1313
{
1414
/// <summary>Bindable property for <see cref="Command"/>.</summary>
15-
public static readonly BindableProperty CommandProperty = BindableProperty.Create(
16-
nameof(Command), typeof(ICommand), typeof(MenuItem), null,
17-
propertyChanging: CommandElement.OnCommandChanging,
18-
propertyChanged: CommandElement.OnCommandChanged);
15+
public static readonly BindableProperty CommandProperty;
1916

2017
/// <summary>Bindable property for <see cref="CommandParameter"/>.</summary>
21-
public static readonly BindableProperty CommandParameterProperty = BindableProperty.Create(
22-
nameof(CommandParameter), typeof(object), typeof(MenuItem), null,
23-
propertyChanged: CommandElement.OnCommandParameterChanged);
18+
public static readonly BindableProperty CommandParameterProperty;
19+
20+
static MenuItem()
21+
{
22+
CommandParameterProperty = BindableProperty.Create(
23+
nameof(CommandParameter), typeof(object), typeof(MenuItem), null,
24+
propertyChanged: CommandElement.OnCommandParameterChanged);
25+
26+
CommandProperty = BindableProperty.Create(
27+
nameof(Command), typeof(ICommand), typeof(MenuItem), null,
28+
propertyChanging: CommandElement.OnCommandChanging,
29+
propertyChanged: CommandElement.OnCommandChanged);
30+
31+
// Register dependency: Command depends on CommandParameter for CanExecute evaluation
32+
// See https://github.com/dotnet/maui/issues/31939
33+
CommandProperty.DependsOn(CommandParameterProperty);
34+
}
2435

2536
/// <summary>Bindable property for <see cref="IsDestructive"/>.</summary>
2637
public static readonly BindableProperty IsDestructiveProperty = BindableProperty.Create(nameof(IsDestructive), typeof(bool), typeof(MenuItem), false);
@@ -122,7 +133,7 @@ static object CoerceIsEnabledProperty(BindableObject bindable, object value)
122133
return false;
123134
}
124135

125-
var canExecute = CommandElement.GetCanExecute(menuItem);
136+
var canExecute = CommandElement.GetCanExecute(menuItem, CommandProperty);
126137
if (!canExecute)
127138
{
128139
return false;

0 commit comments

Comments
 (0)