loading
Note:
Why not just the per-call handle?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.

Support check

IsSupported / IsTimeoutSupported / IsAnySupported

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.

C#
@inject Bit.Butil.AbortController abortController

var supported = await abortController.IsSupported();
var hasTimeout = await abortController.IsTimeoutSupported();
var hasAny = await abortController.IsAnySupported();
Live sample
support check output
Results will appear here when you interact with the samples.

Create, abort, observe

Create / Abort / GetAborted / GetReason / OnAbort

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.

C#
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();
Live sample
Controller None created.
controller output
Results will appear here when you interact with the samples.

One signal, many operations

FetchRequest.Signal

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.

C#
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);
Live sample
Requests Idle.
shared signal output
Results will appear here when you interact with the samples.

Deadlines

Timeout

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.

C#
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();
Live sample
timeout output
Results will appear here when you interact with the samples.

Deadline plus a Cancel button

Any

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.

C#
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 deadline
Live sample
Outcome Idle.
composite signal output
Results will appear here when you interact with the samples.
Warning:
Disposing is not aborting Letting a handle go out of scope cancels nothing, exactly as an unaborted AbortController 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

Member
Signature
Description
IsSupported
ValueTask<bool> IsSupported()
True when the runtime exposes AbortController. Returns default (false) during prerender/SSR instead of throwing.
IsTimeoutSupported
ValueTask<bool> IsTimeoutSupported()
True when the runtime exposes AbortSignal.timeout.
IsAnySupported
ValueTask<bool> IsAnySupported()
True when the runtime exposes AbortSignal.any. Any() still works when this is false, via an equivalent hand-wired controller.
Create
ValueTask<AbortControllerHandle?> Create()
Creates a controller and its signal. Null when the runtime has no AbortController.
Timeout
ValueTask<ButilAbortSignal?> Timeout(TimeSpan delay)
A signal that aborts itself after delay with a TimeoutError reason. Null where AbortSignal.timeout is missing. The timer starts immediately.
Any
ValueTask<ButilAbortSignal?> Any(params ButilAbortSignal[] signals)
A signal that aborts when any source does, carrying that source's reason. Null when a source has already been released.
DisposeAsync
ValueTask DisposeAsync()
On scope/circuit teardown, releases every signal whose handle was never disposed. Does not abort them.
AbortControllerHandle.Signal
ButilAbortSignal Signal
The signal this controller aborts. Pass it to anything that accepts one.
AbortControllerHandle.Abort
ValueTask Abort(string? reason = null)
Aborts the signal. Left null, the browser's own AbortError message is used. A signal keeps the first reason it was given.
AbortControllerHandle.DisposeAsync
ValueTask DisposeAsync()
Releases the controller and its signal. Idempotent. Does not abort.
ButilAbortSignal.Id
Guid Id
The signal's id - what crosses the interop boundary when a signal is passed to another API.
ButilAbortSignal.GetAborted
ValueTask<bool> GetAborted()
Whether the signal has already aborted.
ButilAbortSignal.GetReason
ValueTask<string> GetReason()
Why it aborted, flattened to text. Empty while it has not aborted.
ButilAbortSignal.OnAbort
ValueTask<ButilSubscription?> OnAbort(Action<string> onAbort)
Runs onAbort with the reason. Fires at most once, and fires immediately when the signal has already aborted.
ButilAbortSignal.DisposeAsync
ValueTask DisposeAsync()
Releases the signal's JS entry and detaches its listeners. Does not abort it.
FetchRequest.Signal
ButilAbortSignal? Signal
Aborts this request when the shared signal aborts. Composes with the request's own AbortableFetch handle and CancellationToken rather than replacing them.
An unhandled error has occurred. Reload 🗙