AsyncScope topic

AsyncScope

A scope whose whole content is a lifecycle: an asynchronous initialization and an asynchronous disposal, with no dependency container and no state class. Use it when the objects already exist — a singleton, a connection, a repository owned by a parent scope — and only their starting and stopping has to follow the widget tree.

AsyncScope(
  initScope: (context, ctx) async {
    ctx.progress('connecting');
    await connection.open();
  },
  disposeScope: () => connection.close(),
  waitingBuilder: (context) => const SizedBox.shrink(),
  progressBuilder: (context, progress) => Text('$progress'),
  errorBuilder: (context, error, stackTrace, progress) => Text('$error'),
  builder: (context) => const HomeScreen(),
);

AsyncScopeBase is the subclassable form: the same members as overrides — initScope, disposeScope, onMount, onUnmount, buildOnWaiting, buildOnProgress, buildOnReady, buildOnError — plus scopeKey, scopeKeyTimeout, initCancellationTimeout, disposeScopeTimeout, waitForChildrenTimeout, their onTimeout callbacks and pauseAfterInitialization. AsyncScopeCore sits under both for a scope that needs its own element.

The four states

AsyncScopeState is a sealed hierarchy, and each state drives one builder:

state builder when
AsyncScopeWaiting buildOnWaiting, or buildOnProgress when it returns null mounted; waiting for a scopeKey and for the first event
AsyncScopeProgress buildOnProgress initScope reported progress; the value is progress
AsyncScopeReady buildOnReady initScope returned
AsyncScopeError buildOnError initScope failed before it was ready; the progress it had reached comes with it

initScope says only two of those four things, and names neither: ctx.progress(x) is the progress, and returning is the ready. "Waiting" and "failed" belong to the scope rather than to the work — the first is where a scope starts, and the second is what a throw makes of it.

The ready state is not applied in the same frame the event arrives in. Without pauseAfterInitialization the scope schedules a post-frame callback, so the last progress value gets a frame to itself instead of being replaced within the frame it appeared in; with it, the ready branch is held back for that duration. ScopeConfig.pauseAfterInitializationEnabled = false turns all such pauses off globally — see the debug topic.

Progress

Progress is whatever the initialization says it is. AsyncScopeProgress carries an Object?, the builders receive an Object?, and the package never looks inside it. A String is the common case; anything with a toString will do.

AsyncScope(
  initScope: (context, ctx) async {
    ctx.progress('connecting');
    await api.connect();

    ctx.progress('loading the profile');
    await api.loadProfile();
  },
  disposeScope: api.close,
  progressBuilder: (context, progress) => Center(child: Text('$progress')),
  errorBuilder: (context, error, stackTrace, progress) =>
      Center(child: Text('failed at $progress: $error')),
  builder: (context) => const HomeScreen(),
);

Four things are worth knowing about the progress argument.

It is null before the first event. The scope is AsyncScopeWaiting from the moment it is mounted until initScope reports, and if buildOnWaiting returns null the waiting branch is buildOnProgress(context, null). Write the builder so that null means "nothing reported yet" — that is also what it means in buildOnError when the failure came before any progress did.

The last value gets a frame of its own. AsyncScopeReady is applied in a post-frame callback, so a progress value reported immediately before the body returns is actually painted instead of being replaced within the same frame. pauseAfterInitialization holds the ready branch back further still, which is what to reach for when the steps are too fast to read.

Progress after ready has nowhere to go. The scope is initialized once; a progress call made after it is ready — by a helper the body left running, say — is ignored and does not change what is on screen. An initialization that goes on producing values after the scope is usable wants a Listenable under the scope, not this context.

No progress at all is fine. An initScope that reports nothing never leaves AsyncScopeWaiting, so the scope shows buildOnWaiting — a spinner, usually — and then the ready branch.

Counting steps

For an initialization that knows how many steps it has, ProgressIterator counts them and Progress is the value it produces: number, total, value as a fraction between 0 and 1, and a toString of 2/3.

initScope: (context, ctx) async {
  final steps = ProgressIterator(3);

  ctx.progress(steps.nextStep()); // 1/3
  await api.connect();

  ctx.progress(steps.nextStep()); // 2/3
  await api.loadProfile();

  ctx.progress(steps.nextStep()); // 3/3
  await api.warmUpCache();
},
progressBuilder: (context, progress) => switch (progress) {
  final Progress progress => LinearProgressIndicator(value: progress.value),
  _ => const LinearProgressIndicator(),
},

The fraction is always between 0 and 1 — an empty task reads as complete rather than as NaN — so it can go straight into a progress indicator. See the utils topic.

Where the type comes back

ctx.progress takes an Object and the builders receive an Object?, so the type of a progress value is the builder's to declare: buildOnProgress can say covariant Progress? progress and read fields instead of calling toString. ScopeAutoDependencies reports a ScopeAutoDependenciesProgress per dependency — the path, the name and the step counter in one object. See the Scope topic.

The package itself never looks inside a progress value. Progress is a caption; the type parameter a family has, where it has one, belongs to the value being built.

Reading the state from the subtree

final scope = AsyncScope.of(context, listen: true);
if (scope.isInitialized) { … }

of, maybeOf and select return an AsyncScopeContext, which exposes state, isInitialized (the state is AsyncScopeReady), hasError, and error / stackTrace, both of which throw a StateError when there is no error. Reading a single field through select is the usual way in, since it subscribes to that field alone — the base topic explains the filtering.

Errors

A body that throws before the scope is ready puts it into AsyncScopeError, and buildOnError receives the error, its stack trace, and the progress the scope had reached when it failed.

One failure is handled differently, and deserves to be known.

An error with no outcome of its own — a late failure of an action abandoned by wait, a disposer, an onCancel callback or work started by unattended — reaches ScopeObserver.onError and FlutterError.reportError. It does not switch the screen to buildOnError, and a ready scope stays ready. That is deliberate: the widgets on screen are the ready ones, whatever initScope acquired still has to be released by disposeScope, and swapping the subtree for an error screen behind the user's back would strand both. A body that goes on working after it has returned — a helper it left running — is unusual, but it is exactly the case where the difference matters. Bare asynchronous work outside the context still reports to its own zone.

Disposal, in order

The teardown runs as one sequence, and every asynchronous step of it is awaited:

  1. onUnmount — synchronous, and always first. Whatever must stop reaching the scope at once is dropped here: subscriptions, listeners. It runs exactly once, whichever way the scope goes — removed from the tree, or closed with close() while it stays on screen.
  2. The wait for a scopeKey is cancelled, if the scope was still queueing for one.
  3. The initialization is cancelled, and the wait for that is bounded by initCancellationTimeout (ScopeConfig.defaultInitCancellationTimeout by default). Cancellation waits for the body, its child jobs and cleanup. The body learns of it through ctx.progress, check or the waiting family, which throws Cancelled according to the call's rules below — and a failure raised while it unwinds is reported rather than thrown on: abandoning the disposal at that point would leave the scope registered with its parent and its scopeKey unreleased. A cancellation that never finishes at all would leave it there just as surely, and needs no failure to do it: a body parked on somebody else's future is not interrupted by anything, and one that never asks ctx anything is never told. When the limit expires the initialization is left where it stands, the expiry is reported, and the teardown goes on. What the body holds stays held — and if it finishes later, what it produced is handed to disposeScope, unless the teardown has by then run to its end. Cleanup registered with the job does not need that hook and still runs.
  4. The initialization is awaited if it could not be cancelled.
  5. The child scopes are awaited, bounded by waitForChildrenTimeout (ScopeConfig.defaultWaitForChildrenTimeout by default). An expiry is reported through FlutterError.reportError and the disposal proceeds.
  6. disposeScope — the scope's own teardown, awaited when it returns a future, and bounded by disposeScopeTimeout (ScopeConfig.defaultDisposeScopeTimeout by default). It runs only when the initialization succeeded: a scope that never became ready — still waiting, or failed — has nothing to release. A teardown that never completes is user code holding step 7 below, and with it the scopeKey of a scope that has already left the tree; on expiry that is reported and step 7 runs anyway, while the teardown itself is left to finish whenever it does.
  7. The scope unregisters from its parent and releases its scopeKey, which lets the next scope waiting on that key through.

Every step is guarded on its own: a failure in one is never a reason to skip the ones behind it. Only one failure can be passed on, though — that is all a throw carries — and it is the first one, handed over once the whole sequence is over: to whoever called close(), or, for a scope taken off the tree, to the zone the teardown ran in. Every failure behind it is reported through FlutterError.reportError instead.

The order is what makes the family worth using: a parent never disposes of something while a child is still using it, and a re-created scope never overlaps with the one it replaces.

An initialization that fails owns its own mess

Step 6 is the one to read twice: disposeScope runs only when the initialization succeeded. A scope that failed halfway never reaches it, and there is no second hook that does — onUnmount runs, but it is handed nothing to work with.

That is not an oversight, and it is not a gap waiting to be closed: a scope that failed needs a partial teardown, and only the code that did the building knows which part. disposeScope is written against a scope that is finished and does the whole of it — asking it to also work out how far an initialization got would put that question into the one hook that is meant to be free of it. So it is the initialization's job to give back what it took before it failed:

// Wrong: the connection is open and nobody will ever close it.
initScope: (context, ctx) async {
  connection = await Api.connect();
  await connection.authenticate();           // throws
},
disposeScope: () => connection.close(),      // never called
// Right: what a step took is given back unless the scope took it over.
initScope: (context, ctx) async {
  final opened = await Api.connect();

  try {
    ctx.progress('authenticating');
    await opened.authenticate();
  } on Object {
    await opened.close();
    rethrow;
  }

  connection = opened;
},
disposeScope: () => connection.close(),

catch, and no flag — and both halves of that are worth saying, because the older form of this package needed the opposite of each.

An initialization ends early in two ways: a step of it fails, or the scope goes away before it was ever ready, removed from the tree or close()d. Both arrive in the body as a throw — the second one as Cancelled, raised by a checkpoint such as ctx.progress between two steps — so one catch covers both. Cancelled comes from async_job and is re-exported by scopo; catching it needs no separate dependency or import.

There is a third way, and it is the body's own: throw Cancelled('why') gives up on an initialization that has decided to stop -- no session, nothing to show, a precondition that did not hold. The scope treats it as a failure, because that is what it is from the screen's side: an initialization that never became ready. The Cancelled reaches buildOnError and ScopeObserver.onError the way a failing step would -- and, like a failing step, it does not go to FlutterError: the error is on the screen, and a red line in the console for something a builder is already showing says nothing new. Only a cancellation the teardown asked for is silent, and it can afford to be: somebody is waiting for it.

A body that touches the context nowhere is the exception that proves it: nothing is thrown at it, because Dart cannot interrupt somebody else's wait, and it runs to its end for a scope that is already gone. That is not a hole. What it produces then is handed to disposeScope rather than lost, which is the promise the next section is about.

There is no flag because there is nothing to guard against. Returning is the handover, and a body that returned has no lines left to run: reaching the catch and reaching the handover are exclusive by construction, so the guard cannot release something the scope has taken over. Where the guard has to know the difference from inside — a body with several steps releasing them in reverse — collect the releases as they are taken:

final acquired = <Future<void> Function()>[];

try {
  final database = await Database.open();
  acquired.add(database.close);

  ctx.progress('connecting');
  final session = await Session.connect();
  acquired.add(session.close);

  connection = Connection(database, session);
} on Object {
  for (final release in acquired.reversed) {
    await release();
  }
  rethrow;
}

Keep what the guard awaits able to finish. Nothing downstream sees the failure until the body is done with it, so an await in the guard holds the failure as well as the resource: a close() that never completes leaves the scope showing its loading branch until initCancellationTimeout expires, with nothing on screen and nothing in the console. The scope's own waits are all bounded for this reason, and so is the one the dependency container of the Scope family makes on your behalf. A guard you write yourself, or cleanup registered with the job, must be able to finish too; the job puts no timeout around a disposer.

What goes through the context, and what does not

ScopeInitContext adds progress to JobContext. Dart cannot interrupt somebody else's await, so the body hears about cancellation through checkpoints: progress, check, wait, join, uncancellable, onCancel and run. Registering cleanup and starting unattended work still work after cancellation while the job is alive; ctx.job.isCancelled answers without throwing.

wait with discard: is the form to reach for when an acquisition can be left in flight. It ends the waiting rather than the work. The action runs on, but the value has someone to close it even if it never reaches the body:

final opened = await ctx.wait(Api.connect, discard: (api) => api.close());

The rule is one: a value that never reaches the body is cleaned up unconditionally; a value that does goes on the cleanup stack. An abandoned wait puts its value on the stack while the job is still unwinding it, or closes it on the spot if the job is already over. On that stack, discard: runs on failure or cancellation; dispose: runs whatever the outcome. Use dispose: for a temporary file, a lock or a subscription the body keeps to itself, and pass only one of the two.

For a read, a warm-up or a pause there may be nothing to release:

await ctx.wait(cache.warmUp);   // let go the moment the scope gives up

join is for a call that must not be left halfway — a migration, a device write, somebody else's init. It accepts cancellation at once but waits for the call to finish before throwing Cancelled; an error from the call still arrives as that error. It replaces the old pair await x(); ctx.check();, keeping the wait and the check together:

await ctx.join(database.migrate);

For a join that returns a resource, pass discard: or dispose: too; a value the body will not receive is released before the cancellation is thrown. ctx.check() still belongs where there is no call to wrap, such as a loop over work of your own. await ctx.uncancellable(step) holds cancellation back for a step that must not be cancelled at all; it is delivered when the step ends. ctx.unattended(work) starts work nobody waits for and reports its errors to the observer; it neither waits for nor cancels that work.

For a value the body already holds, onDispose and onDiscard register the same two kinds of cleanup. They return a function that unregisters it; disown(value) instead removes cleanup attached to that value by wait or join. The stack unwinds in reverse registration order, after the child jobs and before the outcome, awaiting each release:

ctx.onDispose(temporary.close);          // released on every outcome
final unregister = ctx.onDiscard(session.close); // on failure or cancellation
final opened = await ctx.wait(Api.connect, discard: (api) => api.close());

connection = Connection(opened, session);
// Handed to the scope: its disposeScope now owns both releases.
ctx.disown(opened);
unregister();

Remove those registrations at the handover, with no await or checkpoint between it and returning. Otherwise the job's cancellation cleanup and the scope's release of a late return would both close the same resource. A registration made by onDispose or onDiscard has no value attached, so disown cannot remove it; use the returned function, as above.

A bare call is still right for a short initialization that asks the context nothing. The reason that is safe is a promise of the scope rather than a hope: a body that comes back for a scope which has already given up settles nothing, but what it produced is released rather than droppeddisposeScope here, disposeData in the AsyncDataScope topic, the container's own teardown in the Scope one. The one path where it cannot is a teardown that has already finished, an initCancellationTimeout it gave up on: by then the scope has no widget left to read the hook from.

For several dependencies with an order of their own, the dependency container of the Scope family keeps the tree and its teardown — see the Scope topic. AsyncControllerScope closes the same hole from the other side: its controller is disposed of on every path, including the one where init() threw.

Parents and children

Every asynchronous scope registers with the nearest AsyncScopeParent above it — a parent scope if there is one, an AsyncScopeCoordinator otherwise — and that is what step 5 above waits for. The mixin exposes what it knows:

hasChildren;    // bool
childrenCount;  // int
await waitForChildren(timeout: …, onTimeout: …);

Written without a receiver on purpose: the mixin sits on the element, and the elements of the five built-in families are private. So those three are for a scope of your own — a family built on AsyncScopeCore, reading them on this — and not for a subtree looking upwards. AsyncScope.of(context, listen: false) and its siblings hand back an AsyncScopeContext, which carries the state of the scope and none of this.

From a subtree, the wait to ask for is the coordinator's:

await AsyncScopeCoordinator.waitForChildren(context);

It waits for the scopes registered with the nearest coordinator — the ones with no parent scope above them — and not for the children of one particular scope. Waiting for those, from outside the scope that has them, is not something the built-in families offer.

waitForChildren awaits the children registered at the moment of the call. A child that registers while the wait is running is not awaited by it, and is still registered once it is over. On expiry the children that never finished are dropped, onTimeout is called — by default a FlutterError.reportError naming the scope, and a callback of your own is handed that same named error — and the future completes normally either way. Nothing here deadlocks; it degrades into a delay and a report.

A scope with neither a parent scope nor a coordinator above it registers nowhere, and nothing waits for it. That is worth knowing before removing a coordinator that looked decorative.

scopeKey and the coordinator

scopeKey serializes scopes that must not overlap. A scope with a key waits at the head of a queue until the previous holder of that key has finished disposing of itself — the whole seven-step sequence above, not just its removal from the tree.

The queues belong to the nearest AsyncScopeCoordinator:

AsyncScopeCoordinator(child: MaterialApp(home: HomeScreen()))

Two consequences follow from "the nearest". Two scopes with equal keys under different coordinators never wait for one another. And the queues live on the coordinator's element, so serialization holds only as long as that element does: replacing the coordinator — a different ValueKey, a different place in the tree — throws its queues away with it. Above everything that can be replaced is therefore the right place for it, and above MaterialApp is the usual one.

A scope with a scopeKey and no coordinator above it fails loudly:

No AsyncScopeCoordinator.
The AsyncScopeCoordinator is missing in the context. Add it to the widget tree
so that all your scopes that need it can access it. The most universal solution
is to place it above MaterialApp. …

The coordinator is also the wait root for the scopes in its subtree that have no parent scope, which is what makes it useful without any scopeKey at all:

await AsyncScopeCoordinator.waitForChildren(context);

before tearing down a test, or before leaving a splash screen. Its timeout defaults to ScopeConfig.defaultWaitForChildrenTimeout, and an expiry behaves exactly as above: reported, not thrown.

Where to go next

topic what it covers
AsyncDataScope this family plus one value produced by the initialization
LiteScope a state class with the same lifecycle, and close()
Scope the full family: a dependency container on top of all this
ScopeNotifier the state models this family is built on
debug the observer that reports every step above, and the timeouts

Classes

AsyncScope AsyncScope
AsyncScopeAccess<W extends AsyncScopeBase<W>> AsyncScope
The three accessors of one AsyncScopeBase, with its type argument named once.
AsyncScopeBase<W extends AsyncScopeBase<W>> AsyncScope
AsyncScopeContext<W extends ScopeInheritedWidget> AsyncScope
AsyncScopeCoordinator AsyncScope
Owns the scopeKey queues of its subtree, and is the wait root for the scopes in it that have no parent scope above them.
AsyncScopeCore<W extends AsyncScopeCore<W, E>, E extends AsyncScopeElementBase<W, E>> AsyncScope
AsyncScopeElementBase<W extends AsyncScopeCore<W, E>, E extends AsyncScopeElementBase<W, E>> AsyncScope
AsyncScopeError AsyncScope
AsyncScopeInitState AsyncScope
AsyncScopeModel AsyncScope
The read-only model of an asynchronous scope: its AsyncScopeState, and a notification whenever that state changes.
AsyncScopeProgress AsyncScope
AsyncScopeReady AsyncScope
AsyncScopeState AsyncScope
AsyncScopeWaiting AsyncScope

Mixins

AsyncScopeParent AsyncScope
A scope that waits for the scopes below it before disposing of itself.