debug topic
debug
The observer and the global settings of the package. Everything on this page is
static and lives in ScopeConfig, so the usual place to set it up is main(),
before runApp.
The observer
The package is silent until it is given somewhere to speak.
ScopeConfig.observer is null by default; assign a ScopeObserver and every
scope in the application starts reporting through it.
void main() {
ScopeConfig.observer = const ScopePrintObserver();
runApp(const App());
}
ScopeObserver is a class of twelve methods, every one of them empty. A
subclass overrides what it wants and inherits the silence of the rest, so an
observer that only cares about failures is one method long:
final class CrashReporter extends ScopeObserver {
const CrashReporter();
@override
void onError(
ScopeObservable target,
ScopePhase phase,
ScopeErrorRoute route,
Object error,
StackTrace? stackTrace,
) {
// Any other route: somebody else has the failure, or it is no failure.
if (route != ScopeErrorRoute.observerOnly) return;
crashlytics.recordError(
error,
stackTrace,
reason: '${target.debugLabel}: ${phase.name}',
);
}
}
It forwards only what route says goes nowhere else. The rest is somebody
else's: FlutterError.onError or the zone has it, and the crash reporting of
an application usually listens there already, or the code that called close()
is handed it. Forwarding that as well would report it twice. What says
cancellation is no failure at all. See "onError and ScopeErrorRoute"
below.
ScopeObserver is an ordinary class: a subclass needs no class modifier, and a
test double can implement it. Extend it for an ordinary observer so that new
empty hooks are inherited.
ScopeConfig.observer holds one observer, and wanting two is ordinary — the
one above beside ScopePrintObserver while you are working. That is
ScopeCompositeObserver:
ScopeConfig.observer = const ScopeCompositeObserver([
ScopePrintObserver(),
CrashReporter(),
]);
It is part of the package rather than something to write by hand, and that is the point of it. The empty hooks above are what keep an ordinary observer compiling when a new one is added later — and a delegate is the one subclass that gains nothing from them: the new hook would arrive with the empty implementation of the base, and every observer behind the delegate would stop hearing that event without a word from anywhere. This one is written with the class it forwards. An observer that throws does not stop the ones after it either; the failure is reported and the rest are asked.
The hooks are called synchronously, from the build, the initialization or the teardown they belong to. That is what makes them useful — the order the lines come out in is the order the package did the work — and it is also why one that throws is caught rather than left to escape; see "When the observer itself fails" below.
The twelve hooks
| hook | what happened |
|---|---|
onInitStarted(target) |
an initialization has begun |
onStepStarted(target, path) |
one step of it has begun |
onProgress(target, progress) |
one step of it is done |
onInitialized(target) |
it finished successfully |
onCancelled(target) |
it was cancelled before it finished |
onDisposalStarted(target) |
a teardown has begun |
onDisposalStepStarted(target, path) |
one step of it has begun |
onDisposalStepFinished(target, path) |
one step of it is done |
onDisposed(target) |
a teardown has finished |
onError(target, phase, route, error, stackTrace) |
something failed |
onTimeout(target, what, error, stackTrace) |
a bounded wait expired |
onTrace(target, message) |
a step of the machinery below the lifecycle |
Four of them are the dependency container's steps — onStepStarted and
onProgress for an initialization, onDisposalStepStarted and
onDisposalStepFinished for a teardown — and they are two pairs: an entry and
an exit for each half of its lifecycle. Both entries are sent from inside the
step and before it awaits anything, which is what makes the pairs worth
reading — see "The last entry with no exit" below. onProgress is the one of
the four that a scope sends as well, on its own account; the other three come
from a container and nowhere else.
Who sends them
A family with no initialization phase of its own — ScopeWidget, ScopeModel,
ScopeNotifier, AsyncScopeCoordinator — reports onInitStarted when its
element is initialized, and onDisposalStarted/onDisposed as a pair around
its teardown. That is all such a scope has to say when it works; one whose
init() throws says onError with ScopePhase.initialization and nothing
else. Before this reporting existed it said nothing at all: its lifecycle was
visible only to a debugger.
A family that runs an initialization — everything built on the asynchronous
element: AsyncScope, AsyncDataScope, AsyncControllerScope, LiteScope
and Scope — reports that phase instead, in the detail the phase has:
onInitStarted, then onProgress per step, then onInitialized or
onCancelled; and, on the way out, onDisposalStarted and onDisposed.
Nothing reports both halves — the structural pair is suppressed for these
families, so a LiteScope produces one onInitStarted, not two.
Three things that order does not say, and all three matter to an observer that pairs events up:
onCancelleddoes not always follow anonInitStarted. A scope still queued for itsscopeKeywhen it is taken off the tree never started an initialization of its own, so there was nothing to announce: its whole recording isonCancelled,onDisposalStarted,onDisposed. The cancellation genuinely happens in the queue, before the initialization this family would otherwise report;onDisposalStartedandonDisposedalways come as a pair.onDisposalStartedis sent by every teardown, including one of a scope that never became ready and has nothing of its own to release, andonDisposedis sent even when the teardown failed — after theonErrorthat says so, not instead of it. So a leak counter or a span tracker that opens on one and closes on the other stays balanced whichever way the scope went;- for a family with no phase of its own, that same pair requires the
onInitStartedthat would open it.ScopeWidget,ScopeModel,ScopeNotifierandAsyncScopeCoordinatorreportonDisposalStarted/onDisposedonly for an element whoseinit()succeeded; one that threw, or never ran, reports neither half — nothing was announced as open, so nothing is announced as closed, even though the element still tears itself down internally. This is the one point where the two kinds of family differ: the phase-reporting families above can still close a teardown that opened with noonInitStartedat all, as the previous bullet shows. The failure itself is not lost either way —init()is the one hook both kinds run before anything else, and a throw from it reportsonErrorwithScopePhase.initializationfor both.
The container of automatic dependencies of a Scope reports its own lifecycle
under its own label, beside the scope that owns it: onInitStarted, then
onStepStarted/onProgress per dependency initialized, onInitialized or
onCancelled, and then onDisposalStarted,
onDisposalStepStarted/onDisposalStepFinished per dependency released,
onDisposed.
A single dependency sends nothing but onTrace: the two points where it
handles an error of its own. The four step events above are its steps but the
container's events — they arrive under the container's label, which is the
label an observer that filters by target is already holding.
The last entry with no exit
onStepStarted is sent from inside the step, before the initializer awaits
anything, and onProgress for that same step is sent once it is done. So the
recording of an initialization that hung ends with the path of the step it hung
in:
scopo | AppDeps(#1a2b7) | initialize…
scopo | AppDeps(#1a2b7) | initialize storage/db…
scopo | AppDeps(#1a2b7) | progress: storage/db (1/3)
scopo | AppDeps(#1a2b7) | initialize network/session…
network/session was entered and never came back. Nothing else in the
recording says so: progress counts what finished, and the step after
storage/db is only guessable for a sequential group — a concurrent one
has several steps in flight at once, and the number of the last completed one
says nothing about which of them is stuck.
onDisposalStepStarted and onDisposalStepFinished are the same pair for the
teardown, and are read the same way.
Two things the pairs promise, and one they do not:
- the path of the two halves is the same string. It is assembled by the
same groups, through the same code, on the way up — so
pathcan be used as the key of a map that opens on the entry and closes on the exit; - a release is announced only when there is one to run. A dependency that
registered nothing, or only an
unmount, has no asynchronous teardown, and a bare entry for it would read as a release that hung. It is walked past in silence instead, so an unmatched entry always means what it looks like; - a dependency of your own making announces no entry. One that implements
ScopeDependencyrather than being built bydep,sequentialorconcurrenthas nowhere to take the mark from. Both of its exits still arrive:onProgresstravels theonStepcallback ofinit(), which is the part of the contract such a dependency does implement, and its release is announced for it — by the group above it as the path comes through, or, for such a dependency standing as the whole tree, by the container's own callback. That last case is the one place the guarantee below stops: a tree whose root is yours and whose disposal someone else drives is not announced at all, because the container receives no callback from that walk.
An entry has three ends, and only two of them are events: the exit, an
onError carrying ScopePhase.disposal, and silence. The failure arrives
right behind the entry it ends, so a release that threw is never mistaken for
one that hung — and an entry with nothing behind it means the step is still
running, or never came back.
The disposal pair holds whoever drives the walk. Disposing of the tree by
hand is a supported thing to do, and a container can also join a walk already
running rather than start a second one; in both cases the container is not the
one receiving the paths. onDisposalStepFinished is sent when the disposer has
returned, not when the path reaches a callback, so the pair survives all three.
The disposal walk cannot be cancelled; every release is awaited.
Unlike onProgress, an entry is not passed on to the scope that owns the
container — see the note at the end of the section below.
What onProgress carries
progress is an Object? because the two sources report different things,
each already typed on its own terms:
- from a scope, the value its initialization reported as progress: whatever the
application passed to
ctx.progress— aStringon the splash screen, in most of them; - from a dependency container that is initializing, a
ScopeAutoDependenciesProgress, which carries thepathof the dependency just built along withname,number,totalandvalue. AScopewhose container builds its dependencies passes the same value on, so it arrives twice: once under the container's label and once under the scope's.
Wrapping the two in a common type would have meant inventing a third.
A disposal is not one of them. It used to be — the bare String path of
the dependency just released arrived here too — and that left one hook meaning
two different things at two different points of the lifecycle, told apart by
the type of a value. It has onDisposalStepFinished of its own now. See
"Coming from 0.12.x" below.
The entry marks arrive once, not twice. onProgress reaches the scope as
well as the container because the container forwards it to ctx.progress.
onStepStarted and onDisposalStepStarted travel a channel of their own —
that is what lets them be sent from inside a step rather than after it — so
they arrive under the container's label only. An observer that wants them
beside a scope reads target: the container reports next to the scope that
owns it, under a label of its own.
onError and ScopePhase
phase says what was running when the failure happened:
ScopePhase |
what was running |
|---|---|
initialization |
the initialization: the init() hook or the phase |
initializationCancellation |
cancelling an initialization still running |
build |
a build of the scope's own subtree |
preparationForDisposal |
getting ready for the teardown: debugLabel, the timeout callbacks, giving up the scopeKey wait |
unmount |
the onUnmount hook |
disposal |
any dispose: disposeScope, a dependency's, a controller's, a ScopeModel's |
abandonedWait |
a wait that failed after its waiter was gone |
The enum can grow, so a switch over it in your own code wants a default
branch.
build is the one phase you would have seen something of without an observer:
Flutter's build error boundary answers a failing build with an ErrorWidget.
The report is added to that rather than put in its place — the ErrorWidget
still appears, and the failure also arrives through the channel the rest of the
lifecycle arrives through. The five buildOn* branches,
ScopeWidgetBase.build, ScopeModel.build and the build of ScopeState and
LiteScopeState all report under this phase.
ScopeState and LiteScopeState also report failures of initStateAsync and
onInitialized under initialization, with their owning scope as the target
and the original zone or Flutter error path preserved. Ordinary Flutter hooks
such as initState, didChangeDependencies, didUpdateWidget, deactivate
and activate remain outside this observer channel.
A widget that is not a scope does not: ListenableSelector and the views of
ScopeNotifier build like any other widget, and onError needs a
ScopeObservable to name as the target. Their builds stay where they were —
inside the build error boundary, and nowhere else.
A teardown that fails more than once reports every failure, not the first
alone. A Scope tears its state down before its dependencies, and each half is
guarded on its own, so both can fail, and onError carries them both.
Where such a failure goes besides the observer depends on whether anybody is
left to be handed it. Of the failures thrown by the scope's own teardown steps,
including a hand-written ScopeDependencies.dispose, the first reaches whoever
asked for the teardown — close(), say — and the rest are reported. Nothing at
all is raised while the framework is taking a scope off the tree: that caller
is a loop unmounting a whole batch of elements with no boundary around any one
of them, over a list it has already cleared, so a throw there would cost every
scope behind this one the teardown it is owed. Both channels still carry the
failure; neither of them is the throw.
ScopeAutoDependencies.dispose does not rethrow a failure of its tree. Each
failed disposal step is reported through FlutterError.reportError where it
fails, once, and its onError carries the failure named with the path of the
step and says ScopeErrorRoute.flutterError — so a tree where two leaves
failed gives two reports. A step that fails with Cancelled says
ScopeErrorRoute.cancellation and is not reported. A successful
await close() therefore does not prove that every automatic dependency was
released successfully. Every failed step is available in
flattenDependenciesWithErrors() and through onError. A group that fails
before it reaches any child — a dependency of your own making whose
disposalRequired throws — reports that as a failed step of its own, named
with the path of the group: once, with ScopeErrorRoute.flutterError, whether
or not another failure of the walk came first.
onError and ScopeErrorRoute
An observer that sends every onError to a crash reporter reports many
failures twice. A failure nobody is left to be handed — a teardown that failed
after the scope left the tree, a build that threw — is also reported through
FlutterError.reportError, and the crash reporting of an application usually
listens at FlutterError.onError as well. route says where else the failure
goes, and it is known by the time onError is called:
ScopeErrorRoute |
who else has the failure | for instance |
|---|---|---|
observerOnly |
nobody: the observer is the only one told | an initScope that threw, a Cancelled included, or an initDependencies that threw; a failure of a child job that the body caught, or that nobody read |
flutterError |
FlutterError.onError, through a report of the package or of Flutter's build error boundary |
a build or a synchronous init() that threw; a teardown failure with no caller left; a late release; a failed cleanup, callback or late work of the initialization job |
zone |
the zone the scope runs in, as an uncaught error | an initStateAsync of a LiteScopeState or a ScopeState that threw; an AsyncScope that failed before its initialization started |
caller |
the code that called close(): its Future completes with the failure |
the first failure of a teardown that close() began |
cancellation |
nobody, since it is not a failure | a Cancelled thrown by a cleanup or a callback of the initialization job, or by a disposal step of a dependency tree; an error that Job.isCancellationError names on a cancelled job |
So a crash reporter forwards ScopeErrorRoute.observerOnly and nothing else,
as CrashReporter above does, and each failure is reported once. caller is
your own code: whatever called close() is handed the failure, and reports it
if it wants to. The enum can grow too, and a switch over it wants a default
branch.
Leaving ScopeConfig.observer at null hides no failure completely. An
initialization that failed still shows in buildOnError, and a failure of a
child job that nobody read, which goes to the observer alone while there is
one, is reported through FlutterError.reportError instead.
The route is final when onError is called, and in seven cases it still does
not tell the whole story:
- a
close()that joins a teardown the tree began. A scope taken off the tree has begun its teardown, so the first failure of that teardown saysflutterErrorand is reported. Aclose()called on the scope after that shares the teardown, and theFutureit returns completes with the same failure. Code that reports what itsclose()throws reports that one a second time; - a dependency tree disposed of by hand.
root.dispose()on the tree of aScopeAutoDependencieswalks it the way the container does: every failed step is reported and saysflutterError, the first one included — androot.dispose()also throws that first one to you. It has been reported already, so the code that drives the walk need not report what it throws; - a
Cancelledthrown by a teardown hook of a scope, such asdisposeScopeoronUnmount. It saysflutterErrorand is reported, as any other failure of that stage is. A crash reporter that listens atFlutterError.onErrorreceives it as a failure; ignoreFailure()called late on a cancelled child. A child job whose body fails after the child was cancelled saysflutterError, and the report comes once the child has ended. Achild.ignoreFailure()your code calls in between — after the failure, before the child ends — takes that report away. The failure then reaches the observer alone, under a route that says otherwise, and a crash reporter that forwardsobserverOnlynever sees it;- two failures that end alike, and two routes. When the body of an
initialization fails while a cleanup it registered is still running, and the
scope is taken off the tree before that cleanup ends, the cancellation takes
over the outcome: the failure then says
flutterError, withScopePhase.initializationCancellation, and is reported — orcancellation, and is not, whenJob.isCancellationErrornames it. A failure of a child job that the body let out, covered by the cancellation the same way, saysobserverOnly, withScopePhase.initialization: the observer heard it from the child before anything was settled, was told that it goes nowhere else, and it is not reported, unless the body takes it up again after its cancellation (the next case). Forwarded by route, each is reported once — but a log or a test that expects both inFlutterError.onErrorfinds only the first there; - one object that fails twice. The package tells failures apart by
identity, so a
constexception —throw const NetworkUnavailable()— or a cached error thrown again is one failure to it, however many times it is thrown. The observer hears it once, with the first route. When a failure of a child job saidobserverOnlyand the same object then fails a cleanup, a callback or late work of the initialization job, or the body itself after the scope was taken off the tree — reading the child, or throwing again what it caught — whether it is the same failure thrown again or a second one, it is reported throughFlutterError.reportErroras well, and the observer is not told again. A duplicate is better than a loss: a crash reporter that forwardsobserverOnlyand listens atFlutterError.onErrorgets that failure from both; - an observer is there even when it does nothing. A failure of a child job
that nobody read says
observerOnlyand is kept out ofFlutterErrorwheneverScopeConfig.observeris set, short of the case above: the package does not look behind it, and an observer that drops what it hears keeps the failure out the same way, and then nobody has it. AScopeCompositeObserverwith nobody inside is told apart: the package takes it for no observer at all. WithScopeConfig.observeratnullthe failure is reported, and so it is when theonErrorof the observer throws: that observer has heard nothing. AScopeCompositeObserverone of whose observers throws has heard nothing either: the failure is reported, and an observer in it that did hear it and also listens atFlutterError.onErrorgets it twice.
What onTimeout covers
Every bounded wait reports an expiry through the observer — all seven of them —
and what names the one that expired:
what |
the wait |
|---|---|
access to its scopeKey |
a scope queued behind the previous one on the same key |
its own teardown |
a scope waiting out disposeScope |
its state to be disposed of |
the first half of that wait on a Scope, which has two |
its dependencies to be disposed of |
and the second half |
its initialization to be cancelled |
a teardown that arrived while the initialization was still running |
its controller to be released |
an AsyncControllerScope giving back a controller its own initialization never handed over |
its child scopes |
a parent waiting for the scopes below it |
the disposal |
a dependency container waiting for the tree it built to be released |
Seven rows and eight labels: a Scope has two steps behind disposeScope and
bounds each of them, so it reports one of the middle two rather than
its own teardown, which is what every other family reports.
error is the TimeoutException the report carries: the scope's name, the
limit, and — for the two waits that queue behind something — what was still
pending when that limit ran out, the entries ahead in the queue of a scopeKey
or the children a parent never saw finish. It is everything the package knows
about the expiry, so an observer that logs it needs nothing beside it.
An expiry is never announced through the observer alone. The first two of these
also reach onScopeKeyTimeout and onWaitForChildrenTimeout — the scope's own
callbacks — and every one of them is reported through
FlutterError.reportError, unless a callback of yours takes that place or the
application switched those reports off. Neither the callback nor the switch
touches this event: the observer is what the package says about itself, not
your error handling.
What onTrace covers
Everything below the lifecycle: preparing for initialization and for disposal,
the scopeKey queue (waiting for access, obtaining it, giving it up, leaving),
waiting for child scopes, waiting for an initialization to finish, the two
points inside a dependency where an error is handled, and messages the
initialization sends through ctx.log.
A scope produces a dozen of these where it produces one of everything else,
which is why ScopePrintObserver leaves them off. They are what to turn on
when a scope hangs, initializes in an unexpected order, or is disposed of too
late — and nothing else.
Who the event is about
The first argument of every hook is a ScopeObservable, and its only member is
the name the source calls itself by:
| source | debugLabel |
|---|---|
| a scope element, any family | CounterScope(#4e0b7) or CounterScope(cart) |
| the container of dependencies | AppDependencies(#25f53) |
| a single dependency | its declared name; [group] if the group is anonymous |
A tag is therefore the cheapest way to tell two scopes of the same type apart
in the output — and the only way to get a label that is the same on every run,
since the short hash is not.
ScopeObservable is deliberately not implemented by ScopeDependencies or
ScopeDependency: those are yours to implement, and a new required member on
them would break the code that already does. Only the classes of the package
produce events, so only they carry the marker.
An observer that wants one kind of source narrows with a pattern rather than a cast:
final class ScopeAnalytics extends ScopeObserver {
const ScopeAnalytics();
@override
void onInitialized(ScopeObservable target) {
if (target case ScopeInheritedElement(:final widget)) {
analytics.logEvent('scope_ready', {'type': '${widget.runtimeType}'});
}
}
}
ScopePrintObserver
The observer that comes with the package writes a line per event:
ScopeConfig.observer = const ScopePrintObserver();
scopo | CounterScope(#4e0b7) | initialize…
scopo | AppDependencies(#25f53) | progress: prefs (1/2)
scopo | CounterScope(#4e0b7) | initialized
scopo | CounterScope(#4e0b7) | initialization failed: Exception: no network
The shape is scopo | <label> | <what happened>. A failure adds : <error>,
and the stack trace on a line of its own when the event carries one.
The phase of a failure is spelled out as English rather than as the name of the
ScopePhase value: initialization failed,
initialization cancellation failed, build failed,
preparation for disposal failed, unmount failed, disposal failed, and —
the one that does not fit that shape — an abandoned wait ended in a failure.
Two parameters, both optional:
ScopeConfig.observer = ScopePrintObserver(
output: debugPrint,
trace: true,
);
output is where a line goes; print by default. trace decides whether
onTrace is printed at all, and is false by default — which is the whole of
what a level threshold used to do here.
The constructor is const, and the observer above is not: debugPrint is a
variable Flutter lets you replace, so it is not a constant. const works with
the default output and with a function of your own that is one.
When the observer itself fails
The observer is your code, and the package calls it from a build, from an
initialization and from a teardown. A hook that threw out of one of those would
take the scope with it: a scope that never built its ready branch, or a
teardown that stopped halfway with a scopeKey still held.
So every call goes through a guard. A hook that throws is reported through
FlutterError.reportError with library: 'scopo' — a red screen in debug,
FlutterError.onError in release — and whatever was reporting the event goes
on. Nothing is swallowed, and nothing is retried.
The same guard refuses re-entry. An observer that produces a scope event while it is being notified — one that mounts a scope from inside a hook, say — would otherwise recurse without end; the second notification is refused and reported, once per refused call, and the first one runs to its end.
Timeouts
Four kinds of wait in the scope lifecycle are bounded by a timeout, and all
four defaults live in ScopeConfig:
ScopeConfig.defaultScopeKeyTimeout— how long a scope waits for itsscopeKeyto be released by the previous owner;ScopeConfig.defaultInitCancellationTimeout— how long a teardown waits for the initialization to be cancelled;ScopeConfig.defaultDisposeScopeTimeout— how long a teardown waits fordisposeScope, the scope's own release;ScopeConfig.defaultWaitForChildrenTimeout— how long a scope waits for its child scopes to be disposed of before disposing of itself.
All four are three seconds by default. null removes the limit and every scope
waits indefinitely, and so does ScopeTimeout.none — the value below, which
says the same thing for a single scope and is accepted here too. An expired
timeout is not fatal: it is reported through FlutterError.reportError, and
the scope then proceeds as if the wait had succeeded — so a dependency that
never completes its disposal degrades into a delay plus an error report instead
of a deadlock.
The report is what ScopeConfig.timeoutReportsEnabled = false switches off,
and all it switches off. The wait still gives up on time, the scope still
goes on, the four callbacks are still called, and the observer still hears the
expiry — with the very TimeoutException the report would have carried, so
nothing is lost by taking the reporting over. It is for an application that
reports expiries from its own observer: one expiry arriving twice, once as an
event and once as a Flutter error, is what makes one of the two look like a
second problem.
Two more waits share one of these defaults. The first takes no override of its
own. When the initialization of a Scope fails, the dependency container
releases what it had already built, and that release is bounded by
ScopeConfig.defaultDisposeScopeTimeout rather than by the
disposeScopeTimeout of the scope: a container knows nothing of the widget
that owns it and works without one, so there is nothing there to read a
per-scope value from. It is bounded at all because it is the path of a failed
initialization — nothing downstream sees that failure until the container lets
go, so a disposer that never finishes leaves the scope above showing its
loading branch with nothing on screen and nothing in the console.
The second is the other half of the teardown of a Scope, and it does take the
override. disposeScope is one method there and two steps — the state's own
teardown, and then the container's — and each is bounded by
disposeScopeTimeout of its own rather than by what the first left of the
other. So a teardown where both steps hang reports two expiries and calls
onDisposeScopeTimeout twice: two steps were given up on. Every other family
has one step behind that method and one limit around it.
All four are measured on real time rather than on the clock of the zone the
teardown runs in, which is what a widget test replaces with a fake one. A hang
like the ones they exist for outlives frames, and a scope is usually taken down
between them: a timer belonging to that zone would still be pending when the
tree is gone, and flutter_test ends a test on exactly that. So a test of your
own that has to wait one of these out waits in real time — pump(duration)
moves the fake clock and reaches none of them.
pauseAfterInitialization is the exception, and deliberately: that delay is
one the user sees, so a widget test drives it with pump(duration) like any
other animation. The scope puts it out when it is taken down mid-pause.
Every scope can override all four defaults for itself with the
scopeKeyTimeout, initCancellationTimeout, disposeScopeTimeout and
waitForChildrenTimeout parameters, and observe an expiry through the
onScopeKeyTimeout, onInitCancellationTimeout, onDisposeScopeTimeout and
onWaitForChildrenTimeout callbacks. null there means "take the default",
not "wait as long as it takes" — the second thing is said with
ScopeTimeout.none:
AsyncScope(
waitForChildrenTimeout: ScopeTimeout.none, // this scope waits it out
disposeScopeTimeout: const Duration(seconds: 5),
…
)
It is a Duration of its own kind rather than a value to remember, so every
one of these parameters stays a Duration? and nothing else about them
changes. AsyncScopeCoordinator.waitForChildren and a parent's own
waitForChildren take it too, for one call, and so do the four ScopeConfig
defaults above.
Being a Duration of its own kind is also the one thing to know about it: it
is told apart by its type, so it does not survive being computed with.
ScopeTimeout.none == const Duration(microseconds: -1) is true, and
ScopeTimeout.none + Duration.zero is an ordinary Duration — what comes back
from any arithmetic is the negative length behind the marker, which a timer
reads as "expire at once". The package asserts against a negative limit
wherever it resolves one, so this is loud in debug rather than silent; a
timeout is still a value to pass on rather than one to compute with.
initCancellationTimeout is the one that refuses it, with an assert. A
cancellation waits for the initialization to finish, and an initialization
parked on a future that never completes never does — an unbounded wait there is
the hang the limit exists to prevent. Removing that one is a decision for the
whole application, and ScopeConfig.defaultInitCancellationTimeout is where it
is made — as null, or as the ScopeTimeout.none that means the same thing
there. The assert is raised inside the teardown's own guard for that stage, so
it arrives as onError with ScopePhase.initializationCancellation and the
teardown goes on.
pauseAfterInitialization refuses it too, with an assert of its own, and
for the opposite reason: a pause is a stretch of time to hold the ready branch
back for rather than a limit on a wait, and "wait as long as it takes" has
nothing to say about one. That assert is raised while the scope is becoming
ready, so it arrives as onError with ScopePhase.initialization and the
scope shows its error branch.
pauseAfterInitializationEnabled
pauseAfterInitialization is an artificial delay between the moment a scope
becomes ready and the moment its subtree is shown; it exists to keep a loading
indicator visible long enough to be read instead of blinking.
ScopeConfig.pauseAfterInitializationEnabled = false switches every such pause
off globally, without touching the widgets that declare it. Set it in the setup
of a widget test, or while stepping through an initialization in the debugger.
reset()
The switches above — the pause and the four timeouts — are global and outlive
the code that changed them, so a test that raises a timeout and forgets to put
it back hands the next test a different package. ScopeConfig.reset() puts all
five back to their defaults:
void main() {
tearDown(ScopeConfig.reset);
…
}
setUp works as well, and covers a test that failed before its own teardown
ran. The observer is left alone: it is an object rather than a switch, and it
is usually the whole point of the run it was assigned for. A suite that wants
it gone puts it back itself — ScopeConfig.observer = null.
In tests
An observer that records instead of printing turns the lifecycle into a value a
test can assert on. Compare the whole list at once: that catches a missing
event and one too many alike, which a verify per event does not.
final class RecordingObserver extends ScopeObserver {
final events = <String>[];
@override
void onInitStarted(ScopeObservable target) =>
events.add('init ${target.debugLabel}');
@override
void onInitialized(ScopeObservable target) =>
events.add('ready ${target.debugLabel}');
@override
void onDisposed(ScopeObservable target) =>
events.add('disposed ${target.debugLabel}');
}
void main() {
late RecordingObserver observer;
setUp(() {
observer = RecordingObserver();
ScopeConfig.observer = observer;
ScopeConfig.pauseAfterInitializationEnabled = false;
});
tearDown(() {
ScopeConfig.observer = null;
ScopeConfig.reset();
});
testWidgets('the scope initializes once and is disposed of once',
(tester) async {
await tester.pumpWidget(const CounterScope(tag: 'counter'));
await tester.pumpAndSettle();
await tester.pumpWidget(const SizedBox());
await tester.pumpAndSettle();
expect(observer.events, [
'init CounterScope(counter)',
'ready CounterScope(counter)',
'disposed CounterScope(counter)',
]);
});
}
Two things that expectation depends on. The scope is tagged, because an
untagged one labels itself with a short hash that is different on every run —
tag it, or strip the (…) off the label before comparing. And the observer is
cleared in the teardown, because ScopeConfig.reset() does not clear it: an
observer left behind goes on recording into the next test's list.
Keep trace out of it unless the trace is what the test is about. At that
level a single scope produces a dozen events, and an expectation that lists
them all fails on every unrelated change to the coordination.
See example/minimal for the setup of a real application, and example/scopo_demo for a demo that shows every lifecycle call of every scope family side by side.
Coming from 0.21.x
Four hooks and one route have new names, so that each pair of hooks reads the same way — what began and what finished:
| 0.21.x | now |
|---|---|
onInit |
onInitStarted |
onReady |
onInitialized |
onDispose |
onDisposalStarted |
onDisposalProgress |
onDisposalStepFinished |
ScopeErrorRoute.observer |
ScopeErrorRoute.observerOnly |
The events themselves have not changed. An override marked @override names
the old hook in a compile error; one written without @override keeps
compiling and is no longer called, so search for the old names as well.
A phase of the lifecycle is now called a status, which leaves state to
Flutter's State and to the value of a model. The element of each family is
named after its widget, the context after the inherited widget it reads, and
the step counter after what it does:
| 0.21.x | now |
|---|---|
AsyncScopeState |
AsyncScopeStatus |
AsyncScopeContext.state |
AsyncScopeContext.status |
buildOnState |
buildOnStatus |
AsyncScopeError |
AsyncScopeFailed |
ScopeDependencyState |
ScopeDependencyStatus |
ScopeDependency.state |
ScopeDependency.status |
stateToString() |
statusToString() |
ScopeWidgetElementBase and every other …ElementBase |
ScopeWidgetCoreElement, …CoreElement |
ScopeContext |
ScopeInheritedContext |
ProgressIterator |
ProgressTracker |
Each of these old names is a compile error, so the analyzer points at every place to change. The changelog lists the rest of the breaking changes of this release.
Coming from 0.12.x
A dependency container no longer reports its disposal through onProgress.
The release of each dependency arrives at
onDisposalStepFinished(target, path) instead, and the step it belongs to is
announced ahead of it by onDisposalStepStarted(target, path).
An existing onProgress override keeps compiling, which is the awkward part of
this one: it still overrides a method that still exists — it just stops being
called for the disposal, and nothing points at the change. If yours told the
two halves apart by the type of progress, that branch is now a hook:
// before
final class BeforeObserver extends ScopeObserver {
@override
void onProgress(ScopeObservable target, Object? progress) {
if (progress is String) {
log('released $progress');
} else {
log('built $progress');
}
}
}
// after
final class AfterObserver extends ScopeObserver {
@override
void onProgress(ScopeObservable target, Object? progress) =>
log('built $progress');
@override
void onDisposalStepFinished(ScopeObservable target, String path) =>
log('released $path');
}
ScopeCompositeObserver and ScopePrintObserver come with the package and
were changed with it; an observer of your own is the only thing to look at.
One way this release can fail a build rather than go quiet. Three names are
added to ScopeObserver, and Dart has no overloading: a subclass that already
has a member of its own called onStepStarted, onDisposalStepStarted or
onDisposalStepFinished now declares an invalid override. Rare — they are not
obvious names for a helper — but it is a compile error rather than a change of
behaviour, so the analyzer names it and renaming your member settles it.
Coming from 0.9.x
The nine public names built on logger_builder are gone: ScopeConfig.logger,
ScopeLogger, ScopeLevelLogger, ScopeLog, ScopeLogPublisher,
ScopeLogFormatter, ScopeLogTransformer, ScopeLogLevel and
ScopeLogCallback. The package no longer depends on logger_builder.
What each of them was for:
ScopeConfig.logger.level = ScopeLogLevel.info→ScopeConfig.observer = const ScopePrintObserver();ScopeLogLevel.debug→const ScopePrintObserver(trace: true);ScopeLogLevel.off→ScopeConfig.observer = null;- a publisher that formatted and printed → the
outputofScopePrintObserver, or aScopeObserverof your own; - a publisher that collected the logs into a list to assert on → an observer that records, as under "In tests" above;
- a transformer that dropped the logs of one path → an
ifinside the hook, ontarget.debugLabelor on the type oftarget; - routing failures onward by parsing
ScopeLog.message→onError, with the error, the stack trace and aScopePhasealready separated.
There is no threshold any more, and nothing that takes its place as one value.
Its three jobs are split: ScopeConfig.observer = null turns everything off,
an empty hook body turns off one kind of event, and trace turns off the
noisiest kind.
Classes
- ScopeCompositeObserver debug
- A ScopeObserver that hands every event to each of observers, in order.
- ScopeConfig debug
- ScopeObservable debug
- An object whose lifecycle ScopeConfig.observer is told about.
- ScopeObserver debug
- Hooks called as scopes and their dependencies live and die.
- ScopePrintObserver debug
- A ScopeObserver that writes a line per event.
- ScopeTimeout debug
- The one value a single scope has for "wait as long as it takes".
Enums
- ScopeErrorRoute debug
- Where a failure reaching ScopeObserver.onError goes besides the observer.
- ScopePhase debug
- What was running when a failure reached ScopeObserver.onError.