scopo 0.12.0
scopo: ^0.12.0 copied to clipboard
A Flutter package for managing scopes and dependency injection within the widget tree
0.12.0 #
- Added
controllerDep(name, create), a fourthScopeAutoDependenciesbuilder next todep/sequential/concurrent: a single dependency backed by aScopeController, wired toperformInit/performUnmount/performDispose. The same controller class anAsyncControllerScopeowns now fits, unchanged, as one branch of a dependency tree.
0.11.0 #
-
Breaking:
NavigationNode,NodeNavigatorStateandPreviousNavigatorExtensionhave left this package. They are the navigation_node package now — the same code, the same behaviour, the same six lessons of an example. Add the dependency and change one import:import 'package:navigation_node/navigation_node.dart';Nothing in scopo used the widget, and the widget used nothing in scopo — it imports nothing but Flutter — so a package about scopes was carrying a nested navigator that nobody looking for one would think to find there. The two still go together: a scope over a screen is exactly what a pushed route loses, and a node is what keeps it.
-
Added an accessor object per family —
ScopeAccess,LiteScopeAccess,ScopeWidgetAccess,ScopeModelAccess,ScopeNotifierAccess,AsyncScopeAccess,AsyncDataScopeAccess,AsyncControllerScopeAccess. Each takes the type arguments of its family once and forwards to the statics of the same names, so the five wrappers a scope used to declare become one line:static const access = ScopeAccess<App, AppDependencies, AppState>();. Nothing is deprecated by it — the statics stay, and a scope that wants accessors under its own names still writes them. New section inREADME.mdand in the topicbase. -
@mustCallSuperremoved fromonUnmountof a scope state. It was empty there and in every class between it and the one an application extends, sosuper.onUnmount()did nothing and never had — whiledisposeStateAsyncbeside it, the other half of the same teardown and just as empty, asked for nothing. The two halves now read alike. On an element the hook does carry work —ScopeElementBase.onUnmountdisposes of the dependency container — and there the annotation stays. Nothing to change on the consuming side: asupercall that is no longer required is still allowed. -
Editor templates now ship with the package:
ide/scopo.code-snippetsfor VS Code (and Cursor, Windsurf, Antigravity) andide/scopo-live-templates.xmlfor IntelliJ and Android Studio. Eleven templates — a class skeleton per family, two containers, and the accessor line; each writes every class its shape needs in one paste. The skeletons write the accessors out as statics of the scope, which is the shape to prefer when something else is doing the typing;ScopeAccessis the shape to prefer when you are. Installation is inide/README.md. The skeletons they insert are expanded intotest/ide/, a file each, and compiled by the gate, so a template that stops being valid Dart fails the build.
0.10.0 #
-
Fix
ScopeModel.create(context)running before the element was mounted: the context can now read ancestor scopes withlisten: false, while the model and notifier subscriptions are still ready before the first subtree build. The same mounted-before-build timing now applies to customScopeWidgetElementBase.init()overrides. Synchronous initialization failures stay inside Flutter's build error boundary. Such a failure is terminal: the hook is not attempted again, so nothing it already took is taken a second time, and every later build reports the same failure. Cleanup is symmetrical with it — the element disposer runs for a failed initialization too, so whatever it took before it failed is given back, and family disposers now expect a partially initialized scope.AsyncScopestarts its async phase only after a successful synchronous initialization, and only once; a scope that never got that far registers with no parent scope and itsclose()completes instead of waiting for an initialization that will never begin. -
Subscribing to another scope from an initialization hook —
listen: trueinsideinit()orScopeModel.create()— is now caught by an assertion. The hook runs once, before the first build, so such a subscription could never be honoured; look the scope up withlisten: falseinstead. -
Fix a dependency keeping what it had already taken. A dependency whose initializer acquired a resource and registered its disposer, and only then failed or was cancelled, was skipped by the disposal entirely: the criterion was the state it ended in rather than what it was holding. Whatever was taken is now released either way, once. A dependency in that position therefore reports
disposedrather thancancelledwhen it carried no errors of its own. -
Fix a scope left with no subtree when its own build notified its dependents. A
builderthat touches the model synchronously — a lazy load, a default filled in on first read — notifies while the scope is building, and the flag that notification raises was read by the rebuild already in progress: the subtree was treated as one to keep rather than one to mount, and there was none to keep. In debug this surfaced as two framework assertions naming neither the scope nor the cause; in release the subtree was simply absent. The flag a notification raises and the flag the current rebuild reads are now separate, so a notification can no longer reach into the rebuild that is running. Such a notification is also no longer lost: marking an element that is already building as needing a build does nothing, so the rebuild is asked for once the frame is over, and the dependents hear about the change a frame later instead of never. -
Behaviour change:
dep.unmountnow runs in reverse declaration order within a group, the waydep.disposealready did. The two are halves of one teardown, and reverse order exists because a later dependency is built on top of an earlier one — so having the synchronous half walk forward released the foundation first. Insequential('', [dep('bus'), dep('repo')]), whererepolistens tobus, the bus was unmounted first and the repository was left listening to a source that had already been told to stop. The order was not documented before and is now, in the dartdoc ofsequential,concurrentandScopeDependencyHandle.unmount, and in theScopetopic. -
Fix
unmountbeing skipped for a dependency whose scope failed to initialize, whiledisposeran. The hook is documented to run exactly once and always beforedispose, whichever way the scope goes, but the container reached the element only once initialization had succeeded — so on the failing path nothing unmounted, and a dependency that had registeredunmount = subscription.cancelalongsidedispose = sink.closeclosed the sink and kept writing into it. The container now unmounts itself from inside its own initialization when that initialization did not finish, whether or notautoDisposeOnErroris set: a container kept for inspection still holds the subscriptions it took. A hook that throws there is reported rather than re-thrown, so it cannot replace the failure the initialization is already carrying.unmountis also exactly-once by construction now — each hook is taken off as it runs — so the passes that can reach a container cannot double up on it. -
Fix one failing disposer taking the rest of a sequential group with it. The walk stopped at the first dependency that could not let go, leaving everything below it — already initialized, still holding resources — untouched. Each release is now guarded on its own, the walk finishes, and the first failure is passed upwards afterwards; every failure is recorded on the dependency it belongs to, as before.
-
Fix an initialization that fails synchronously leaving the scope on its loading branch for good.
AsyncScopeErrorwas reached only from the stream's own failures, so a throw raised while the stream was still being built — theAsyncScopeCoordinatorlookup of a scope that asks for ascopeKeyand has no coordinator above it, or aninitScopethat is not a generator and throws — never reached the model: the scope stayed inAsyncScopeWaitingand went on showingbuildOnProgress, which for a missing coordinator is the commonest mistake in the tree. It now showsbuildOnError, as the state table always said it would, and the failure is still reported as before. The state is applied outside the frame, because such a failure is raised before the firstawaitand therefore inside the build that started the initialization. -
Subscribing to a scope from anywhere but a build —
selectorof(listen: true)indidChangeDependencies, say — is caught by an assertion. What a dependent asked for is remembered per build, and the boundary between builds is taken from the frame, so a registration made outside a build belonged to whichever build shared its frame and was dropped by the first one that did not: it looked like it worked, and then stopped, on the first rebuild that came from the parent rather than from a change. Keep the subscription inbuild, and read withlisten: falsefromdidChangeDependencieswhen the point is to react rather than to show. -
Progress.valueis a fraction between 0 and 1 whatever it was built from, which is what a progress indicator is handed. A task of no steps at all used to give theNaNof0 / 0, and a release build that stepped past the total gave4/3; the first is now 1 and the second is clamped.Progressalso refuses a negativenumberortotalby assertion, and the assertion inProgressIterator.addStepssaystotalwhere it used to saycount. The dartdoc example ofProgressIteratorcompiles again: it showed a namedcount:argument the constructor never had, and aProgressValuetype that does not exist. -
StateAsNotifierlets go of its notifier indisposeinstead of merely disposing of it, and takes no listener after the state is gone. A callback that outlived the state — a stream event, a timer — used to reach a disposedChangeNotifier, which answers with "A ChangeNotifier was used after being disposed" in debug; a lateaddListenerwas worse, since in release the listener was then held for good by a notifier nobody would ever notify. -
SchedulerBinding.isBuildingalso sees a build that runs with no frame in progress — which is howrunAppbuilds the first tree.markNeedsBuildfrom inside one of those is refused just as it is inside a frame, sorunOutsideFramenow holds the action back there too. The build owner keeps that flag behind an assertion, so in a release build the scheduler phase is still all there is to go by. -
Fix
SchedulerBinding.isBuildinganswering differently in release, andrunOutsideFramerunning an action inside the build it was keeping it out of. The build owner's flag exists only inside anassert, so a release build had the frame phase alone to go by, and a build driven with no frame in progress — the wayrunAppbuilds the first tree — looked there like no build at all. Marking an element dirty from inside a build is refused in silence, so anAsyncScopewhose initialization failed before its firstawaitshowed an empty subtree in release where debug showedbuildOnError. The scopes of this package now count their own rebuilds in a plain field, which release builds keep, andisBuildingreads it. What remains beyond reach is a build that belongs neither to a frame nor to this package; theutilstopic says so. -
A
NavigationNode.onPopthat returns a failingFuture— a confirmation dialog that raised, usually — is reported throughFlutterError.reportErrorinstead of surfacing as an unhandled zone error far from the widget that caused it. The press is simply not acted on, and the next one is asked as usual. What the answer sets off is now held the same way. Acting on atruemeans asking the route what a pop there would do and then popping, and both read user code — a guard of the application's own, a callback the route makes as it goes. A failure in either was left in a chain nobody holds, because the previous fix answered for the question and not for what followed it. A node also no longer stays stood aside when that reading falls over: it steps aside for the length of one read, and a raise in the middle of it used to leave it aside for good, after which every later press took the whole route without the node being asked at all. -
NavigationNodehands its nested navigator the same page list across a rebuild that changes nothing.NavigatorStatecompares the list it is given by identity, so a fresh one on every build had it diff its stack and report it again — twice per rebuild, to every listener above the node — for a page that had not changed. WhilechildandisRootare the same objects, nothing is handed over anew. -
[breaking changes] The Flutter floor is
>=3.27.0, down from the>=3.29.0raised earlier in this same release. Nothing here ever needed 3.29 — the floor went up becauselogger_builder, then a dependency, asked formeta ^1.16.0while theflutter_testof 3.27 pinsmetato 1.15.0. Its 0.6.1 relaxed that to^1.15.0, which is what let the floor back down; later in this same release the dependency goes away altogether, so nothing external is left to push the floor up again.fake_asyncandleak_tracker_flutter_testingare asked for a patch lower,^1.3.1and^3.0.8, the versions 3.27 pins; both are dev dependencies, so nothing a consumer resolves changed except the floor itself.sdk: ^3.6.0is untouched and now reaches something real: 3.27.0 carries exactly Dart 3.6.0, so the language version the formatter reads is the floor's own and not one above it. Tests, formatter, dartdoc, publish dry run, translations and both example suites are run on 3.27.0, and CI pins the same number. -
[breaking changes] Renames, the first of several waves. Nothing here changes behaviour: each is a name that said the wrong thing or said it differently from its neighbour.
ScopeConfig.defaultScopeKeysTimeoutisdefaultScopeKeyTimeout, singular like thescopeKeyandscopeKeyTimeoutit answers for and like the three settings beside it. The three sealed bases of the dependency states drop a plural that described the hierarchy rather than the object:ScopeDependencySuccessStates,ScopeDependencyFailedStatesandScopeDependencyCancelledStatesareScopeDependencyAnySuccess,ScopeDependencyAnyFailedandScopeDependencyAnyCancelled, which is how they read at a call site —state is ScopeDependencyAnyFailed.DepHelperisScopeDependencyHandleand has a file of its own: everything else in that family spellsDependencyout, and "Helper" said nothing about the handle it is.ScopeInitFunctionisScopeInitCallback,Callbackbeing the one suffix this package uses for the idea, andScopeInitBuilderisScopeProgressBuilderafter the state it builds for.Progress.progressisProgress.value— a fraction inside a class already calledProgressexplained itself worse than the pairnumber/totalbeside it — andScopeAutoDependenciesProgress.progressfollows it.ProgressIterator.addisaddSteps, the wordnextStepandcurrentStepalready use. -
[breaking changes] The branch built while a scope initializes is named after the state it belongs to, in both halves of the API: the parameter
initBuilderisprogressBuilderand the methodbuildOnInitializingisbuildOnProgress, in every family.initandinitBuildersat in one constructor and read as a pair -- the work and its builder -- while the second is the screen shown while the first runs; the state it answers isAsyncScopeProgress, and so now is its name.buildOnWaitingandbuildOnReadyalready worked that way. The entries above that named the old spelling were rewritten to the new one: 0.10.0 has not shipped, so the section speaks the vocabulary the release will have. Released sections are untouched. -
[breaking changes] The lifecycle hooks say what they initialize and what they release, on both levels, because one name meant two things:
initAsyncwas the scope's own initialization onAsyncScopeBaseand the state's own onLiteScopeState, anddisposeAsyncwas both teardowns at once. The scope's half isinitScopeanddisposeScope—LiteScope.init()joins them under the first name — and the state's half isinitStateAsyncanddisposeStateAsync, whereAsyncstill earns its place beside Flutter's synchronousinitState.onUnmountkeeps its prefix on both: it is called when the scope goes, and it is not what takes it away.initData,disposeData,initDependenciesandcreateControllerare unchanged — they already named what they prepare. The timeout that bounds the scope's teardown follows the method:disposeAsyncTimeoutisdisposeScopeTimeout,onDisposeAsyncTimeoutisonDisposeScopeTimeout, andScopeConfig.defaultDisposeAsyncTimeoutisdefaultDisposeScopeTimeout.initCancellationTimeoutis untouched: it is named after an event rather than after a method. -
[breaking changes] A parameter of
AsyncScope,AsyncDataScopeorAsyncControllerScopenow carries the name of the method it stands in for:onMount,initScope/initData,onUnmount,disposeScope/disposeData,createController. The builders keep the shape they had —waitingBuilder,progressBuilder,errorBuilder,builder— besidebuildOnWaiting,buildOnProgress,buildOnErrorandbuildOnReady. The callbacks are constructor parameters only now: a field cannot share a name with the method it implements, so each is held in a private field. Nothing in this package, its tests or its examples read them from outside, and the analyzer says so at once for anyone who did. -
[breaking changes]
ScopeDependencystops offering two ways to run one step. The interface hadinit()besiderunInit()anddispose()besiderunDispose(), the second of each wrapping the first and keepingstatein step; the bare pair was an implementation detail with the shorter, more inviting name. The wrappers areinit()anddispose()now — the names a caller expects — and the step itself is private to the library. Nothing outside called the bare pair, here or in the tests. -
A scope owning a controller has a context of its own.
of,maybeOfandselectofAsyncControllerScopeandAsyncControllerScopeBasereturn anAsyncControllerScopeContext, which answerscontroller,controllerOrNullandhasController— the controller used to be read asdataoff anAsyncDataScopeContext, a type named after another family and a member named after a payload. This is an addition: the new interface implements the old one, sodata,dataOrNullandhasDatastill answer the same object. -
[breaking changes]
ScopeStateNotifier.equalsisshouldNotify, and its sense is inverted: it returnstrueby default, whereequalsreturnedfalse, andupdatenotifies when it says so rather than when it does not. The old name promised a comparison and denied every equality — including an object with itself — while what it decides is whether the listeners hear about a change;CompareUtils.equalsbeside it is the honest one. An override written with@overridestops compiling, which is the point; one written without it does not, so check for it before upgrading. -
The dartdoc of
AsyncControllerScopeBasesays why itsbuildOnProgressandbuildOnErrortake no progress where the other three families do: this scope's initialization iscreateController()and the controller's owninit(), and neither reports steps. The reason was a comment in the implementation, where the person reading the signature never sees it. -
Add
ScopeConfig.observer: an application assigns a typedScopeObserverto hear about a scope's lifecycle. Nine hooks, every one of them empty by default —onInit,onProgress,onReady,onCancelled,onDispose,onDisposed,onError,onTimeoutandonTrace— so a subclass overrides only what it needs; the first argument of each is aScopeObservable, carrying thedebugLabelits source names itself by. The field isnullby default, so the package says nothing until one is assigned. The structural paironInit/onDisposedlives onScopeWidgetElementBase— the ancestor of every scope family — so a family that never reported anything of its own now reports through the observer the same as the rest. A hook that throws is reported throughFlutterError.reportErrorrather than reaching the scope, and a nested notification — an observer producing a scope event of its own from inside a hook — is refused rather than left to recurse. -
AsyncScope's own lifecycle now reports throughScopeConfig.observertoo — and with it every family built on the same element:AsyncDataScope,AsyncControllerScope,LiteScopeandScope.onProgress,onReady,onCancelled,onDispose,onTimeoutfor an expired wait, andonErrorfor each phase that can fail (initialization, its cancellation, preparation for disposal,onUnmount, disposal, a wait nobody was left to hear the end of). A family that runs its own initialization reports it in place of the structural pairScopeWidgetElementBasefires for the rest, rather than alongside it —ScopeWidgetElementBase.reportsOwnLifecycle,falseby default,trueonAsyncScopeElementBase— soonInit/onDisposedare not doubled forLiteScopeandScope, whose defaultinitScope()runs the exact streamAsyncScope's does. The coordination below the lifecycle — thescopeKeyqueue, cancelling an initialization, waiting for children — reports throughonTrace. -
The dependency container and a single dependency now report through
ScopeConfig.observertoo, the same eleven points that used to log:ScopeAutoDependenciesreportsonInit,onProgress(aScopeAutoDependenciesProgresswhile it initializes, the bare path of each step while it disposes),onReadyoronCancelled,onDispose,onDisposed, andonErrorfor each phase that can fail here —onUnmount, an abandoned wait for its own disposal, and disposal itself — plusonTimeoutfor that same abandoned wait giving up. ItsdebugLabelisT(#hash), the shape itsloggername already had; a single dependency's is its ownname. The two diagnostic lines insideScopeDependencyMixin— handling an error, handling one after a cancellation — report throughonTraceinstead, the error folded into the message rather than kept as a separate field.runStreamGuarded, which every dependency'sinit()anddispose()run through, is internal and not exported — it has noScopeObservableof its own either, being a function rather than an object — so it gained an optionalobservableparameter: its two callers inside the package pass their own, and its seven internal steps report throughonTraceunder that label. -
Breaking:
logger_builderis no longer a dependency. Nine public names built on it are gone:ScopeConfig.logger,ScopeLogger,ScopeLevelLogger,ScopeLog,ScopeLogPublisher,ScopeLogFormatter,ScopeLogTransformer,ScopeLogLevelandScopeLogCallback. In their place, a ready-madeScopePrintObserver:ScopeConfig.observer = const ScopePrintObserver();prints one line per event —scopo | <label> | <what happened>, a failure's error and stack trace included — the same shapeScopeLogger.defaultFormatwrote, minus the level and the logger path. A failure line spells out its phase as English instead of the bareScopePhasename —preparation for disposal failed, notpreparationForDisposal failed— the same wordingScopeLogger.defaultFormatused for the same six phases; the one phrase that does not end in "failed",an abandoned wait ended in a failure, is the old logger'san abandoned wait for $what ended in a failurewith the$whatleft out, since the observer has none to put there.trace: truealso prints whatonTracecarries, off by default: that is where the coordination below the lifecycle reports, and a scope produces a dozen such lines where it produces one of the rest. -
Fix a failure that outlives its scope reaching
ScopeConfig.observeras a failure of the observer. A bounded wait that expired is abandoned rather than forgotten, and the work behind it reports throughonErrorwithScopePhase.abandonedWaitif it falls over later — by which time the scope has given its widget back, so readingtarget.debugLabelraised aTypeErrorinside the hook and the guard reported that instead. The teardown now keeps the label it had while it still had a widget, so the three calls that can reach a consumer after it is over — the scope's owndisposeScope, the cancellation of its initialization, and anAsyncControllerScopegiving back a controller — deliver the failure they were written to deliver. -
Behaviour change:
ScopeObserver.onDisposeandScopeObserver.onDisposednow always come as a pair.onDisposeis sent by every teardown, not only by one that follows a successful initialization, so a scope taken down while it was still loading no longer reports the end of a teardown nobody was told had begun; andonDisposedis sent even when the teardown failed, after theonErrorthat says so rather than instead of it. Separately each half was defensible; together they made the pair unreliable in both directions, and a consumer that counts with it — a leak counter, a span tracker — got it wrong either way.onCancelledis documented as what it is: a scope cancelled while still queued for itsscopeKeysends it without anyonInitbefore it, because it never started an initialization of its own. The structural pair joins the same guarantee: a family with no initialization phase of its own —ScopeWidget,ScopeModel,ScopeNotifier,AsyncScopeCoordinator— used to sendonDisposedalone, with noonDisposebefore it. It now sends both, and only for an element whoseinit()actually succeeded: one that threw, or never ran, reports neither half — the opposite of the phase-reporting families above, where the pair can still close a teardown that opened with noonInitat all. -
Fix a synchronous initialization failure being invisible to
ScopeConfig.observer.ScopeWidgetElementBase.build()catches whatinit()threw, records it and raises it again into Flutter's build error boundary — which puts anErrorWidgetin the subtree but tells the observer nothing. A structural family therefore reported no event at all for such a scope: noonInit, and by the rule above no teardown pair either. A family with a phase of its own fared no better, since that phase is started only for aninit()that returned. The hook now sendsonErrorwithScopePhase.initializationbefore it raises the failure again — the one point both kinds of family pass through; for a structural scope that single event is its whole recording. -
Breaking: add
ScopePhase.build, and report a failing build throughScopeConfig.observer.buildOnReady,buildOnProgress,buildOnError,buildOnWaiting,buildOnClosing,ScopeWidgetBase.buildandScopeModel.buildall run fromScopeWidgetElementBase.build(), whose failure Flutter's build error boundary answers with anErrorWidget— what the subtree shows, not what the observer hears, the same split a failinginit()used to fall into. This was the last family of user code the observer could not hear; every other lifecycle hook already reported. The report is added to the raise rather than put in its place: unlike a teardown with nobody left to hand a failure to, a build has a caller, and theErrorWidgetit draws is unchanged. A widget that is not a scope —NavigationNode,ListenableSelector, the views ofScopeNotifier— has nodebugLabelto be named as the target, so its build is not covered.ScopePhasealready asked aswitchover it in your own code to carry adefaultbranch; one written without it stops compiling. -
Fix an
AsyncControllerScopefailing to give back a controller its own initialization never handed over, and tellingScopeConfig.observernothing about it. That release runs from thefinallyof the initialization, where a raise would replace the failure that actually broke the scope, so its own failure was reported throughFlutterError.reportErroralone — while the expiry of the very same wait already arrived asonTimeoutwithits controller to be released. An observer therefore heard about a release that ran too long and nothing at all about one that failed. It now also sendsonErrorwithScopePhase.disposal, the phase the ordinary teardown uses for the same kind of failure; theFlutterErrorreport is unchanged. -
Fix a failing
onUnmountreachingScopeConfig.observeron one of the two paths out of a scope but not the other. The hook runs fromunmountScope(), and bothScopeWidgetElementBase.unmount()and the asynchronous teardown call it — whichever gets there first does the work, and the other finds it already done. The asynchronous one guards it and sendsonErrorwithScopePhase.unmount; the element's did neither, so a scope the tree took away reported nothing while a scope that closed itself reported the failure. The framework still showed it, as an error of the widget tree rather than an event of the scope. Both paths now report it — and the element's path no longer raises it afterwards; see the entry about a batch of scopes below. -
Fix the second of two simultaneous teardown failures of a
ScopereachingScopeConfig.observerthrough neither channel. The state is torn down before the dependencies and each half is guarded on its own, so both can fail; only the first can leave through the throw, and the second went toFlutterError.reportErroralone. It now also sendsonError— withScopePhase.unmountorScopePhase.disposal, whichever half it came from. This is reachable with a dependency container written by hand againstScopeDependencies: the built-inScopeAutoDependenciesreports what its own children throw and never hands a failure to the scope above it. -
Fix a
ScopeModelwhosedisposecallback throws tellingScopeConfig.observernothing about it. The callback runs from the element's own disposer, whichunmount()calls from afinally— outside the guard around theunmountScope()beside it — so the failure went to the framework and no further, and theonDispose/onDisposedpair closed around it as if the teardown had gone through. It now sendsonErrorwithScopePhase.disposalbefore the failure is raised at the caller as before. With this, every point where a scope hands control to your code on its way in or out reports through the observer; abuildOnReadyand its siblings still do not, because a build belongs to Flutter's own error boundary, which answers it with anErrorWidget. -
All six bounded waits now report an expiry through
ScopeConfig.observer. Four already did; the wait for ascopeKeyand the wait for child scopes reported only throughFlutterError.reportErrorand the scope's ownonScopeKeyTimeout/onWaitForChildrenTimeout, which is where they were before the observer existed.onTimeoutnames themaccess to its scopeKeyandits child scopes. The wait for the children reports fromAsyncScopeParent.waitForChildren, the one point all three ways of asking for it pass through — a scope's own teardown, a parent asked directly, andAsyncScopeCoordinator.waitForChildren— and it reports even when you pass anonTimeoutof your own: a callback replaces theFlutterErrorreport, not what the package says about itself. -
Breaking: the
AsyncScopeParentmixin now implementsScopeObservable, so a parent of your own has to answerdebugLabel. That is what lets an expired wait for the children reach the observer from the mixin, under the same label the rest of the scope's events carry. Every element of the package already answered to it. -
Add
ScopeTimeout.none: the one value a single scope has for "wait as long as it takes". Every timeout parameter is aDuration?and all three of its values were taken — absent ornullmeans "take the default fromScopeConfig",Duration.zeromeans "expire at once", and any otherDurationis the limit — so removing a limit was possible only for every scope at once. Accepted byscopeKeyTimeout,disposeScopeTimeoutandwaitForChildrenTimeout, on the scopes and on bothwaitForChildrenhelpers, and by all fourScopeConfigdefaults, where it says for the whole application whatnullsays there — including the release of a dependency container after a failed initialization, which readsScopeConfig.defaultDisposeScopeTimeoutand takes no per-scope override. It is a subtype ofDurationrather than a magic value, so the parameters keep their type and no existing call site changes; the package tells it apart by its type rather than by==, so aDurationsome arithmetic happened to make negative is not mistaken for it. Such aDurationis refused instead, with an assert, everywhere a limit is resolved: a wait cannot be bounded by a length of time that has already passed, and a timer given one expires on its first tick. -
initCancellationTimeoutandpauseAfterInitializationrefuseScopeTimeout.none, each with an assert, and for opposite reasons. A cancellation waits for the initialization generator to run out, and one suspended on a future that never completes never does — an unbounded wait there is the hang the limit exists to prevent; removing that limit stays a decision for the whole application, throughScopeConfig.defaultInitCancellationTimeout, which takesnullorScopeTimeout.nonefor it. A pause refuses the marker because it is not a limit on a wait at all but a stretch of time to hold the ready branch back for, and "wait as long as it takes" has nothing to say about one. -
Fix an anonymous dependency group — the common shape for the root of a tree,
sequential('', […])orconcurrent('', […])— reporting throughScopeConfig.observerunder an empty label instead of[group].ScopeDependencyMixin.debugLabelfell back toname, empty by design for such a group, so a line about it printed with a doubled space where the label should have been;[group]is the same fallbackwrappedNamealready used for the same case, and a named group still reports under its own name, unwrapped. -
The bookkeeping that decides which build a dependent's selectors belong to asks for a frame when nothing else will bring one. The reset runs in a post-frame callback, and a build is not always inside a frame —
runAppbuilds the first tree outside any — so with no frame to come the flag would stay raised and every dependent from then on would add its selectors to a pass that never ends. Asked for only when the scheduler is idle: from inside a frame it would order one more, empty, after every frame that built anything. -
AsyncControllerScopeCorehasmaybeOf,ofandselectof its own, like the twoCorelayers below it. Statics are not inherited in Dart, so a family built on this layer had to reach forAsyncDataScopeCore.maybeOfwith its own type arguments — which works, and is not something to have to find out. -
The placeholder
childeveryScopeInheritedWidgetcarries says which mistake it is instead of raising a bareUnimplementedErrorfrom inside the framework. A scope builds what it shows throughbuildChild(); that placeholder is only reached when a family returns achildnobody passed. -
Dartdoc corrections, all of them about promises rather than behaviour:
AsyncDataScopeandAsyncControllerScopenow say what their twinAsyncScopesays aboutscopeKey, about each expiry callback, aboutpauseAfterInitializationand about what the builders receive;ScopeDependency.countno longer claims to include the group itself (it counts the steps a subtree reports, and a group reports none);AsyncScopeModelis the third type argument ofScopeModelCore, not of the two-parameterAsyncScopeCore; and thedisposecomment no longer justifies itself with "a failed leaf is never disposed of at all", which stopped being true when a failed leaf started giving back what it had taken. -
An expired
AsyncScopeParent.waitForChildrennames the scope after its widget, which is the name carryingtag, instead of after its element, which is a type and a hash. The two neighbouring waits —AsyncScopeCoordinator.waitForChildrenand a scope's own teardown — already did. A parent of your own can say what it is called by overriding the newAsyncScopeParent.reportName. -
The log line about giving up a place in a
scopeKeyqueue names the key the initialization took rather than reading thescopeKeygetter again. The getter is your code, and a message resolved lazily would have run it from a teardown that has already begun. -
The diagnostic about a
scopeKeythat changed no longer describes a queue that does not exist. A scope reads its key before it looks for a coordinator, and that lookup is the one step which can fail with the key already read — such a scope entered nothing, and the message said it was "holding [k] in the queue of no AsyncScopeCoordinator". -
A
ScreenshotReplacer.onCompletedthat raises is reported throughFlutterError.reportErrorinstead of being left where nobody is waiting. The callback is called from the capture, which runs as an unawaited future in a post-frame callback, so a raise there surfaced as an unhandled zone error far from the widget; called fromdispose— the last resort, when no capture ever succeeded — it came out ofState.disposeand took the unmount with it. The report stays one-shot either way.disposealso releases the capturedui.Imagebefore telling the application anything, so what the state holds no longer depends on what the callback does. -
Breaking change:
NodeNavigatorStatecan no longer be constructed. The type stays public — it is what aGlobalKey<NodeNavigatorState>()is made of and what it resolves to — but one built by hand and installed under an ordinaryNavigatorwould fail on its first pop rather than at the line where the mistake was made: it reads the node from the widget aNavigationNodebuilds. Only that widget can make one now. -
NodeNavigatorStateis documented rather than hidden with@nodoc. It is the type aGlobalKey<NodeNavigatorState>()is made of, soNavigationNode.navigatorKeycould not be written without naming it. -
ScopeController.performInitkeeps the promise the family makes about it. It raninit()every time it was called and paid no attention to whether the controller had already been let go of, so a second call — or one afterperformDispose()— re-mounted a disposed controller and initialized it against fields itsdispose()had already released. The threeperformmethods are a one-way sequence now, as the documentation always said. -
ScopeController.performDisposeno longer tells a second caller that a teardown still running is over. It marked the controller disposed of before awaitingdispose(), so a concurrent second call returned at once and reported success — including when the run it was reporting on went on to fail. Every caller now joins the one run and receives its outcome, the wayLiteScope.close()already did. -
ScopeConfig.reset()puts the pause switch and the four timeout defaults back where they started. They are global and outlive the code that changed them, and until now every suite saved and restored them by hand — a convention, and one a test that forgot it could break for its neighbours.ScopeConfig.observeris left alone: it is an object rather than a switch, and it is usually the whole point of the run it was assigned for. The dartdoc ofpauseAfterInitializationEnableddescribed what setting it tofalsedoes while documenting a field whose value istrue; it now says what the field is. -
AsyncDataScopeContext.hasDatasays whether the initialization has produced its value. For a nullableTnothing else could:dataOrNullisnullon both sides of the moment the value arrives, sincenullis a value the initialization may legitimately produce, and the flag the family kept for exactly this was consulted only bydata. The dartdoc ofdataand ofunmount/onUnmountnow also says when the value starts being there — a shade before the scope shows its ready branch, and deliberately much earlier than that whenpauseAfterInitializationis set. -
A second
AsyncDataScopeReadyno longer replaces the value behind the model's back. The value is caught in themapthe family wraps the initialization in, which runs as the event goes past, while the check for a second initialization sits one layer up in anasyncMap, which runs after it: by the time the diagnostic was raised the new value had already been stored, the model stayed as it was, the dependents heard nothing,datahanded out the newcomer — and the value the scope had been given was left with nobody to release it. The secondreadyis refused where the value is caught. -
AsyncScopeModelis documented rather than hidden with@nodoc. It is whatAsyncScopeElementBase.modelreturns, and the model type the family fixes on the layer below —ScopeModelCore<W, E, AsyncScopeModel>— so a scope written on theCorelayer has to name it. -
AsyncScopeElementBase.modelis one object instead of a fresh wrapper on every read.state,isInitialized,hasError,error,stackTrace,buildChild()and every run of every selector go through it. -
ScopeDependencyNoDisposalRequiredis a state a dependency can actually reach. A dependency that set nodep.disposehas nothing to give back, so its group passes it by — rightly — but it was passed by in silence and went on sayinginitializedafter the whole tree had been torn down, which made the dump of a fully disposed scope read as though half of it were still alive. The state that exists for exactly this was created nowhere. -
A lookup with
listen: truethat finds no scope is remembered as an unsatisfied dependency, the way Flutter's owndependOnInheritedWidgetOfExactTyperemembers one. A widget that asked when there was no scope above it and is later carried under one by aGlobalKeyis now told its dependencies changed, instead of going on showing what it read when there was nothing to read. -
State.widgeton a scope state throws anUnsupportedErrorthat says why, and is no longer marked@visibleForTesting— an annotation that read as "use this from tests" where the meaning is "there is nothing here to use". A scope state has no widget of its own;paramsis the scope widget. -
Fix
LiteScope.wrapStatedoing nothing. The hook is documented as the way to put a widget around the ready branch alone, andScopehas always honoured it, but the element behindLiteScopenever called it: whatever it returned was thrown away, and the ready branch was built unwrapped. -
Fix a root
NavigationNodeforwarding a pop after all.isRootsays the node keeps a pop to itself, andNodeNavigatorState.pophonoured it, but the system back reaches the navigator above by another path — and there the promise held only for as long as nobody wrote anonPop. A root node whose hook allowed the pop took the route below it; a root node placed ashometook the last route of the application's own navigator and left a blank screen. The hook is still asked, since that is where an application decides what its own outermost back means, but atrueno longer leaves the node. -
Fix an ordinary
NavigationNodeon the first route emptying the application's own navigator. The fix above was made forisRootand stopped there, while the line it left in place — a plainpopon the navigator above — takes the last route that navigator holds without asking whether it is the last. A node placed ashomewith anonPopthat allowed the pop therefore left a blank screen, and an assertion of the framework on the frame after it. -
Fix that same forwarding walking past a
PopScopethe application put around the node: a route the application guarded, and refused a pop for, was taken anyway. Both defects are one line, and the node now asks the route it stands on what a pop there would do instead of telling it. It asks with its own answer stood aside for the length of the question — the node's own entry is registered on that very route and has already had its say — so what is left is what the node has no business answering for itself: the application's guard, and whether there is a route to give up at all. Asking rather than telling is also why an application still hears one press as one: amaybePopof the node's own would report a second refusal to everyPopScopeon that route. -
Fix system back taking the whole route instead of closing a
Drawer, ashowBottomSheetor anything else aNavigationNode's page opened withaddLocalHistoryEntry. None of those change a navigator's stack, and nothing announces them —addLocalHistoryEntryends inchangedInternalState, which marks the route dirty and dispatches no notification — so the node was deciding from an answer worked out before the drawer opened, and the answer said the press was none of its business. The node was taking away what works without it. It now works the answer out when the framework asks for it, by registering aPopEntryof its own instead of building aPopScope. -
Behaviour change: the node's first page no longer carries a
LocalHistoryEntryof the node's own. It was there to draw the back arrow of anAppBaron that page and to route amaybePopout of the node, and it cost the node the ability to tell it from a drawer's entry — a route reports only whether its local history is empty. The page saysimpliesAppBarDismissalfor itself now, andNodeNavigatorState.maybePopleaves the node when the node has nothing of its own to close, which is what the arrow presses.NodeNavigatorState.canPop()therefore stopped overridingNavigatorState.canPop(): with no marker of the node's own in the way, the base answer is the true one, and it used to answerfalsewhile a drawer was open. -
Fix a
pauseAfterInitializationoutliving the tree. The delay was a timer nobody held, so a scope taken off the tree mid-pause left it running: in a widget test that isA Timer is still pending even after the widget tree was disposed, which fails a test of yours for no reason of yours, and in production it is an unmounted element held for the rest of the pause. The scope keeps the timer now and puts it out first thing in the teardown. It is still a timer of the current zone, unlike the bounded waits below — this delay is one the user sees, so a widget test must be able to drive it withpump(duration). -
Fix the wait for a
scopeKeyand the wait for the child scopes taking their timers from the current zone. Both usedFuture.timeout, which does, and both are waits on a hang that outlives frames — a scope is usually taken down between them, so the timer was still pending once the tree was gone, and that is whatflutter_testends a test on. The other two bounded waits of the teardown had already been moved to the root zone for this reason; these two are the same kind of wait and had been missed. A consequence for tests: a wait of any of the four is now waited out in real time, andpump(duration)reaches none of them. Thedebugtopic says so. -
Fix an initialization stream that ends without
AsyncScopeReadyleaving the scope on its loading branch for good, and silently. The model stayedAsyncScopeWaiting,disposeScopewas never called, and the only trace was aninfoline in a logger that is off by default — nothing on screen and nothing in the console, which is the hardest kind of failure to look for. The scope now moves toAsyncScopeErrorwith aStateErrorsaying what a stream is expected to end with, sobuildOnErrorbuilds and the report is loud. This reaches every family:AsyncDataScope,AsyncControllerScopeand theScopecontainer all initialize through the same subscription. -
Fix the same silence coming from the other side:
ScopeAutoDependenciesawaited the disposal of its half-built tree with no limit, from thefinallyof its own generator. Nothing downstream sees the failure of an initialization until the generator finishes, so adep.disposethat never completed held not only the resources but the failure itself, and the scope showed its loading branch for ever. The wait is now bounded byScopeConfig.defaultDisposeScopeTimeout, the way every other wait in the teardown is, and giving up is reported rather than passed over; a release that fails after it was abandoned is reported too. TheAsyncScopetopic now says what anawaitin a hand-written guard costs when it cannot finish. -
Fix a controller left unreleased when its
init()woke up after the teardown was over. An initialization parked on a future cannot be cancelled — cancelling anasync*means resuming its body, and a body suspended for good is never resumed — so the teardown gives up on it afterinitCancellationTimeoutand runs to the end. If that future ever completes, the generator is resumed with itsfinallystill holding the controller to release; by then the element has given back everything it held, the widget among it. ReadingdisposeScopeTimeoutthere went through that widget and raised a_TypeErrorwhere a release belonged, so the console got a report about a null instead of the release the family promises on every path. An abandoned release now runs unbounded, which is what it deserves: nobody is waiting for it and it can hold nothing up. -
LiteScope.buildOnWaitingis documented as the required builder it is, and the two optional ones say what happens when they are left out. The dartdoc read like that of an optional method — "may returnnull" — while the method is abstract, which is the wrong half of the story to tell first. The rule across the families is one: exactly one branch before the ready one has to be written, and it is the one that family is certain to reach. AScopealways initializes a container, sobuildOnProgressis its required one; aLiteScopeinitializes nothing of its own, so the branch it always has is the wait. TheLiteScopetopic now says so, and says what moving a screen fromScopetoLiteScopetrades for what. -
A
LiteScopethat overridesinit()and forgetsbuildOnProgressorbuildOnErrorgets anUnimplementedErrorthat names the scope and the method instead of a bare one that names nothing. The error branch carries the failure it was called for as well: without it that failure was replaced on screen by the missing-builder error, and the reason the initialization failed at all went with it. -
The
AsyncScopetopic showedhasChildren,childrenCountandwaitForChildrenon ascopea reader has no way of getting hold of: the mixin that carries them sits on the element, and the elements of the five built-in families are private, whileAsyncScope.ofhands back anAsyncScopeContextthat has none of them. The topic and the dartdoc ofAsyncScopeParentnow say who those three are for — a family of your own, reading them onthis— and what a subtree asks instead:AsyncScopeCoordinator.waitForChildren, which awaits the scopes registered with the nearest coordinator rather than the children of one scope. -
Behaviour change:
ScopeStateWithErrorNotifier.updatenow puts down a failure the model was holding._errorused to be set once and never cleared, while the inheritedupdatewent through as usual: it replaced the state and notified everybody, andstatewent on throwing the old failure at every listener that came to read it. Under a scope that is one attempt at recovery turning a whole subtree intoErrorWidgets — the selector throws, the dependent is rebuilt, itsbuildreadsstate, and so on. A state handed over is a state that can be read, so the failure goes with the same call; the listeners hear about it even when the value is the one from before the failure, sinceshouldNotifyweighs one value against another and this change is between a state that throws and one that does not. TheScopeNotifiertopic says so. -
The dartdoc of
of,maybeOfandselectonScopeandLiteScopepromised one condition where there are two. Both read the state, and the state exists only in the ready branch — so a scope that is waiting for itsscopeKey, initializing, failed or closed makesofthrow andmaybeOfanswernulljust as an absent scope does. A caller reading the promise as written looked for a scope that was there all along. The two conditions are now spelled out, together with the one difference between the two lookups:ofsays which of them it was,maybeOfcannot. -
Fix every failure of a teardown but the first being lost. The disposal of an asynchronous scope runs in four stages, each guarded on its own so that a failure in one never skips the ones behind it, and only the first of them can be passed on — a throw carries one failure. The rest went to an
info-level logger that is off by default, which is the same as losing them: a scope whose wait for its children expired and whose owndisposeScopethen fell over said nothing at all about the second failure. Every failure behind the first is now reported throughFlutterError.reportError, which is the trade the rest of the teardown already makes for failures it cannot hand to a caller. -
Fix the first failure being lost in the two halves of a
Scopeteardown.ScopeState.onUnmountruns beforeScopeDependencies.onUnmount, andScopeState.disposeStateAsyncbeforeScopeDependencies.dispose; in both pairs the state's failure was kept in a local while the container's was left to throw over the top of it, so the first — the one that explains what the second made of the same teardown — vanished without a trace. Both halves are now guarded apart: the first failure leaves through the throw, the second through a report. This shows only with a hand-writtenScopeDependencies:ScopeAutoDependenciesreports what its own children throw and never hands a failure up. TheScopeandAsyncScopetopics say what the order costs. -
The dartdoc of the twelve timeout parameters said the opposite of what they do.
scopeKeyTimeout,initCancellationTimeout,disposeScopeTimeoutandwaitForChildrenTimeout, in all three asynchronous families, each promised thatnull"waits indefinitely" — and each is read asvalue ?? ScopeConfig.defaultX, which is three seconds. A teardown that was meant to wait as long as it took gave up after three seconds and went on without its children, and the report of the expiry sent the reader looking somewhere else. They now say what the element layer had always said, thatnulltakes the default, and that a limit is removed throughScopeConfig, which is the only place a limit can be removed at all. Thedebugtopic says the same. -
Breaking: the first type argument of
ScopeAutoDependenciesis now bound to the container itself —ScopeAutoDependencies<T extends ScopeAutoDependencies<T, C>, C>. It had been bound toScopeDependenciesonly, so anything at all could stand there, and what stands there is what the container hands the scope when the tree is up. A container naming another container — a copy-paste with the argument left behind — compiled, built its whole tree, initialized it, and then failed on the cast at the very end of a successful initialization: a bareTypeErrorfrom a line the caller never wrote, with everything still running and nothing left holding a reference to release it. The bound alone does not close it, since another container satisfies the bound too, so the container also refuses a type argument that is not itself before it builds anything, and says which two types it is looking at. A container that already named itself, which is what every example and every test did, is unaffected. -
Behaviour change:
NavigationNode.onPopis given a context from inside the node. It used to get one from above the nested navigator, soNavigator.of(context)there was the application's navigator — and the confirmation dialog the documentation recommends asking from that hook, withuseRootNavigator: false, was pushed outside the node, above everything the node exists to stay below. A scope the node stood under was unreachable from the dialog asking whether to leave it. The context the route the node stands on is found from is unchanged, so an answer arriving after that route has been closed or buried still takes nothing. -
A notification no longer rebuilds the widgets of the subtree it is not rebuilding. "Skips rebuilding the whole subtree" was true of the elements and not of the widgets:
ComponentElement.performRebuildcallsbuild()whatever else happens, and onlyupdateChildwas skipped — sobuildChild()ran on everynotifyDependents, and everything it returned was thrown away unlooked at. For a scope notified once a frame that is the whole widget graph of its subtree, built and dropped, once a frame. The widget of the last real build is handed back instead. -
[breaking changes]
ScopeDependenciesExtension.asStreamtakes one type argument instead of two: the container type is the type of the receiver and is inferred, soAppDependencies().asStream<String>()replacesAppDependencies().asStream<String, AppDependencies>(). Written out, it was a downcast the compiler could not check — a container renamed in a refactor left the old name in the call, which still compiled and failed on the first frame. The shortest way to build an initialization stream was the one that moved a type error from compilation to run time. -
ofandselectonLiteScopeandScopesay which scope they found and what state it is in when there is no state to answer with. The state is created in the ready branch, so any other state — waiting for ascopeKey, initializing, failed, or closed in place — used to answerNull check operator used on a null value, naming neither.maybeOfstill answersnullthere, the same as when there is no such scope at all, and the message says so. -
The methods that carry a family's promise are sealed.
initScopeonAsyncDataScopeElementBasecatches the value on its way past, andinitDataAsynconAsyncControllerScopeElementBaseis the whole of what the controller family guarantees; both are@nonVirtualnow, and the controller layer'sdisposeScopeis@mustCallSuper. They sat in the same class as the hooks a subclass is meant to write, so overriding one silently turned the guarantee off —dataleft empty for good, or a controller never released — with nothing from the compiler or the analyzer to say so. -
Fix a controller whose
dispose()fails taking the failure of itsinit()with it. The release runs from afinally, and an exception raised there replaces the one thefinallywas entered for — sobuildOnErrorwas handed the secondary failure and the reason the scope actually broke disappeared. It is reported now, and the original is what the scope shows.dispose()is documented to run on the path whereinit()failed halfway, which makes that the path it is most likely to fail on. -
The same release is bounded by
disposeScopeTimeout, which theAsyncControllerScopetopic already promised for it. Nothing bounded it on the path whereinit()threw: a teardown that never finished never let the generator finish either, so the failure never reached the model and the scope showed its loading branch for ever, with no report of any kind. -
[breaking changes] The
valueofScopeModel.valueandScopeNotifier.valueis a non-nullableM. The field behind it stays nullable — the owning constructor leaves it empty — andrequiredtherefore said nothing about what a.valuescope was handed:nullcompiled and then failed on a bare null check, naming neither the scope nor the parameter, and for a notifier from insideinit(). A nullable expression now needs a!at the call, where the decision belongs. -
CompositeListenableSubscription.cancel()skips a member that was already cancelled on its own instead of cancelling it again. Cancelling a subscription twice is a mistake in the caller and still says so, but a composite holding one is not that caller: raising there left every member after it in the list still listening — the very leak the composite exists to prevent, and in debug builds only, since release has no assert to raise. -
A failure of a dependency reaches
buildOnErrorwith the stack trace of what actually failed. The wrapper was raised withStackTrace.empty, so the trace that travelled up the tree — and into whatever the application does with it — pointed nowhere. The original was insideScopeDependencyException.stackTraceall along, but nothing said so. -
A failure of the dependency teardown is reported through
FlutterError.reportError, not only logged.ScopeAutoDependencies.disposenever re-throws, by design, so the log was the single way out — and the package logger is off by default, which made a disposer that could not close its resource completely silent. -
LiteScopeState.close()andScopeState.close()use the element the state belongs to instead of looking one up throughcontext. The lookup answered the nearest scope of that type, which awrapStateputting another scope of the same type around the state is enough to shadow, and it answered nothing at all once the state had been unmounted — where closing a scope that is already gone should cost nothing. -
Fix a teardown passing on its last failure instead of its first. Its four stages are guarded apart so that a failure in one never skips the ones after it, and the first of them is what the caller was meant to hear — but two of the three handlers assigned to the record instead of keeping what was already there, so the last one won and the first was left in a log that is off by default. Only a scope closed with
close()could show it: on a scope taken off the tree the framework has already run the synchronous half before the teardown reaches it, so that stage cannot fail there at all. -
AsyncScopeParent.waitForChildrendefaults itstimeouttoScopeConfig.defaultWaitForChildrenTimeout, the same default the identically named helper onAsyncScopeCoordinatorhas always applied. It passednullstraight to the registry, wherenullmeans no limit at all — so the most natural call of a public method, without arguments, was the one wait in the package that could hang for ever. Waiting with no limit is now what setting that default tonullmeans, which is a decision for the application rather than for one call. -
Fix a selector that throws taking the whole notification with it. The walk over the dependents runs user code with no boundary of its own around it, so one selector that could not answer stopped the walk: every dependent it had not reached yet never heard about the change, and which ones those were came down to the iteration order of a hash map. A scope's own subscription is walked first, so a failure there swallowed the notification whole. The failure is now reported through
FlutterError.reportErrorand its dependent is treated as changed, so it is rebuilt and asks the selector again from inside its own build — where a second failure becomes anErrorWidgetfor that one widget, instead of a second, derived error for the frame. -
Fix one failing disposer taking the rest of a concurrent group with it — the same defect as the sequential one above, in the class beside it. An error reaching the merged stream cancelled every arm still running, and an arm suspended mid-walk resumes only as far as its next
yield: a nested branch stopped wherever the cancellation found it, everything below that point stayed held, and nothing came back for it, since the walk marks itself done whichever way it ended. Each arm now keeps its failure to itself, the merge finishes, and the first failure is passed upwards afterwards. The initialization of a concurrent group is unchanged: there the first error is meant to cancel the rest, and what the losing arms took is picked up by the disposal that follows. -
Fix a failing
ScopeState.onUnmounttaking the synchronous teardown of the dependencies with it. The state let go of its own first and the dependencies after it, unguarded, so a hook that threw meant no dependency ever heardunmountat all — and none ever would: the pass runs once and nothing comes back for a second attempt, so whatever a dependency drops only there lived on until the tree died with it. The two halves are now guarded apart, as they already were indisposeScope, and the first failure is passed on once both have run. -
Fix an asynchronous
NavigationNode.onPopbeing asked twice and answering too late. Two quick back presses started two questions, and two answers oftruetook two outer routes; an answer arriving after the route the node sits on had been closed by something else — or buried under a newer one — popped whatever was on top instead. The hook is now asked one press at a time, and an answer is acted on only while it still applies. -
A
NavigationNoderefuses anavigatorKeythat changes. It is the key the nested navigator is built with, so another one would mean another navigator and an empty stack — which is why the node kept the first one and the new key simply never resolved. An assertion says so, and points atWidget.keyand at theGlobalKey()written insidebuildthat usually causes it. -
A new family,
AsyncControllerScope, for a scope whose whole content is a controller with a lifecycle of its own — something that has to run while a part of the tree is on screen, rather than something to show. AScopeControllerwrites three hooks —init,onUnmount,dispose— and chains to nosuper: the three methods the scope calls (performInit,performUnmount,performDispose) are sealed, keepmounted, keep the order, and run each hook at most once. The scope releases the controller it created on every path, including the two a hand-written version overAsyncDataScopeloses it on: aninitthat threw, and aninitinterrupted before it handed the controller over. Three layers as everywhere:AsyncControllerScopeCore,AsyncControllerScopeBase, andAsyncControllerScope<C>with acreatecallback. -
[breaking changes] The type arguments of
AsyncDataScope.selectare in the order every otherselectin the package uses — the scope's own type first, the selected type last:select<Profile, String>rather thanselect<String, Profile>. It was the only one the other way round, againstScopeModel,ScopeNotifier,Scope,LiteScopeand its ownAsyncDataScopeBase.select. A call written for the old order stops compiling rather than changing meaning. -
[breaking changes]
AsyncScopehands the progress to the branches built before the scope is ready:progressBuilderis now(context, progress)anderrorBuilderis(context, error, stackTrace, progress), matchingScope,LiteScopeandAsyncDataScope. It was the only family that computed the progress, kept it in the model, and then left the builder to fish it back out ofAsyncScope.of. Subclasses ofAsyncScopeBasetake the same two arguments inbuildOnProgressandbuildOnError. -
Fix a teardown held forever by a
disposeScopethat never completes — the same hole one step further down, and user code on both sides of it. The release that follows it, and with it thescopeKeyof a scope that had already left the tree, waited for it with no limit. Bounded now bydisposeScopeTimeout, three seconds by default (ScopeConfig.defaultDisposeScopeTimeout,nullto wait indefinitely), with anonDisposeScopeTimeoutcallback. On expiry the teardown is left to finish whenever it does, the expiry is reported, and the scope gives back what it was holding. A release that legitimately takes longer than the limit is therefore no longer waited out — raise the limit for such a scope, or drop it withnull. -
Fix a teardown held forever by an initialization that cannot be cancelled. Cancelling an
async*means resuming its body and letting it run out, so a body parked on a future that never completes is never cancelled at all — and the teardown waited for that with no limit, never reaching the release behind it. The scope stayed registered with its parent and never gave itsscopeKeyback, so every later scope on that key queued behind an entry nobody would ever complete. The wait is now bounded byinitCancellationTimeout, three seconds by default (ScopeConfig.defaultInitCancellationTimeout,nullto wait indefinitely), with anonInitCancellationTimeoutcallback beside the two expiry callbacks the families already had. On expiry the initialization is left where it stands, the expiry is reported throughFlutterError.reportError, and the teardown goes on to give back what the scope was holding. What the generator itself holds stays held: it waits on somebody else's future, and no scope can complete that one for it. -
ScopeNotifier.valuetakes atag. It was the one constructor in the family without one, so a scope over a listenable somebody else owns had no name in the log — and that is exactly where two scopes of the same type stand side by side over two different models. -
AsyncScopeandAsyncDataScopetake the nine settings their base classes declare:scopeKeyTimeout,initCancellationTimeout,disposeScopeTimeoutandwaitForChildrenTimeoutwith the callback beside each, andpauseAfterInitialization. The elements behind both widgets had always read them; only the constructors never passed them on, so the two closure forms were the only ones in the package whose user could not set a limit for one scope — the process-wideScopeConfigdefault was the whole choice.AsyncControllerScopetook all nine from the start. -
[breaking changes]
State.disposeis sealed onLiteScopeStateandScopeState. It belongs to Flutter and lands on either side of a scope's teardown depending on how the scope went, so nothing a scope has to let go of can be released on that schedule. Overriding it is now an analyzer warning that says so; the teardown goes inonUnmount()anddisposeStateAsync(). -
[breaking changes]
ScopeDependencies.unmountandScopeDependency.unmountare nowonUnmount, so that every hook a scope calls to drop what must stop reaching it goes by one name. The assignable callbacks keep the short verb they always had:dep.unmount, andAsyncScope(onUnmount:). -
[breaking changes] The synchronous half of a teardown is now a step of the teardown rather than a tail of
Element.unmount, andLiteScopeState/ScopeStategainedonUnmount()to put it in. A scope leaves in one of two ways, and only one of them went through the framework:close(), which keeps the element mounted on purpose, skipped the synchronous half altogether — a dependency'sunmountnever ran, and by the time the element did leave the tree the asynchronous half had already released it, so nothing was left to drop the subscription from.onUnmountnow runs exactly once, always beforedisposeStateAsync, whichever way the scope goes.State.disposeis not part of that order and cannot be: it belongs to Flutter, which calls it before the scope's teardown begins on removal, and not until the tree comes down after aclose(). The synchronous half of a scope's teardown therefore belongs inonUnmount(). Scopes that only ever leave by removal are unaffected. -
[breaking changes]
AsyncScopeBase.onMountandAsyncDataScopeBase.onMountnow run before the initialization they are documented to precede. They ran fromElement.mount, which is after the first build — so after the synchronousinit()and after the asynchronous phase had started. They run frominit()now, which is also where the rules of that hook apply: a failure is terminal, and subscribing to an ancestor scope withlisten: trueis rejected by an assertion. -
A scope now refuses to change between the constructor that owns its model and
.value. Switching in place had no honest answer and both directions were silent:.valueto owning dereferenced avaluethat is no longer there, and owning to.valuekept the model the scope had made, ignored the one it was handed, and left nothing to ever release the first. An assertion refuses the rebuild and points atWidget.key. -
Fix
ScopeNotifier.valuekeeping its listener on the model it was given before. The swap was decided by==, so two models that compare equal were taken for one: the listener stayed on the model the scope had let go of, and every notification of the new one was lost. Ownership of a subscription is now decided by identity. -
Fix
ListenableSelectorignoring a newselectororcompare. They were replaced only together with thelistenable, so a parent that passed a new closure over the same source kept getting the previous one. Any of the three changing now re-subscribes. -
Fix
Listenable.selectleaving a listener behind when the selector fails on its first read. The first value was read after the listener was registered, so a failure there left the listener in place with no subscription handed back to take it away, and the next notification reached an unassignedlatefield. The first read now happens before the registration. -
CompareUtils.identicalno longer recommends itself forcompare:. Acompare:answers "did it change?", so the one to pass for a value that is replaced rather than mutated isnotIdentical;identicalreports the opposite of what it is asked. The same correction lands in the dartdoc ofListenable.selectandListenableSelector.compare, and indoc/utils.md. -
Fix
AsyncDataScopeContext.datahanding out anullthe scope never produced. For a nullableTthe getter read the value itself as the answer to "is there one yet?", so before the initialization finished it returnednullinstead of throwingStateError. Readiness is now tracked apart from the value, and a legitimatenullresult still reads asnull. -
Fix a selector staying registered after the widget stopped reading it. What a dependent selected was added to what it had selected in earlier builds, and the pile was only cleared once a change had already been found: a widget that moved from one value to another was still rebuilt by the one it had left. What a dependent selects now belongs to the build it selected in. The build boundary is the frame, so a dependent rebuilt twice within a single frame keeps both sets and pays at most one extra rebuild — the scope's own notification is not that case.
-
Fix a failing cleanup hook taking the mandatory teardown with it. A scope hands control to code you wrote on its way out —
onUnmount,onWaitForChildrenTimeout, the state'sdisposeStateAsync, a dependency'sunmount— and a failure there used to abandon everything behind it: the scope stayed registered with its parent, itsscopeKeywas never released, its model was never disposed of, and its dependencies kept whatever they had taken. Every mandatory stage now runs whatever the hooks make of it, and the first failure is reported once the teardown is over. Two consequences worth knowing: anunmountthat fails on one dependency no longer leaves its siblings mounted, and a scope whose disposal failed before the mandatory block now reports that failure after the block rather than instead of it. -
Fix a failing
onUnmountcosting every other scope of the same batch the teardown it is owed. The failure was raised at the caller ofScopeWidgetElementBase.unmount()once it had been reported — and that caller isBuildOwner._inactiveElements._unmountAll(), a loop with no boundary around any one element, over a list it has already cleared. Three sibling scopes with a throwingonUnmountin the middle left the third one mounted for good: nounmountScope, nodispose, no asynchronous teardown, itsscopeKeynever given back and its registration with the parent never dropped. Such a failure now goes toScopeConfig.observerandFlutterError.reportErrorand no further, which is the trade the rest of the teardown already makes. Theclose()path is unchanged: there a caller exists, and it still hears it. -
Progress.valuedrops a branch that said the same thing twice.num.clampcompares withcompareTo, which treatsNaNas the maximal double, so0 / 0already came back as the upper limit and the explicittotal == 0test in front of it could not change any answer. Nothing about the value changes; what does is that the promise now has one implementation and a test that holds it. -
Fix the first failure of an asynchronous teardown leaving as an unhandled error of the zone while every failure behind it was reported through
FlutterError.reportError. The teardown runs on a futuredispose()discards, so on the ordinary way off the tree there is nobody to raise at — an application with ordinary crash reporting therefore saw the failures that had no caller and missed the one that did. It is reported now, by the same channel as the rest.close()is untouched: there a caller exists and still hears it. -
onDisposeopens before the four stages of the teardown rather than between the second and the third.onErrorfor the unmount and for the preparation,onCancelledand twoonTimeoutall used to arrive before the teardown they belong to had been announced at all, so an observer pairingonDisposewithonDisposedcounted them against whatever came before. -
A scope lets go of the
scopeKeyobject and of the coordinator element once the key has been released. A scope closed withclose()stays mounted for as long as its owner likes — a closing screen can be on show for minutes — and both were held for all of it, long after the only thing that reads them had stopped. -
ScopeStateNotifier.updatereplaces the state whether or notshouldNotifysays anybody has to hear about it. That hook answers whether the change is worth waking a listener for, not whether it happened; skipping the assignment with the notification left the model holding the older of two objects its own comparison called the same.ScopeStateWithErrorNotifier.updatekeeps the value it was handed when it recovers from a failure — the one call that puts a failure down was the one call whose value was thrown away — and asksshouldNotifyonce instead of twice. -
Every
unmountfailure of a dependency group is reported. The first is passed on as it always was; the ones behind it used to be dropped where they happened, reaching neither the caller, nor the observer, norFlutterError. -
runStreamGuardedcancels a source that reported an error from insidelisten()itself, before the subscription had been handed back. Nothing in the package produces such a stream; the helper stands up to one that does. -
Add
ScopeCompositeObserver, which hands every event to each of the observers it is given.ScopeConfig.observerholds one, and wanting two is ordinary —ScopePrintObserverwhile developing and a reporter of your own beside it. It belongs to the package rather than to the application, and that is the point:ScopeObserveris abase classwith empty hooks so that an ordinary observer keeps compiling when a tenth hook is added, and a delegate is the one subclass that gains nothing from that — the new hook would arrive with the base implementation and every observer behind the delegate would go quiet without a word. An observer that throws does not stop the ones after it; the failure is reported and the rest are asked. -
Breaking:
CompositeListenableSubscription.addno longer throws aStateErroroutright after the composite has been cancelled. It still says the call is a mistake, with an assertion, the waycancelbeside it does — but the subscription it was handed is cancelled first. Raising left exactly what the composite exists to prevent: the subscription was made on the line before, and the throw was what kept it attached to its listenable with nobody holding it. -
The suite is no longer part of the published archive. 756 KB against the 540 KB of
lib/, downloaded by everyone who depends on the package and of no use to any of them — the topics indoc/are the documentation and they ship. 364 KB compressed becomes 234 KB. -
The dartdoc of
ListenableSelector.selectorsays what comparing it by identity costs: the inline closure the examples show is a new object on every build of the parent, so each of those rebuilds cancels the subscription and takes a new one. Hold the selector in a field where the parent rebuilds often and the listenable does not. -
ScreenshotReplacerno longer reports a failed capture throughFlutterError.reportErrorwhen it gives up. The case it reported is the one the widget documents as ordinary — a subtree that is never painted cannot be captured — and in debug the pre-check catches it beforetoImageis reached, so the report only ever happened in release: a line in a crash reporter for something the developer could not see happening, and not a failure of the application at all.onCompletedand the absence of a picture say what happened. -
The dartdoc of the four timeout parameters of
ScopeandLiteScopesays what the three asynchronous families' already did: whichScopeConfigdefault each takes, thatScopeTimeout.noneremoves the limit for one scope, thatinitCancellationTimeoutis the one that refuses it, and what happens when a wait expires. OnScope,disposeScopeTimeoutalso says that it bounds two steps rather than one. -
The dartdoc of the constructor-mode check on
ScopeModelandScopeNotifiersays that "refuses" means an assertion, and an assertion is debug-only: in release the switch still happens and still ends in a leaked model or a null check on avaluethat is gone. There is nothing honest to repair it with at runtime, which is the reason the check is where it is. -
The error a
LiteScopethrows for a missing branch namesinitScope(), the method that exists, rather thaninit(), which is a different hook. TheLiteScopetopic said the same thing in the same place. -
The
debugtopic counts the bounded waits the same way in both places it mentions them, and lists all of them: the two aScopereports for the two steps behinddisposeScopewere missing from the table. -
Fix
PreviousNavigatorExtension.previousthrowing for aNavigatorStatewhose tree is gone. The guard was written the wrong way round —State.contextasserts on an unmounted state, so asking the context whether it is mounted read it first and raised. It asks the state now, and answersnull. -
Fix a root
NavigationNodeletting amaybePopout of itself. "A root node keeps a pop to itself" was honoured bypopand not bymaybePop, which is the path the back arrow of anAppBartakes and the one a caller holding thenavigatorKeytakes: it would have pushed the pop out of the node and taken the route the node stands on with it. -
NavigationNode.onPopis asked wherever the press reaches the node, a node with no navigator above it included. It used to be skipped when there was nothing outside to hand a pop to, which made the promise wider than the code: the hook is where an application decides what its own outermost back means, and a root node'struewas already documented as taking nothing. -
PopEntry.onPopInvokedis a no-op rather than anUnimplementedError. It is the deprecated half of the pair and the framework's own is empty; raising from it made the node refuse to be aPopEntryat all in any version that still calls it. -
The dartdoc of
onPopand theutilstopic say what the hook actually answers: every pop the route is asked about — the system back,Navigator.maybePop(), and the back arrow of anAppBarabove the node as much as one inside it — and notNavigator.pop(), which takes the route rather than asking and consults noPopEntry. -
Fix a notification made from the build of a descendant being refused. The guard that defers such a notification asked whether this element was rebuilding, and a model touched from a descendant's build — a lazy load, a default filled in on first read, the ordinary user code the guard exists for — arrives while it is not. The bare
markNeedsBuild()was then called on an element that is not the one building, which the framework refuses outright; in release the check lives in an assert and the same code worked, so this was a difference between debug and release rather than a rule. Any build defers it now, and a second notification arriving before the frame is over joins the callback already waiting instead of adding one of its own. -
An
aspectthe scope does not recognise subscribes the dependent to every change instead of to nothing. It can only come from adependOnInheritedElementwritten by hand, and the assert that says so is debug-only: subscribed to everything a dependent is rebuilt more often than it needs, subscribed to nothing it is never rebuilt and nothing says why. -
The post-frame callback that registers a scope with its parent asks for the frame it needs, as the package's three other deferred callbacks already did. A build
runAppdrives outside a frame leaves nothing to ask for it. -
Fix a disposer running twice when two disposals of one dependency overlap. The hook was read at the top of the walk and cleared in the
finally— that is, after theawait— so a seconddispose()arriving while the first was parked on the disposer read the same hook and ran it again: a second rollback, a second close, a second write. It is taken off before it is called now, the wayunmountbeside it always was, so "exactly once" holds by construction rather than by which caller arrives first.ScopeAutoDependencies.dispose()likewise hands a second caller the run already going rather than opening a second teardown of the same tree; once it is over a later call runs again, since a walk that was stopped halfway leaves the tree still asking to be disposed of. -
Fix a disposal that was cancelled halfway being recorded as one that finished.
dispose()marked the tree done either way, so it stopped saying it needed disposing of — and the nextinit()replaced it, leaving everything the walk had never reached holding what it took with nobody able to reach it. Only a walk that reaches its end is done now, and a group whose disposal was cancelled still answersdisposalRequired, so a secondinit()is refused rather than quietly losing a tree. -
Fix a dependency that registered only
unmountsaying it held nothing.disposalRequiredasked aboutdisposealone, whileunmountis the other documented way of holding something — a subscription, usually. A bare leaf standing as the root of a container therefore looked disposable, and a secondinit()replaced it in silence: theunmountof the first run was never called at all. -
Breaking:
ScreenshotReplacerandListenableSelectorarefinal, the way the other sixty-odd public classes of the package already were. They were the two left as a bareclassby oversight rather than by decision, and sealing them after 1.0 would be a breaking change; now it is one word. -
Breaking: the barrel lists what
ScopeConfigand its parts export withshowinstead of naming the internals withhide.hidesays what stays in, so the next internal helper written besidenotifyObserverwould have joined the public API without anybody deciding it — and a name is public from the moment it ships. Nothing a consumer could reach changes. -
Progresscompares by value. It arrives inbuildOnProgressand insideAsyncScopeProgress, both of which are compared — a selector holding one, and the model of a scope — and by identity two readings of the same step answered "changed", so a subtree rebuilt for a step that had not moved. -
NavigationNodegainsenabled, for the one shape where a node cannot work out whether the press is its own: several nodes on one route, of which one is on screen — a node per tab of anIndexedStack, which builds every branch and shows one. A route asks each of itsPopEntrys and calls each of them back, so a single back press unwound the stack of every tab at once, the hidden ones included. Which node is the one on screen cannot be found out from inside: a hidden branch answersTickerMode.of(context)andModalRoute.of(context)exactly as a shown one does, and the order sibling nodes register in says nothing. The application knows, and writesNavigationNode(enabled: i == tab, …). A disabled node takes no place on the route — not asked, not called back — while its nested navigator keeps its stack and goes on answeringNavigator.of(context)from inside. Nodes nested one inside another never need it: an inner node registers on the page of the navigator above it rather than on the route both stand on. The ambiguity is Flutter's own — twoPopScopes on one route are both consulted — and this is the same answer an application gives there.README.md, theutilstopic and the dartdoc all carry the reasoning. -
Fix a
NavigationNodewith anonPoptaking a system back that belonged to the route it stands on. A route asks itsPopEntrys before it looks at its own local history, so an entry that says "do not pop" ends the matter — andonPop != nullsaid that unconditionally. AScaffoldwith adrawer:above the node therefore could not be closed with back at all whenonPoprefused, and when it agreed the user was asked "leave this screen?" about a press whose whole job was to close a drawer. The node now stands aside whenever the route will handle the press internally, which is what a drawer, a bottom sheet and an application's ownLocalHistoryEntryall look like. -
Fix
disposeScopeTimeoutexpiring on aScopeskipping the disposal of its dependency container entirely. The teardown put one limit arounddisposeScope(), and for aScopethat method is two steps — the state's own asynchronous teardown and then the container's. A state that never finished therefore spent the whole limit, the wait was given up on, and what it gave up on was both steps: thescopeKeycame back on time and every dependency stayed held with nothing left to release it. TheScopetopic described the two as separate steps and promised that "a failure in one is never a reason to skip what comes behind it" — which was true of a failure and not of a hang. The two are now bounded one each. A teardown where both hang reports two expiries, and the topic says so. -
Fix
close()over a subtree that can never be painted — inside anOffstage, or the unselected branch of anIndexedStack— leaving that subtree standing.ScreenshotReplacergives up aftermaxRetriesframes and reports that the screenshot is no longer pending, which releases the barrierclose()waits on; it used to keep the child in place all the same. The scope then tore itself down under a ready subtree that was still mounted: the scopes below it stayed registered, this one waited out its wholewaitForChildrenTimeoutfor a child nobody had taken away, and released what that child was still reading. Giving up now takes the child away too, which is what the report is for — with no image to put there, what takes its place is nothing. -
The
LiteScopetopic no longer promises that the ready branch waits forinitStateAsync(). It cannot: the state is created by the ready branch, so by the time its asynchronous initialization can begin, that branch has already built. The topic said the opposite in two places, and the package's own demo was already working around it with anisInitializedcheck. Both are corrected, the dartdoc ofinitStateAsyncnow says what does and does not wait for it, and both places nameisInitializedandonInitializedas the two halves of the answer. No behaviour changed — the promise did. -
selectandlisten: trueare allowed from the builder of aLayoutBuilder, anOrientationBuilderor aSliverLayoutBuilder. Those run fromperformLayout, inside a build of their own element, and what they return is that element's subtree — but aRenderObjectElementraisesdebugDoingBuildforperformRebuildalone, so the assertion behind the "only from a build" rule refused a working and common pattern, and refused it in debug only. The assertion now also accepts a layout callback. It still refusesdidChangeDependencies, except for a dependent that is itself under a layout callback, which is the one case the two cannot be told apart in. -
Fix a second
ScopeReadyfrom aScope's owninitDependenciesreplacing the container before anything could refuse it. The field was assigned inside themapthe family wraps the initialization in, one step ahead of the "already initialized" check in the layer above —mapruns as the event goes past,asyncMaponly after it. The model stayed as it was and the dependents heard nothing, but the container the scope had been using was gone from the field: the teardown unmounted and disposed of the newcomer, and what the scope had actually been running on was left with nobody to release it. The secondreadyis now refused where the assignment is, which is where the neighbouringAsyncDataScopehas always refused it. -
Fix two
ScopeAutoDependencies.init()runs overlapping. A secondinit()on a live tree was already refused, but the question asked was whether the tree had leftScopeDependencyInitial— and a tree that is initializing right now has not: that state is set at the very end of the run. A call arriving while the first was parked on anawaitwas therefore handed the same tree and started it again. Each dependency has oneScopeDependencyHandle, so the second run replaced it along with theunmountanddisposethe first had registered, and whatever that run had already acquired was left with nothing to release it; worse, the second run's own teardown then tore the tree down under the first. BothScopeAutoDependencies.init()andScopeDependency.init()now refuse a call that arrives while one is running, the second because a dependency tree driven by hand never passes the first. -
Fix a scope frozen on an
ErrorWidgetfor the rest of its life after one failed build. A rebuild made for a notification alone hands back what the last real build produced and leaves the child element as it is — which needs there to have been a real build. When the first one threw, the boundary above put anErrorWidgetin the subtree's place and the cache stayed empty: every notification after that built a fresh subtree, handed it to anupdateChildthat kept theErrorWidgetinstead, and filled the cache with what it had just thrown away, so the next notification did not even build. Only a rebuild from the parent could bring the scope back. A rebuild with nothing cached is no longer treated as notify-only. This is the layer every family is built on, so it applies to all nine. -
Fix
close()never completing when the build that starts it failed. The barrierclose()waits on is released by theScreenshotReplacerthat the closing build mounts, so anything that stops that build from finishing was a teardown that never began at all — not four stages skipped, but the whole of it, thescopeKeyand the registration with the parent included, while Flutter's ownState.disposehad already run under theErrorWidget. The closing build is now guarded and releases the barrier before the failure goes on to the boundary above. The reachable trigger was the package's own: the ready branch was wrapped in a bareStack, which resolves its alignment through aDirectionalityabove itself, and a scope at the root of the application builds theMaterialAppinside its own branches — so everyDirectionalityin the tree is below that point and the first rebuild afterclose()threw while theStackwas being mounted. ThatStacknow aligns withAlignment.topLeft, which needs none; nothing is aligned by it either way. TheLiteScopetopic says both, and says that the default closing overlay reads the theme above the scope, which for such a root scope isThemeData.fallback(). -
ScopeController.performDispose()runsdisposeeven whenonUnmountthrew. The two stages were chained, so a synchronous half that failed left everything the controller had taken held — and_disposeCompleterwas installed by then, so a secondperformDispose()handed back the failed run instead of picking the teardown up. They are now guarded apart, the way the four-stage teardown of a scope guards its own, and the first failure is passed on once both are over. The path this is reached on is the one whereinitfailed: everywhere else the scope has runonUnmountitself already, andperformDisposefound it done. -
Fix
NavigationNodesystem back handling: a pushed route or dialog in its nested navigator now closes before the enclosing route can pop. Once the node has nothing of its own left to close,onPopis asked exactly once and its refusal keeps the route; a back inside nested nodes reaches the innermost one and leaves every route above it alone.Navigator.canPopinside a node no longer counts the node's own forwarding bookkeeping as a route it can close. -
Fix a
NavigationNodeemptying itself.Navigator.pop()on the node's first page used to take that page away and leave the node with nothing to show: at once in a node markedisRoot, and from the second pop on in any other node, whose way outwards was a one-shot. A root node now keeps such a pop, and an ordinary node forwards it every time it is asked. -
[breaking changes] Raise the Flutter floor to 3.29.0. The declared
>=3.27.0never resolved:logger_builderrequiresmeta ^1.16.0while theflutter_testof 3.27 pinsmetato 1.15.0, sopub getfailed for anyone who took the constraint at its word. The suite is now run on 3.29.0 itself before a release. The Dart constraint stays^3.6.0— the code needs nothing newer, and raising it would switchdart formatto the tall style and reformat the package for no gain. Superseded later in this same release: oncelogger_builder0.6.1 andansi_escape_codes4.0.1 letmetaback down to^1.15.0, nothing external held 3.29 any more and the floor returned to>=3.27.0. -
metais no longer a dependency: nothing underlib/imports it. A single test utility does, so it is a dev dependency now. -
[breaking changes] An expired
waitForChildrennow drops the children it was awaiting, sohasChildrenandchildrenCountfall to zero for them. Children registered while the wait was already running are kept. -
The dartdoc of
ScopeConfig.defaultScopeKeyTimeoutanddefaultWaitForChildrenTimeoutwas wrong: a zero duration expires immediately, it does not disable the timeout. Onlynullremoves the limit. The behaviour is unchanged — anyone who setDuration.zeroexpecting the documented meaning has been running with instantly-expiring waits. -
[breaking changes]
AsyncScopeCoordinatornow owns thescopeKeyqueues of its own subtree instead of a process-wide map, and is what scopes without a parent scope register with. The globalasyncScopeRoot,AsyncScopeRoot,AsyncScopeCoordinatorEntryandScopeChildEntryare gone, andAsyncScopeParent.waitForChildrentakestimeoutandonTimeout.- Migration:
asyncScopeRoot.waitForChildren()becomesAsyncScopeCoordinator.waitForChildren(context, {timeout, onTimeout}), which awaits the scopes registered with the nearest coordinator abovecontext.timeoutdefaults toScopeConfig.defaultWaitForChildrenTimeoutand an expiry is reported throughFlutterError.reportErrorunlessonTimeoutis given. AsyncScopeCoordinator.enteris no longer public: the queues are entered by the scopes themselves and the entry types are internal.AsyncScopeParent.registerChildis no longer public. It was a public member of a public mixin, so code that mixedAsyncScopeParentin and called or overroderegisterChildno longer compiles;hasChildren,childrenCountandwaitForChildrenstay public.- Silent behaviour change: a scope with neither a parent scope nor an
AsyncScopeCoordinatorabove it now registers nowhere, so nothing awaits its disposal — previously every such scope landed in the globalasyncScopeRoot. Such code keeps compiling unchanged and behaves differently: if anything used to await those scopes, put anAsyncScopeCoordinatorabove them (the usual place is aboveMaterialApp) and awaitAsyncScopeCoordinator.waitForChildren(context).
- Migration:
-
[breaking changes] Unify dependency path format: no leading
/inScopeDependencyException.name,ScopeDependencyInfo.pathand progress paths; anonymous groups add no separator. -
[breaking changes]
ScopeAutoDependenciesProgress.nameis renamed topath, which is what it always held.namestays, and is now the name the dependency was declared with — the last segment ofpath.- Silent behaviour change:
progress.namekeeps compiling and starts reporting the leaf name instead of the whole path. A caption that is meant to show the whole path becomesprogress.path.
- Silent behaviour change:
-
[breaking changes] Remove dead API:
LiteScopeInitState/Waiting/Progress/Ready; renameScopeDependencyNoDisposalRequredtoScopeDependencyNoDisposalRequired. -
Remove the internal, never-exported
typeToShortStringand the unusedNotifiermixin with itsTestNotifier. -
Fix infinite recursion in
CompareUtils.identical. -
Fix hang in
ScopeAutoDependencies.dispose()when no dependency requires disposal. -
Fix an abandoned disposal when the initialization raises while it is being cancelled: a generator that fails in its
finallyhands that failure to thecancel()the disposal awaits, and it used to leave the disposal right there — the scope never unregistered from its parent, which then waited out its wholewaitForChildrenTimeouton a scope that was already gone, and never released itsscopeKey. The failure is now reported throughFlutterError.reportErrorand the disposal runs to its end. -
Fix a deadlock when an asynchronous initialization fails before it starts:
initScope()raising on the spot, or the missing-AsyncScopeCoordinatorerror of a scope with ascopeKey, left the scope waiting for its own initialization forever. It never unregistered from its parent, so the parent burned its wholewaitForChildrenTimeouton a scope that was already gone, and neither of them was ever disposed of. The failure is still reported the same way, and a scope whose initialization never happened is still not disposed of. The same failure inLiteScopeCoreState.initStateAsync()no longer keepsclose()waiting forever either. -
Fix a failure raised after
initScope()had already reachedAsyncScopeReadycrashing withBad state: Future already completedinstead of being reported: the stream's error handler completed the initialization completer a second time, and that crash replaced the failure it was handling, so the real error reached nobody. Such a failure is now reported throughFlutterError.reportError(libraryscopo) and the scope stays ready — it is no longer flipped intoAsyncScopeError, which would have replaced the widgets already on screen withbuildOnErrorwhiledisposeScope()still ran. Thealready initializeddiagnostic now checks whether the initialization succeeded instead of the applied model state, so a secondAsyncScopeReadyarriving before the post-frame callback that applies the first one no longer re-runs the whole ready branch. -
Fix a
scopeKeyheld forever whenonScopeKeyTimeout()throws: an expired wait lets the scope into the key anyway and then calls that hook, so by the time it ran the entry was already in the queue — and a failure there made the scope forget the entry, so its disposal never released the key. Every later scope on that key then waited for an entry nobody would ever complete, with no way out. The coordinator is now resolved before the entry is created, which is what the blanket handler existed for, and an attached entry is never dropped. -
Fix a scope running its whole initialization after its disposal had already begun: a scope with a
scopeKeyawaits the coordinator before it subscribes toinitScope(), and the disposal can only cancel an initialization through that subscription — so a disposal starting inside that window had nothing to cancel, and themountedguard on the far side of the await says nothing about aclose(), which keeps the element mounted on purpose. The scope went on to subscribe once the key was granted and to acquire resources it would never release, since a scope whose disposal has already passed thedisposeScope()decision does not run it. The initialization now also stops when the disposal has begun; the normal path is unchanged. -
Fix
close()leaving an orphaned child entry behind: the post-frame callback that registers a scope with its parent was guarded bymountedalone, andclose()keeps the element mounted on purpose. A disposal that finished before that callback fired handed the parent a fresh entry registered after thefinallyhad unregistered the previous one, so the parent — orAsyncScopeCoordinator.waitForChildren— burned its whole timeout on a scope that was already gone. The callback is now guarded the same way its two siblings are. -
Fix moving a closed scope in the tree with a
GlobalKeycrashing: the disposal unregistered the entry it held with its parent but left the field pointing at it, andactivate()re-registered unconditionally — so the move reached for an entry that was already gone. In debug that hit an assert; in release, where the assert is not there to stop it, it fell through toBad state: Future already completed. The field is now cleared, a reactivation after disposal no longer registers at all, andChildEntry.unregister()is idempotent. -
scopeKeyis now documented and enforced as read exactly once, when the initialization starts: the answer it gives then —nullincluded, which is an answer and not the absence of one — together with theAsyncScopeCoordinatorabove the scope, is binding until the scope has finished disposing of itself. A key that appears after a scope initialized without one, a key that is given up, a key that changes, and a scope moved with aGlobalKeyunder a different coordinator all used to be silent, and the mutual exclusion the key exists for quietly stopped working — an appearing key was never taken at all, so a second scope simply coexisted with the holder. All four are now reported in debug builds through anassert, each with a message that says what happened and what to do instead (give the widget a differentkey, so a new element reads the key afresh). Release builds are unaffected, and nothing is repaired: releasing a key and taking another one is asynchronous, and a rebuild is not. A scope that has finished disposing of itself holds nothing, so it is exempt: an element that outlives its own disposal — which is whatLiteScope.close()leaves behind, still mounted so it can show a closing screen, and still movable with aGlobalKey— may be rebuilt and reparented freely. A key that changes while aclose()is still in flight, with the entry still in its queue, is reported as before. -
Fix an expired
waitForChildrenforgetting the children registered after it started: the wait dropped the whole live registry instead of only the snapshot it was awaiting, so a scope that registered mid-wait — one the wait never awaited by design — was silently unregistered, and the nextwaitForChildren()returned at once while it was still disposing of itself. The children are now dropped even when theonTimeoutreporter throws. -
AsyncScopeParent.waitForChildrennow defaultsonTimeoutto reporting theTimeoutExceptionthroughFlutterError.reportError(libraryscopo), prefixed with the parent's short description — the same defaultAsyncScopeCoordinator.waitForChildrenalready applied. Calling the mixin method directly on a scope element used to drop the children and complete with nothing reported at all. -
[breaking changes] A
ScopeDependencythat carries errors keeps them through its disposal instead of being overwritten withScopeDependencyDisposed. A group is disposed of because something under it failed —disposalRequiredcoversScopeDependencyFailed— so the disposal threw away the one record of what had failed, and with the defaultautoDisposeOnErrorthat happened before the caller ever saw it. A failed leaf was never disposed of and so always kept its errors; the groups now behave the same way.disposalRequiredno longer reads the state alone, so a group that staysScopeDependencyFailedis not disposed of twice.- Migration: after disposing of a tree that failed, the root reports
isFailed == trueandisDisposed == false, where it used to report the opposite;stateToString()still names the children that failed.
- Migration: after disposing of a tree that failed, the root reports
-
ScopeAutoDependencies.init()can be called again once the previous run has been disposed of: it rebuilds the tree instead of reusing the one the disposal left behind, which tripped an opaqueassertinside the first dependency it reached. The tree is replaced on the nextinit()rather than dropped bydispose(), so the outcome of the run that is over stays readable throughflattenDependencies(). A secondinit()on a tree that is still alive now fails with aStateErrorthat says so, instead of silently abandoning everything the first run is holding. -
Fix
ScopeNotifier.valuenot subscribing to a new listenable on update. -
Fix
LiteScope.close()hang outside the Ready state; fixScreenshotReplacercompleting early and leakingui.Image. -
Fix
LiteScope.close()waiting forever on a screenshot that could never be taken: anotifyDependents()left pending asks the next rebuild to skip the subtree, so the widgetbuildOnReady()built for the closing frame — the one carrying theScreenshotReplacerthat releases the barrier — was thrown away byupdateChild.mounted && state is AsyncScopeReady, which is whatclose()checks before installing the barrier, is necessary but not sufficient, and a scope closed in place stays mounted, so thedispose()fallback never ran either. The closing frame now rebuilds the subtree anyway; the pending notification is still delivered. -
Fix a double close() race in LiteScope orphaning the screenshot barrier; cap ScreenshotReplacer retries (new public ScreenshotReplacer.maxRetries).
-
Fix concurrent
LiteScope.close()callers disagreeing about a failed disposal: only the caller that started the run saw the error, while every other one — a secondclose(), or the implicit disposal on unmount — was told the very same run had succeeded. All of them now receive the same value, or the same error and stack trace. -
Fix the closing screenshot never being taken in release and profile builds: the
debugNeedsPaintpre-check is now assert-gated, so it no longer throws aLateInitializationErroron everyclose(). -
Guard the Ready-state model update against running after disposal has started (an element closed via close() stays mounted while its model is being disposed of).
-
Base the disposeScope() decision on successful initialization instead of the applied model state (resources are now disposed of when the element is removed in the init-completion frame).
-
Guard AsyncScope post-frame callbacks with
mounted. -
Log dependency disposal errors instead of swallowing them.
-
Fix unbalanced parenthesis in
AsyncScopeError.toString(). -
Add
repository,issue_trackerandtopicsto pubspec. -
[breaking changes] Tighten the SDK constraints to Flutter
>=3.27.0(was>=1.17.0) and Dart^3.6.0(was^3.2.0) — the floor the package actually requires, since it callsColor.withValues. This is where 0.10.0 ends up after a detour: the floor went to>=3.29.0in the middle of the release because>=3.27.0would not resolve, and came back once the dependency that stopped it gave up the constraint. The two entries above say why it moved and what moving it back cost. -
Switch analysis to flutter_lints in the package and demo.
-
Rewrite README; sync the pub.dev example; real
debug/Scopedoc pages.
0.9.6 #
- Upgrade logger_builder to 0.4.0.
0.9.5 #
- Replace ellipsis characters in log messages.
0.9.4 #
- Minor logging changes.
0.9.3 #
- Fix some bug on dispose
AsyncScopeElementBase.
0.9.2 #
- Minor changes to
LiteScope.buildOnWaiting. - Add docs.
0.9.1 #
- Minor changes to the logging.
0.9.0 #
- Upgrade ansi_escape_codes to 3.0.2.
- [breaking changes] Upgrade logger_builder to 0.3.1.
0.8.1 #
- Upgrade ansi_escape_codes to 2.2.1.
- Upgrade logger_builder to 0.2.0.
0.8.0 #
- [breaking changes] change
pkglogtologger_builder. - change license to MIT.
0.7.5 #
- fix bug:
datainAsyncDataScopemay benull. - fix bug:
unmountinAsyncDataScopecan be called beforedatainitialization.
0.7.3-0.7.4 #
- add
onMount/onUnmountcalls toAsyncScopeandAsyncDataScope. - add
unmounttoScopeDependencies,ScopeDependencyandDepHelper.
0.7.1-0.7.2 #
- update logging
- minor changes
0.7.0 #
- [breaking changes] rename
ScopeQueueMixintoScopeAutoDependenciesand refactor. - [breaking changes] rename
waitBuildertowaitingBuilder. - minor: add package
pkglogfor logging.
0.6.3 #
- add timeouts for waiting for access (
scopeKey) and waiting for children to complete (AsyncScopeParent,waitForChildren) - set default timeouts to 3 seconds.
- add info logging (
ScopeLog.logInfo) for important messages.
0.6.2 #
- add
AsyncScopeCoordinatorfor coordination of scopes with the same key. - minor fixes.
0.6.1 #
- add
asyncScopeRootto register scopes that do not have a parent, so that you can wait for them to complete.
0.6.0 #
- fix some bugs.
- add
buildOnClosingforScope. - add more examples.
- add
AsyncScope,AsyncDataScope,LiteScopewithLiteScopeState.
0.5.0 #
- [breaking changes] refactor, rename.
- [breaking changes]
exclusiveCoordinatortransformed toscopeKey. - parent scopes now depend on their children (
asyncInit,asyncDispose). - scope states can now also be initialized and disposed asynchronously
(
asyncInit,asyncDispose).
0.4.1 #
- update example's README.md.
0.4.0 #
- [breaking changes] add context to init.
- add
AsyncInitializerandAsyncState.
0.3.3 #
- return
childback. by default, it is not used, but you can use it yourself.
0.3.2 #
- add
ScopeDependenciesQueuefor sequiential async initialization and disposal from list of dependencies.
0.3.1 #
- fix a serious bug: the code is built using a Flutter fork. transfer to the official version.
0.3.0 #
- add
ScopeModel,ScopeNotifier,ScopeAsyncInitializer,ScopeStreamInitializer. - new
Scope. - remake scopo_demo
- add
LifyceycleCoordinatorfor sequiential async initialization and disposal.
0.2.2+1 #
- breaking changes: rename
ListenableAspectBuildertoListenableSelector. - breaking changes: rename
listenTotoselect. - update docs.
- fix:
pauseAfterInitializationto zero by default.
0.2.0-0.2.1 #
- breaking changes: remove context from
init.
0.1.3 #
- implement
ScopeContentfromListenable - add utils for
Listenable - add minimal example.
0.1.2 #
yield ScopeReadyclosesinit- add
ScopeConsumer - remove type for progress from
Scopedefinition
0.1.1 #
- remove
wrap - add
wrapContent
0.1.0 #
- scopo is ready for production