store_scope 0.4.0
store_scope: ^0.4.0 copied to clipboard
Flutter's Jetpack ViewModel — widget-tree-scoped dependency injection with automatic disposal, zero codegen, and bring-your-own reactivity.
0.4.0 #
The four scoped widget mixins collapse into two. Picking an entry point is now a single question — "am I writing a StatelessWidget or a State?" — instead of a 2×2 matrix whose second axis was never real.
Changed #
- BREAKING:
ScopedSpaceStatelessMixin→ScopedStatelessMixin, and its build methodbuildWithSpace(context, space)→buildScoped(context, space). Behaviour is unchanged. Migration: rename both. - BREAKING:
ScopedSpaceStateMixin→ScopedStateMixin. Behaviour is unchanged;space,scope, anddisposesemantics are identical. Migration: rename the mixin.
Removed #
- BREAKING: the two raw-scope mixins are gone. Their names are reused by the mixins above, so read this even if your code still compiles:
ScopedStateMixin— the old one exposed onlyListenable scope; the new one is the formerScopedSpaceStateMixin, which exposes the samescopegetter plus aspacegetter. Existing code needs no change and behaves identically —context.store.bindWith(p, scope)still compiles and still binds against the same notifier. The store is only touched if you actually readspace, so aStatethat never uses it still works with noStoreScopeancestor.ScopedStatelessMixin— the old one handed youbuildScoped(BuildContext, Listenable); the new one hands youbuildScoped(BuildContext, StoreSpace). For the signature you actually wrote, this is a compile error, not a silent change (StoreSpaceis neither a subtype nor a supertype ofListenable, so the override is invalid — addingcovariantdoes not rescue it either), and it lands on the exact line that needs attention. Migration: change the parameter type and usespace.bind(p), or keep the old call ascontext.store.bindWith(p, space.scope).- One exception, if you are in the habit of loosening parameter types: declaring the scope parameter as
dynamicorObjectis a valid override (parameters are checked contravariantly), so such code still compiles and instead fails at runtime withtype 'StoreSpace' is not a subtype of type 'Listenable'on the first build. Grep forbuildScopedbefore upgrading if that describes your code.
- One exception, if you are in the habit of loosening parameter types: declaring the scope parameter as
- Consequence of the above: a
StatelessWidgetusing this mixin now resolves the ambientStoreon every build, so one used outside anyStoreScopethrows where it previously did not. If all you want is a disposalListenablewith no DI container, use aDisposeStateNotifierin aStatefulWidgetdirectly. The error raised in that case now names the mixin and explains the requirement, instead of the generic "No StoreScope found in context" pointing at acontext.storecall your code never made.
Fixed #
-
A store swap noticed outside a frame no longer strands the old scope (present since 0.2.0, in what was then
ScopedSpaceStateMixin).spacere-checks the ambientStoreon every read and releases the superseded scope viaaddPostFrameCallback, which is correct during a build — running instance disposers mid-build can trip "markNeedsBuild called during build" — butaddPostFrameCallbackdoes not schedule a frame. So a swap first noticed while the scheduler was idle handed the old scope to a callback that never ran, leaking every instance bound against the previous store. The release now runs inline when no frame is in flight and stays deferred during the build phase.Reaching it takes a
GlobalKeyreparent beneath a differentStoreScopethat no intervening build notices (easy for a widget that readsspaceconditionally), followed by a read ofspacefrom anonPressed, aTimer, or a stream listener.ScopedStateMixinis the exposed one, since itsspaceis a public getter; on theStatelessWidgetside the space is only ever read frombuild, which is always inside a frame.
Rationale: StoreSpace implements ScopeAware, so a space was always a strict superset of a raw scope (space.scope is that Listenable) — the raw-scope variants bought nothing but a second decision for every user. On the State side the space is additionally built lazily, so that superset is free; on the StatelessWidget side it is materialised as a build argument, which is exactly the trade noted above. ScopedBuilder is unchanged apart from tracking the rename internally.
0.3.0 #
No public API was removed or changed — but four behaviour changes below are marked BREAKING: they can turn a previously green build red without any code change on your side. Read the Changed section before upgrading.
Fixed #
ViewModel.dispose()no longer abandons a teardown mid-way. A cleanup callback that throws used to escape the loop, skipping every lateraddCloseable/addKeyedCloseablecallback andsuper.dispose()— leaving the notifier alive with its listeners attached, while the Store'scatchreduced the whole thing to one log line. Failures are now collected, the teardown (including the owning provider'sdisposer) runs to completion, and only then is the failure surfaced perStoreScopeConfig.throwOnCloseError.addKeyedCloseableno longer loses the replacement callback when the closeable it replaces throws. The old callback was invoked unguarded before the new one was stored, so the exception escaped into caller code, the new callback was never registered (its resource leaked), and the old one stayed in the map to throw again atdispose().DisposeStateNotifier.dispose()is now fully idempotent. A second call used to reachChangeNotifier.dispose()again, tripping a debug-only assertion — so defensive double-teardown (and re-entrant disposal from insidenotifyListeners()) crashed in development and passed silently in release.- A provider whose value is legitimately
null(nullableT) is created exactly once. Cache lookup tested the value instead of key presence, so such a provider was rebuilt on every access — allocating a fresh instance scope each time, all registered under the same key, so every earlier scope was orphaned and its dependency cascade never released. - Per-instance dependency scopes are keyed by
(provider, instance)instead of instance identity alone. Dart canonicalizes values, so two unrelated providers that each returned42(or aconstobject, or the same enum value) collided: the second registration evicted the first, and disposing one provider tore down the other's cascade while it was still in use. Store.unmount()and scope teardown are no longer aborted by the error reporting itself — see below.addSubscriptionnow honours the disposed state, as its docs always claimed. It appended straight to the internal set instead of going throughaddCloseable, so a subscription registered after the ViewModel was disposed landed in a collection nothing drains again — never cancelled, still firing, still holding the ViewModel alive. This is reachable on the happy path: a load kicked off ininit()that only completes after the binding scope died. It also takessubscription.cancelas a tear-off now, so de-duplication actually applies to it.- A provider creator that throws no longer orphans the dependencies it had already bound.
Provider.createallocated the instance scope, ran the creator, and only then registered the scope with the instance-scope manager — so when the creator (or aViewModel.init()) failed after aspace.bind, that scope never reached the manager and could never be disposed. The child stayed in the store untilunmount(), and its refcount gained a permanent+1on every attempt, which grows without bound when a failing bind is retried on each rebuild. The scope is now released before the original error is rethrown; callers still see the creator's exception, unchanged.
Changed #
-
BREAKING: an exception escaping a provider's
disposeis now reported throughFlutterError.reportErrorinstead of being swallowed into a log line. A widget test with a throwing disposer that used to pass silently now fails — that is the intent, but expect previously green tests to go red. Migration: fix the disposer, or scope the expectation withexpectLater(tester.takeException(), ...). It reaches the console with a stack trace and fails widget tests, which is the point: a silent failure here hides the one guarantee this package exists to provide. The store still catches, so one bad disposer cannot strand the rest; ifFlutterError.onErroritself throws (e.g.(d) => throw d.exception), reporting falls back toStoreScopeConfig.lograther than breaking the teardown loop.- BREAKING consequence: these failures no longer pass through
StoreScopeConfig.log, so a custom log sink (crash reporting, log collection) will stop seeing them and the loss is silent. Migration: hookFlutterError.onErrorinstead.
- BREAKING consequence: these failures no longer pass through
-
BREAKING: during scope-driven teardown, the instance is removed from the store before its
disposeruns.store.exists(p)/store.find(p)observed from insidep's own disposer now report "gone" rather than "still there", matching what theunmount()path already reported. -
StoreScopeConfigis exported frompackage:store_scope/store_scope.dart(was reachable only via an implementation import). Exported withshow StoreScopeConfigon purpose: the top-levelLogWriterCallbacktypedef anddefaultLogWriterCallbackfunction carry GetX's exact names and would make any file importing both packages unprefixed fail withambiguous_import. -
BREAKING: when
StoreScopeConfig.throwOnCloseErrorsurfaces a cleanup failure, the original error is rethrown with its original stack trace instead of anExceptionwrapping a stringified one. Tests can now match on the type the callback actually threw, and the stack points at the callback instead of atViewModel.dispose— but an existingthrowsA(isA<Exception>())now fails for a callback that threw anErrorsubtype (StateErroris anError, not anException). Migration: match the real type, e.g.throwsStateError. -
When several cleanup callbacks fail in one teardown, each is reported as its own
StoreScopeConfig.logentry rather than being joined into a single blob (which buried everything after the first and broke line-oriented log parsing). Only one error can be thrown, so withthrowOnCloseErrorthe first is rethrown and the rest are logged. -
The
equatableconstraint is now^2.1.0(was^2.0.7). Argument providers mix inEquatableinstead of the deprecatedEquatableMixin;Equatableonly became usable as a mixin in 2.1.0. The comparison semantics are unchanged —EquatableMixinandEquatablehave identical==/hashCode/toStringimplementations.
Docs #
- Documented the override rule as a single rule with no exceptions: the store returns an overridden instance but never runs its lifecycle — no
init(), nodispose(), even for aViewModelProvidertarget. Added the two recipes (run the hooks yourself; oroverrideWith((space) => Fake(space)..init(), dispose: (vm) => vm.dispose())when the fake's setup needs a liveStoreSpace) and the guidance to override a ViewModel's dependencies when the real lifecycle is what's under test. - Documented that a
StoreScopeConfig.logimplementation must not throw. It is called from the middle of binding and teardown — including the last-resort branch that runs afterFlutterError.onErrorhas already failed — and those call sites are deliberately unguarded, so a throwing sink would breakbindWithoutright and could strand a teardown half-finished. - Documented that scope-driven teardown is inside-out — an instance's dependency cascade is released before its own
dispose().unmount()is explicitly excluded: it runs no cascade and disposes in creation order, so teardown order must not be relied on there.
0.2.0 #
- Add provider overrides for tests/DI. Inject fakes via
StoreScope(overrides: [...])orStoreImpl(overrides: [...]), built withprovider.overrideWithValue(fake)(caller owns the instance's lifecycle) orprovider.overrideWith((space) => fake, dispose: ...). - BREAKING: acquiring a store-lifetime instance is now
Store.share/StoreSpace.share/context.share(wasread). It accepts only aSharedProviderand pairs with theProvider.shared(...)definition. Migration: replaceread(withshare(at those call sites. - Added store-lifetime argument providers: mark a
withArgumentfactory.asSharedat the definition site, then acquire per-argument singletons withstore.share(p(arg)). Keyed by the argument's value and alive until the Store is unmounted; available on every arity (withArgument..withArgument6) for bothProviderandViewModelProvider. Example:final userProvider = Provider.withArgument<User, int>((s, id) => User(id)).asShared; - BREAKING (low impact): the per-arity argument-provider factories were unified into a single internal provider class; their public
createInstance/createViewModelhelper methods were removed. Acquire instances the usual way — call the factory (factory(arg)) thenspace.bind(...)/store.share(...). Code that called those helpers directly must drop them. - Fixed dartdoc examples that referenced removed/incorrect APIs (
store.shard,context.bindWith, a non-existentDisposeStateAwareMixin).
0.1.0 #
- BREAKING: Instance lifetime is now declared on the provider.
Store.shared()is removed — define store-lifetime instances withProvider.shared(...)/ViewModelProvider.shared(...)and read them via the scope-free, statically typedStore.read/context.read. Scoped providers keep usingbind/bindWith. - Fix:
Store.unmount()now disposes every instance; ViewModels and their subscriptions were previously leaked on teardown. - Fix:
ViewModel.dispose()no longer throwsConcurrentModificationErrorwhen a closeable registers another closeable. - Fix: swapping
StoreScope.storeOwnerno longer throwsLateInitializationError. - Remove the internal shared-instances tracking and the assert-only shared/bind misuse warnings.
0.0.11 #
- ViewModel add addSubscription method
- Add temporary method of Store to support creation of temporary instances
- Introducing ScopedBuilder to simplify the interaction between components and Store
0.0.10 #
- Introducing AutoStoreWidget and AutoStoreStatefulWidget to simplify state management and automatically handle the Store lifecycle
0.0.9 #
- Make viewModel inherit from ChangeNotifier to optimize resource destruction processing
0.0.8 #
- Refactor ArgProvider and ArgViewModelProvider to support custom equality
0.0.7 #
- Add instance scope manager to optimize instance creation and destruction process
0.0.6 #
- Provide a default implementation for the disposeViewModel method
0.0.5 #
- Support passing in parameters when creating a provider
0.0.4 #
- Allow access to store in initState; widget tree rebuilds on store changes
0.0.3 #
- Refactor code to use the new ScopeAware interface, optimizing binding and lifecycle management
0.0.2 #
- fix: change the parameter name
0.0.1 #
- Initial version.