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:
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 withclose()while it stays on screen.- The wait for a
scopeKeyis cancelled, if the scope was still queueing for one. - The initialization is cancelled, and the wait for that is bounded by
initCancellationTimeout(ScopeConfig.defaultInitCancellationTimeoutby default). Cancellation waits for the body, its child jobs and cleanup. The body learns of it throughctx.progress,checkor the waiting family, which throwsCancelledaccording 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 itsscopeKeyunreleased. 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 asksctxanything 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 todisposeScope, unless the teardown has by then run to its end. Cleanup registered with the job does not need that hook and still runs. - The initialization is awaited if it could not be cancelled.
- The child scopes are awaited, bounded by
waitForChildrenTimeout(ScopeConfig.defaultWaitForChildrenTimeoutby default). An expiry is reported throughFlutterError.reportErrorand the disposal proceeds. disposeScope— the scope's own teardown, awaited when it returns a future, and bounded bydisposeScopeTimeout(ScopeConfig.defaultDisposeScopeTimeoutby 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 thescopeKeyof 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.- 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 dropped —
disposeScope 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< AsyncScopeW> > - The three accessors of one AsyncScopeBase, with its type argument named once.
-
AsyncScopeBase<
W extends AsyncScopeBase< AsyncScopeW> > -
AsyncScopeContext<
W extends ScopeInheritedWidget> AsyncScope - AsyncScopeCoordinator AsyncScope
-
Owns the
scopeKeyqueues of its subtree, and is the wait root for the scopes in it that have no parent scope above them. -
AsyncScopeCore<
W extends AsyncScopeCore< AsyncScopeW, E> , E extends AsyncScopeElementBase<W, E> > -
AsyncScopeElementBase<
W extends AsyncScopeCore< AsyncScopeW, E> , E extends AsyncScopeElementBase<W, E> > - 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.