|
| 1 | +--- |
| 2 | +title: "Breaking change: ChangeToken.OnChange async overloads rebind existing Task-returning callbacks" |
| 3 | +description: "Learn about the breaking change in .NET 11 where new ChangeToken.OnChange async overloads can change overload binding for existing async callbacks." |
| 4 | +ms.date: 08/03/2026 |
| 5 | +ai-usage: ai-assisted |
| 6 | +--- |
| 7 | + |
| 8 | +# ChangeToken.OnChange async overloads rebind existing Task-returning callbacks |
| 9 | + |
| 10 | +Starting in .NET 11, <xref:Microsoft.Extensions.Primitives.ChangeToken.OnChange*> adds async callback overloads. After you recompile against .NET 11, existing calls that pass an `async` lambda or a `Task`-returning callback can silently bind to a different overload and behave differently at runtime. |
| 11 | + |
| 12 | +## Version introduced |
| 13 | + |
| 14 | +.NET 11 Preview 7 |
| 15 | + |
| 16 | +## Previous behavior |
| 17 | + |
| 18 | +Previously, `ChangeToken.OnChange` only exposed synchronous callback overloads: |
| 19 | + |
| 20 | +```csharp |
| 21 | +public static IDisposable OnChange(Func<IChangeToken?> changeTokenProducer, Action changeTokenConsumer); |
| 22 | +public static IDisposable OnChange<TState>(Func<IChangeToken?> changeTokenProducer, Action<TState> changeTokenConsumer, TState state); |
| 23 | +``` |
| 24 | + |
| 25 | +If you passed an `async` lambda, the compiler bound the call to an `Action` overload and compiled the lambda as `async void`. The callback behaved as fire-and-forget. `ChangeToken.OnChange` re-registered for the next change as soon as the callback yielded at its first incomplete `await`, and exceptions thrown later surfaced on the synchronization context or thread pool. |
| 26 | + |
| 27 | +```csharp |
| 28 | +// Bound to OnChange(Func<IChangeToken?>, Action) and compiled as 'async void'. |
| 29 | +ChangeToken.OnChange(config.GetReloadToken, async () => |
| 30 | +{ |
| 31 | + await Task.Delay(1000); |
| 32 | + Console.WriteLine("Reloaded"); |
| 33 | +}); |
| 34 | +``` |
| 35 | + |
| 36 | +## New behavior |
| 37 | + |
| 38 | +Starting in .NET 11, `ChangeToken.OnChange` includes two asynchronous callback overloads: |
| 39 | + |
| 40 | +```csharp |
| 41 | +public static IDisposable OnChange(Func<IChangeToken?> changeTokenProducer, Func<Task> changeTokenConsumer); |
| 42 | +public static IDisposable OnChange<TState>(Func<IChangeToken?> changeTokenProducer, Func<TState, Task> changeTokenConsumer, TState state); |
| 43 | +``` |
| 44 | + |
| 45 | +If you pass an `async` lambda or another `Task`-returning callback, the compiler now binds to `Func<Task>` or `Func<TState, Task>`. The callback compiles as `async Task` instead of `async void`, and `ChangeToken.OnChange` re-registers only after the returned task completes. If multiple changes occur while the callback task runs, `ChangeToken.OnChange` coalesces those changes into one later callback invocation. |
| 46 | + |
| 47 | +```csharp |
| 48 | +// Binds to OnChange(Func<IChangeToken?>, Func<Task>) and compiles as 'async Task'. |
| 49 | +// The token re-registers after the returned task completes. |
| 50 | +ChangeToken.OnChange(config.GetReloadToken, async () => |
| 51 | +{ |
| 52 | + await Task.Delay(1000); |
| 53 | + Console.WriteLine("Reloaded"); |
| 54 | +}); |
| 55 | +``` |
| 56 | + |
| 57 | +This overload rebinding is silent. The same source code still compiles, and the compiler reports no ambiguity. |
| 58 | + |
| 59 | +## Type of breaking change |
| 60 | + |
| 61 | +This change is a [behavioral change](../../categories.md#behavioral-change). |
| 62 | + |
| 63 | +## Reason for change |
| 64 | + |
| 65 | +To support correct asynchronous workflows, `ChangeToken.OnChange` now provides callback overloads that return `Task`. Before .NET 11, asynchronous callback logic required `async void` or synchronous blocking, which made error handling and callback timing harder to control. For API approval details, see [dotnet/runtime#69099](https://github.com/dotnet/runtime/issues/69099). |
| 66 | + |
| 67 | +## Recommended action |
| 68 | + |
| 69 | +For most code, no action is required. The new binding usually improves behavior because asynchronous work now completes before `ChangeToken.OnChange` re-registers for the next notification. |
| 70 | + |
| 71 | +If you need the previous fire-and-forget `async void` behavior, cast the callback to `Action` (or `Action<TState>`) so your call continues to bind to the synchronous overload: |
| 72 | + |
| 73 | +```csharp |
| 74 | +ChangeToken.OnChange(config.GetReloadToken, (Action)(async () => |
| 75 | +{ |
| 76 | + await Task.Delay(1000); |
| 77 | + Console.WriteLine("Reloaded"); |
| 78 | +})); |
| 79 | +``` |
| 80 | + |
| 81 | +You can't control this behavior with an AppContext switch or configuration setting. Overload selection happens at compile time. |
| 82 | + |
| 83 | +## Affected APIs |
| 84 | + |
| 85 | +- `OnChange(Func<IChangeToken?> changeTokenProducer, Func<Task> changeTokenConsumer)` <!-- <xref:Microsoft.Extensions.Primitives.ChangeToken.OnChange(System.Func{Microsoft.Extensions.Primitives.IChangeToken},System.Func{System.Threading.Tasks.Task})?displayProperty=fullName> --> |
| 86 | +- `OnChange<TState>(Func<IChangeToken?> changeTokenProducer, Func<TState, Task> changeTokenConsumer, TState state)` <!-- <xref:Microsoft.Extensions.Primitives.ChangeToken.OnChange``1(System.Func{Microsoft.Extensions.Primitives.IChangeToken},System.Func{``0,System.Threading.Tasks.Task},``0)?displayProperty=fullName> --> |
| 87 | +- <xref:Microsoft.Extensions.Primitives.ChangeToken.OnChange(System.Func{Microsoft.Extensions.Primitives.IChangeToken},System.Action)?displayProperty=fullName> |
| 88 | +- <xref:Microsoft.Extensions.Primitives.ChangeToken.OnChange``1(System.Func{Microsoft.Extensions.Primitives.IChangeToken},System.Action{``0},``0)?displayProperty=fullName> |
0 commit comments