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 abandonable, 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.

A child job's failure counts as the body's. One the body lets out ends the initialization the way its own throw would: the screen switches to buildOnError, and ScopeObserver.onError hears it once. One the body catches and recovers from is told to the observer and goes no further: the child did fail, and the scope is ready all the same. FlutterError.reportError gets only what nobody else is left to be handed. An error your app has declared a cancellation through Job.isCancellationError is not one of those while the initialization is being cancelled: an operation that stops at its token with an error of its own is heard by the observer and leaves the console alone. The one exception is a body that failed while a cleanup it registered was still running, and whose outcome the cancellation then took over: the observer does not hear that failure either — see the debug topic, section onError and ScopeErrorRoute.

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 even if the teardown has already finished. 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. The first failure is handed to whoever called close() once the whole sequence is over. For a scope taken off the tree, it is reported through FlutterError.reportError instead. Every failure behind it is reported through FlutterError.reportError too.

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 catch (error, stackTrace) {
  for (final release in acquired.reversed) {
    try {
      await release();
    } on Object catch (cleanupError, cleanupStack) {
      FlutterError.reportError(
        FlutterErrorDetails(
          exception: cleanupError,
          stack: cleanupStack,
          context: ErrorDescription('during partial initialization cleanup'),
        ),
      );
    }
  }
  Error.throwWithStackTrace(error, stackTrace);
}

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. While the scope remains mounted and no disposal has been requested, a close() that never completes keeps the loading branch visible indefinitely: initCancellationTimeout does not run then. It bounds only the wait for initialization cancellation during disposal; expiry lets disposal proceed, but does not finish the hung cleanup or deliver the body's error. 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, abandonable, 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.

abandonable 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.abandonable(
  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 abandonable 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.abandonable(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 abandonable 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.abandonable(
  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 dropped — disposeScope here, disposeData in the AsyncDataScope topic, the container's own teardown in the Scope one. However late it comes back: the release is a hook of the scope's widget, and the scope holds that widget until the initialization is over rather than until it stops waiting for one. initCancellationTimeout bounds the wait, not the promise.

A body that fails instead, after the scope has given up, is reported through FlutterError.reportError: the error branch is gone, and the crash reporting of the application is the one place left to hear of it. When such a failure is expected — an acquisition the user walked away from — catch it and call ctx.check() before rethrowing. Once the scope has given up, the check throws Cancelled, and the failure ends as the cancellation it followed; while the scope still waits for the body, the check passes, and the error branch shows the failure.

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.

close()

await AsyncScope.of(context, listen: false).close();

Taking a scope off the tree starts the teardown above, but nothing waits for it: the widget is gone in that frame, and whoever removed it learns neither when the release is over nor how it went. close() runs the same teardown with the scope still in place, and completes when the sequence is over, with its first failure.

The subtree goes first. The next frame builds the closing branch — closingBuilder, or buildOnClosing in a subclass, and an empty SizedBox when there is neither — and unmounts the old subtree in that frame, its consumers and child scopes with it. Only then does step 1 run, so a dispose below the scope can still use what the scope holds. The scope stays mounted in its closing branch after the future completes, and its scopeKey is released at step 7 without waiting for the tree: the next owner of the key starts while this one is still on screen.

AsyncScope(
  initScope: (context, ctx) => connection.open(),
  disposeScope: () => connection.close(),
  closingBuilder: (context) => const Text('closing'),
  progressBuilder: (context, progress) => const Text('connecting'),
  errorBuilder: (context, error, stackTrace, progress) => Text('$error'),
  builder: (context) => const HomeScreen(),
);

close() works from every state. A scope still waiting for its scopeKey, or still initializing, is cancelled as it would be on removal; one whose initialization failed has nothing to release and completes right after its closing frame. The closing frame is asked for like any other, and when frames are off — an application in the background — a warm-up frame is scheduled instead of waiting for one. A scope taken off the tree before its closing frame goes straight on with the teardown.

Calling close() twice does not restart anything: every call, and the removal from the tree, joins the one teardown and receives its outcome, the same failure with the same stack trace. A call the framework refuses — close() from the build of a descendant, where markNeedsBuild is not allowed — fails with that error and leaves the scope as it was; the next call starts afresh.

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 queues successive owners of one resource. A scope with a key waits for the previous holder of that key to finish disposing of itself — the whole seven-step sequence above, not just its removal from the tree — bounded by a timeout of 3 s by default. On expiry the timeout is reported and the new scope proceeds, even if the old owner is still releasing the resource: two owners can overlap. The waits for children, initialization cancellation and a scope's own disposal also default to 3 s; after expiry a parent can release dependencies while a child still uses them. The README, section scopeKey, shows how to disable all four limits before runApp: a hung disposal or initialization can then block further teardown forever. Per-scope overrides and the reporting of an expiry are in the debug topic, section Timeouts.

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.