Skip to content

Commit 90cf344

Browse files
authored
Merge pull request #127 from CommunityToolkit/dev/default-async-command-concurrency-false
Switch async commands to default to no concurrent execution
2 parents c71eb35 + 0e26a29 commit 90cf344

File tree

7 files changed

+133
-29
lines changed

7 files changed

+133
-29
lines changed

CommunityToolkit.Mvvm.SourceGenerators/Input/ICommandGenerator.Execute.cs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,10 @@ public static ImmutableArray<MemberDeclarationSyntax> GetSyntax(CommandInfo comm
183183
commandCreationArguments.Add(Argument(canExecuteExpression));
184184
}
185185

186-
// Disable concurrent executions, if requested
187-
if (!commandInfo.AllowConcurrentExecutions)
186+
// Enable concurrent executions, if requested
187+
if (commandInfo.AllowConcurrentExecutions)
188188
{
189-
commandCreationArguments.Add(Argument(LiteralExpression(SyntaxKind.FalseLiteralExpression)));
189+
commandCreationArguments.Add(Argument(LiteralExpression(SyntaxKind.TrueLiteralExpression)));
190190
}
191191

192192
// Construct the generated property as follows (the explicit delegate cast is needed to avoid overload resolution conflicts):
@@ -366,7 +366,7 @@ private static bool TryMapCommandTypesFromMethod(
366366
/// <param name="attributeData">The <see cref="AttributeData"/> instance the method was annotated with.</param>
367367
/// <param name="commandClassType">The command class type name.</param>
368368
/// <param name="diagnostics">The current collection of gathered diagnostics.</param>
369-
/// <param name="allowConcurrentExecutions">Whether or not concurrent executions have been disabled.</param>
369+
/// <param name="allowConcurrentExecutions">Whether or not concurrent executions have been enabled.</param>
370370
/// <returns>Whether or not a value for <paramref name="allowConcurrentExecutions"/> could be retrieved successfully.</returns>
371371
private static bool TryGetAllowConcurrentExecutionsSwitch(
372372
IMethodSymbol methodSymbol,
@@ -375,11 +375,10 @@ private static bool TryGetAllowConcurrentExecutionsSwitch(
375375
ImmutableArray<Diagnostic>.Builder diagnostics,
376376
out bool allowConcurrentExecutions)
377377
{
378-
// Try to get the custom switch for concurrent executions. If the switch is not present, the
379-
// default value is set to true, to avoid breaking backwards compatibility with the first release.
378+
// Try to get the custom switch for concurrent executions (the default is false)
380379
if (!attributeData.TryGetNamedArgument("AllowConcurrentExecutions", out allowConcurrentExecutions))
381380
{
382-
allowConcurrentExecutions = true;
381+
allowConcurrentExecutions = false;
383382

384383
return true;
385384
}

CommunityToolkit.Mvvm/Input/AsyncRelayCommand.cs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ public sealed class AsyncRelayCommand : IAsyncRelayCommand
7272
public event EventHandler? CanExecuteChanged;
7373

7474
/// <summary>
75-
/// Initializes a new instance of the <see cref="AsyncRelayCommand"/> class that can always execute.
75+
/// Initializes a new instance of the <see cref="AsyncRelayCommand"/> class.
7676
/// </summary>
7777
/// <param name="execute">The execution logic.</param>
7878
/// <exception cref="System.ArgumentNullException">Thrown if <paramref name="execute"/> is <see langword="null"/>.</exception>
@@ -81,11 +81,10 @@ public AsyncRelayCommand(Func<Task> execute)
8181
ArgumentNullException.ThrowIfNull(execute);
8282

8383
this.execute = execute;
84-
this.allowConcurrentExecutions = true;
8584
}
8685

8786
/// <summary>
88-
/// Initializes a new instance of the <see cref="AsyncRelayCommand"/> class that can always execute.
87+
/// Initializes a new instance of the <see cref="AsyncRelayCommand"/> class.
8988
/// </summary>
9089
/// <param name="execute">The execution logic.</param>
9190
/// <param name="allowConcurrentExecutions">Whether or not to allow concurrent executions of the command.</param>
@@ -99,7 +98,7 @@ public AsyncRelayCommand(Func<Task> execute, bool allowConcurrentExecutions)
9998
}
10099

101100
/// <summary>
102-
/// Initializes a new instance of the <see cref="AsyncRelayCommand"/> class that can always execute.
101+
/// Initializes a new instance of the <see cref="AsyncRelayCommand"/> class.
103102
/// </summary>
104103
/// <param name="cancelableExecute">The cancelable execution logic.</param>
105104
/// <exception cref="System.ArgumentNullException">Thrown if <paramref name="cancelableExecute"/> is <see langword="null"/>.</exception>
@@ -108,11 +107,10 @@ public AsyncRelayCommand(Func<CancellationToken, Task> cancelableExecute)
108107
ArgumentNullException.ThrowIfNull(cancelableExecute);
109108

110109
this.cancelableExecute = cancelableExecute;
111-
this.allowConcurrentExecutions = true;
112110
}
113111

114112
/// <summary>
115-
/// Initializes a new instance of the <see cref="AsyncRelayCommand"/> class that can always execute.
113+
/// Initializes a new instance of the <see cref="AsyncRelayCommand"/> class.
116114
/// </summary>
117115
/// <param name="cancelableExecute">The cancelable execution logic.</param>
118116
/// <param name="allowConcurrentExecutions">Whether or not to allow concurrent executions of the command.</param>
@@ -138,7 +136,6 @@ public AsyncRelayCommand(Func<Task> execute, Func<bool> canExecute)
138136

139137
this.execute = execute;
140138
this.canExecute = canExecute;
141-
this.allowConcurrentExecutions = true;
142139
}
143140

144141
/// <summary>
@@ -171,7 +168,6 @@ public AsyncRelayCommand(Func<CancellationToken, Task> cancelableExecute, Func<b
171168

172169
this.cancelableExecute = cancelableExecute;
173170
this.canExecute = canExecute;
174-
this.allowConcurrentExecutions = true;
175171
}
176172

177173
/// <summary>

CommunityToolkit.Mvvm/Input/AsyncRelayCommand{T}.cs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ public sealed class AsyncRelayCommand<T> : IAsyncRelayCommand<T>
4848
public event EventHandler? CanExecuteChanged;
4949

5050
/// <summary>
51-
/// Initializes a new instance of the <see cref="AsyncRelayCommand{T}"/> class that can always execute.
51+
/// Initializes a new instance of the <see cref="AsyncRelayCommand{T}"/> class.
5252
/// </summary>
5353
/// <param name="execute">The execution logic.</param>
5454
/// <remarks>See notes in <see cref="RelayCommand{T}(Action{T})"/>.</remarks>
@@ -58,11 +58,10 @@ public AsyncRelayCommand(Func<T?, Task> execute)
5858
ArgumentNullException.ThrowIfNull(execute);
5959

6060
this.execute = execute;
61-
this.allowConcurrentExecutions = true;
6261
}
6362

6463
/// <summary>
65-
/// Initializes a new instance of the <see cref="AsyncRelayCommand{T}"/> class that can always execute.
64+
/// Initializes a new instance of the <see cref="AsyncRelayCommand{T}"/> class.
6665
/// </summary>
6766
/// <param name="execute">The execution logic.</param>
6867
/// <param name="allowConcurrentExecutions">Whether or not to allow concurrent executions of the command.</param>
@@ -77,7 +76,7 @@ public AsyncRelayCommand(Func<T?, Task> execute, bool allowConcurrentExecutions)
7776
}
7877

7978
/// <summary>
80-
/// Initializes a new instance of the <see cref="AsyncRelayCommand{T}"/> class that can always execute.
79+
/// Initializes a new instance of the <see cref="AsyncRelayCommand{T}"/> class.
8180
/// </summary>
8281
/// <param name="cancelableExecute">The cancelable execution logic.</param>
8382
/// <remarks>See notes in <see cref="RelayCommand{T}(Action{T})"/>.</remarks>
@@ -87,11 +86,10 @@ public AsyncRelayCommand(Func<T?, CancellationToken, Task> cancelableExecute)
8786
ArgumentNullException.ThrowIfNull(cancelableExecute);
8887

8988
this.cancelableExecute = cancelableExecute;
90-
this.allowConcurrentExecutions = true;
9189
}
9290

9391
/// <summary>
94-
/// Initializes a new instance of the <see cref="AsyncRelayCommand{T}"/> class that can always execute.
92+
/// Initializes a new instance of the <see cref="AsyncRelayCommand{T}"/> class.
9593
/// </summary>
9694
/// <param name="cancelableExecute">The cancelable execution logic.</param>
9795
/// <param name="allowConcurrentExecutions">Whether or not to allow concurrent executions of the command.</param>
@@ -119,7 +117,6 @@ public AsyncRelayCommand(Func<T?, Task> execute, Predicate<T?> canExecute)
119117

120118
this.execute = execute;
121119
this.canExecute = canExecute;
122-
this.allowConcurrentExecutions = true;
123120
}
124121

125122
/// <summary>
@@ -154,7 +151,6 @@ public AsyncRelayCommand(Func<T?, CancellationToken, Task> cancelableExecute, Pr
154151

155152
this.cancelableExecute = cancelableExecute;
156153
this.canExecute = canExecute;
157-
this.allowConcurrentExecutions = true;
158154
}
159155

160156
/// <summary>

CommunityToolkit.Mvvm/Input/Attributes/ICommandAttribute.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,5 +78,5 @@ public sealed class ICommandAttribute : Attribute
7878
/// when an execution is invoked while a previous one is still running. It is the same as creating an instance of
7979
/// these command types with a constructor such as <see cref="AsyncRelayCommand(Func{System.Threading.Tasks.Task}, bool)"/>.
8080
/// </summary>
81-
public bool AllowConcurrentExecutions { get; init; } = true;
81+
public bool AllowConcurrentExecutions { get; init; }
8282
}

tests/CommunityToolkit.Mvvm.UnitTests/Test_AsyncRelayCommand.cs

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,11 +177,84 @@ public async Task Test_AsyncRelayCommand_WithCancellation()
177177
}
178178

179179
[TestMethod]
180-
public async Task Test_AsyncRelayCommand_WithConcurrencyControl()
180+
public async Task Test_AsyncRelayCommand_AllowConcurrentExecutions_Enable()
181+
{
182+
int index = 0;
183+
TaskCompletionSource<object?>[] cancellationTokenSources = new TaskCompletionSource<object?>[]
184+
{
185+
new TaskCompletionSource<object?>(),
186+
new TaskCompletionSource<object?>()
187+
};
188+
189+
AsyncRelayCommand? command = new(() => cancellationTokenSources[index++].Task, allowConcurrentExecutions: true);
190+
191+
Assert.IsTrue(command.CanExecute(null));
192+
Assert.IsTrue(command.CanExecute(new object()));
193+
194+
Assert.IsFalse(command.CanBeCanceled);
195+
Assert.IsFalse(command.IsCancellationRequested);
196+
197+
(object?, EventArgs?) args = default;
198+
199+
command.CanExecuteChanged += (s, e) => args = (s, e);
200+
201+
command.NotifyCanExecuteChanged();
202+
203+
Assert.AreSame(args.Item1, command);
204+
Assert.AreSame(args.Item2, EventArgs.Empty);
205+
206+
Assert.IsNull(command.ExecutionTask);
207+
Assert.IsFalse(command.IsRunning);
208+
209+
Task task = command.ExecuteAsync(null);
210+
211+
Assert.IsNotNull(command.ExecutionTask);
212+
Assert.AreSame(command.ExecutionTask, task);
213+
Assert.AreSame(command.ExecutionTask, cancellationTokenSources[0].Task);
214+
Assert.IsTrue(command.IsRunning);
215+
216+
// The command can still be executed now
217+
Assert.IsTrue(command.CanExecute(null));
218+
Assert.IsTrue(command.CanExecute(new object()));
219+
220+
Assert.IsFalse(command.CanBeCanceled);
221+
Assert.IsFalse(command.IsCancellationRequested);
222+
223+
Task newTask = command.ExecuteAsync(null);
224+
225+
// A new task was returned
226+
Assert.AreSame(command.ExecutionTask, newTask);
227+
Assert.AreSame(command.ExecutionTask, cancellationTokenSources[1].Task);
228+
229+
cancellationTokenSources[0].SetResult(null);
230+
cancellationTokenSources[1].SetResult(null);
231+
232+
_ = await Task.WhenAll(cancellationTokenSources[0].Task, cancellationTokenSources[1].Task);
233+
234+
Assert.IsFalse(command.IsRunning);
235+
}
236+
237+
[TestMethod]
238+
public async Task Test_AsyncRelayCommand_AllowConcurrentExecutions_Disabled()
239+
{
240+
await Test_AsyncRelayCommand_AllowConcurrentExecutions_TestLogic(static task => new(async () => await task, allowConcurrentExecutions: false));
241+
}
242+
243+
[TestMethod]
244+
public async Task Test_AsyncRelayCommand_AllowConcurrentExecutions_Default()
245+
{
246+
await Test_AsyncRelayCommand_AllowConcurrentExecutions_TestLogic(static task => new(async () => await task));
247+
}
248+
249+
/// <summary>
250+
/// Shared logic for <see cref="Test_AsyncRelayCommand_AllowConcurrentExecutions_Disabled"/> and <see cref="Test_AsyncRelayCommand_AllowConcurrentExecutions_Default"/>.
251+
/// </summary>
252+
/// <param name="factory">A factory to create the <see cref="AsyncRelayCommand"/> instance to test.</param>
253+
private static async Task Test_AsyncRelayCommand_AllowConcurrentExecutions_TestLogic(Func<Task, AsyncRelayCommand> factory)
181254
{
182255
TaskCompletionSource<object?> tcs = new();
183256

184-
AsyncRelayCommand? command = new(async () => await tcs.Task, allowConcurrentExecutions: false);
257+
AsyncRelayCommand? command = factory(tcs.Task);
185258

186259
Assert.IsTrue(command.CanExecute(null));
187260
Assert.IsTrue(command.CanExecute(new object()));

tests/CommunityToolkit.Mvvm.UnitTests/Test_AsyncRelayCommand{T}.cs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,11 +134,26 @@ public void Test_AsyncRelayCommandOfT_NullWithValueType()
134134
}
135135

136136
[TestMethod]
137-
public async Task Test_AsyncRelayCommandOfT_WithConcurrencyControl()
137+
public async Task Test_AsyncRelayCommandOfT_AllowConcurrentExecutions_Disabled()
138+
{
139+
await Test_AsyncRelayCommandOfT_AllowConcurrentExecutions_TestLogic(static task => new(async _ => await task, allowConcurrentExecutions: false));
140+
}
141+
142+
[TestMethod]
143+
public async Task Test_AsyncRelayCommandOfT_AllowConcurrentExecutions_Default()
144+
{
145+
await Test_AsyncRelayCommandOfT_AllowConcurrentExecutions_TestLogic(static task => new(async _ => await task));
146+
}
147+
148+
/// <summary>
149+
/// Shared logic for <see cref="Test_AsyncRelayCommandOfT_AllowConcurrentExecutions_Disabled"/> and <see cref="Test_AsyncRelayCommandOfT_AllowConcurrentExecutions_Default"/>.
150+
/// </summary>
151+
/// <param name="factory">A factory to create the <see cref="AsyncRelayCommand{T}"/> instance to test.</param>
152+
private static async Task Test_AsyncRelayCommandOfT_AllowConcurrentExecutions_TestLogic(Func<Task, AsyncRelayCommand<string>> factory)
138153
{
139154
TaskCompletionSource<object?> tcs = new();
140155

141-
AsyncRelayCommand<string> command = new(async s => await tcs.Task, allowConcurrentExecutions: false);
156+
AsyncRelayCommand<string> command = factory(tcs.Task);
142157

143158
Assert.IsTrue(command.CanExecute(null));
144159
Assert.IsTrue(command.CanExecute("1"));

tests/CommunityToolkit.Mvvm.UnitTests/Test_ICommandAttribute.cs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,23 @@ public async Task Test_ICommandAttribute_RelayCommand()
5757
CollectionAssert.AreEqual(model.Values, Enumerable.Range(0, 10).ToArray());
5858

5959
await Task.WhenAll(tasks);
60+
61+
tasks.Clear();
62+
63+
for (int i = 10; i < 20; i++)
64+
{
65+
tasks.Add(model.AddValueToListAndDelayWithDefaultConcurrencyCommand.ExecuteAsync(i));
66+
}
67+
68+
Assert.AreEqual(10, tasks.Count);
69+
70+
// Only the first item should have been added
71+
CollectionAssert.AreEqual(model.Values, Enumerable.Range(0, 11).ToArray());
72+
73+
for (int i = 1; i < tasks.Count; i++)
74+
{
75+
Assert.AreSame(Task.CompletedTask, tasks[i]);
76+
}
6077
}
6178

6279
[TestMethod]
@@ -366,14 +383,22 @@ private async Task DelayAndIncrementCounterAsync()
366383
Counter += 1;
367384
}
368385

369-
[ICommand]
386+
[ICommand(AllowConcurrentExecutions = true)]
370387
private async Task AddValueToListAndDelayAsync(int value)
371388
{
372389
Values.Add(value);
373390

374391
await Task.Delay(100);
375392
}
376393

394+
[ICommand]
395+
private async Task AddValueToListAndDelayWithDefaultConcurrencyAsync(int value)
396+
{
397+
Values.Add(value);
398+
399+
await Task.Delay(1000);
400+
}
401+
377402
#region Test region
378403

379404
/// <summary>

0 commit comments

Comments
 (0)