AbortController
A cancellation token the browser understands. Create one controller, hand its signal to as many operations as you like, and cancel all of them with a single Abort.
@inject Bit.Butil.AbortController abortControllerMDN reference
Fetch.Start hands back an AbortableFetch that aborts that one
request. A shared ButilAbortSignal is the opposite: one signal, any number of
operations, one button that stops all of them. It is also the only way to give a request a
deadline and a Cancel button at the same time - see Any below.
AbortController itself is supported by every current engine. AbortSignal.timeout and AbortSignal.any are newer - Any still works where the latter is missing, because it falls back to a hand-wired controller that behaves the same, but Timeout returns null. During prerender/SSR every check returns false rather than throwing, so defer it to OnAfterRenderAsync.
@inject Bit.Butil.AbortController abortController
var supported = await abortController.IsSupported();
var hasTimeout = await abortController.IsTimeoutSupported();
var hasAny = await abortController.IsAnySupported();Create returns a handle; keep the handle and hand out its Signal. Abort runs every listener attached to the signal, and the reason you pass is what each of them sees. A signal aborts once and keeps the first reason it was given - aborting twice does nothing the second time. Subscribing to a signal that has already aborted fires the callback immediately rather than never, so a race between the abort and the subscription cannot lose the event.
private AbortControllerHandle? _handle;
private ButilSubscription? _subscription;
_handle = await abortController.Create();
_subscription = await _handle!.Signal.OnAbort(reason => InvokeAsync(() =>
{
// reason is the text passed to Abort, or the browser's own AbortError message
StateHasChanged();
}));
await _handle.Abort("the user changed their mind");
var aborted = await _handle.Signal.GetAborted(); // true
var reason = await _handle.Signal.GetReason(); // "the user changed their mind"
// disposing releases the signal - it does NOT abort it
await _handle.DisposeAsync();This is the point of a standalone controller. The same signal goes onto three requests at once, and one Abort cancels all three - which three AbortableFetch handles could only do one at a time. The signal composes with a request's own abort paths rather than replacing them, so the CancellationToken overload of Send still cancels a single request on its own.
var handle = await abortController.Create();
var requests = Enumerable.Range(1, 3).Select(i => fetch.Send(new FetchRequest
{
Url = $"/api/slow?seconds=10&id={i}",
Signal = handle!.Signal // the same signal on every request
}));
await handle!.Abort("cancelled by the user");
// every response comes back with Aborted = true
var responses = await Task.WhenAll(requests);A signal that aborts itself after a delay, with a TimeoutError reason, and that nothing can abort early. The timer starts when the signal is created, not when it is first used - so create it next to the operation it guards, not ahead of time.
var deadline = await abortController.Timeout(TimeSpan.FromSeconds(2));
var response = await fetch.Send(new FetchRequest
{
Url = "/api/slow?seconds=10",
Signal = deadline
});
// response.Aborted is true; deadline.GetReason() is the browser's TimeoutError message
await deadline!.DisposeAsync();Any composes signals: the result aborts as soon as the first source does, carrying that source's reason. A timeout signal cannot be cancelled by hand and a controller's signal has no deadline, so composing the two is how one operation gets both. The composite does not keep its sources alive - release a source and the composite is left watching a signal that can no longer fire, so dispose the composite first.
var cancel = await abortController.Create();
var deadline = await abortController.Timeout(TimeSpan.FromSeconds(5));
var either = await abortController.Any(cancel!.Signal, deadline!);
var response = await fetch.Send(new FetchRequest { Url = "/api/slow?seconds=10", Signal = either });
// whichever fired first, its reason is what either.GetReason() reports
await cancel.Abort("cancelled by the user"); // ... or wait 5s for the deadlineAbortController in JavaScript cancels nothing. DisposeAsync releases the
signal's JS entry and detaches its listeners; call Abort first if cancelling is what
you meant. As a safety net the AbortController service releases every signal left
behind when its scope is torn down - it does not abort them either.
API reference
ValueTask<bool> IsSupported()ValueTask<bool> IsTimeoutSupported()ValueTask<bool> IsAnySupported()ValueTask<AbortControllerHandle?> Create()ValueTask<ButilAbortSignal?> Timeout(TimeSpan delay)ValueTask<ButilAbortSignal?> Any(params ButilAbortSignal[] signals)ValueTask DisposeAsync()ButilAbortSignal SignalValueTask Abort(string? reason = null)ValueTask DisposeAsync()Guid IdValueTask<bool> GetAborted()ValueTask<string> GetReason()ValueTask<ButilSubscription?> OnAbort(Action<string> onAbort)ValueTask DisposeAsync()ButilAbortSignal? Signal