store_scope 0.4.0 copy "store_scope: ^0.4.0" to clipboard
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: ScopedSpaceStatelessMixinScopedStatelessMixin, and its build method buildWithSpace(context, space)buildScoped(context, space). Behaviour is unchanged. Migration: rename both.
  • BREAKING: ScopedSpaceStateMixinScopedStateMixin. Behaviour is unchanged; space, scope, and dispose semantics 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 only Listenable scope; the new one is the former ScopedSpaceStateMixin, which exposes the same scope getter plus a space getter. Existing code needs no change and behaves identicallycontext.store.bindWith(p, scope) still compiles and still binds against the same notifier. The store is only touched if you actually read space, so a State that never uses it still works with no StoreScope ancestor.
    • ScopedStatelessMixin — the old one handed you buildScoped(BuildContext, Listenable); the new one hands you buildScoped(BuildContext, StoreSpace). For the signature you actually wrote, this is a compile error, not a silent change (StoreSpace is neither a subtype nor a supertype of Listenable, so the override is invalid — adding covariant does not rescue it either), and it lands on the exact line that needs attention. Migration: change the parameter type and use space.bind(p), or keep the old call as context.store.bindWith(p, space.scope).
      • One exception, if you are in the habit of loosening parameter types: declaring the scope parameter as dynamic or Object is a valid override (parameters are checked contravariantly), so such code still compiles and instead fails at runtime with type 'StoreSpace' is not a subtype of type 'Listenable' on the first build. Grep for buildScoped before upgrading if that describes your code.
    • Consequence of the above: a StatelessWidget using this mixin now resolves the ambient Store on every build, so one used outside any StoreScope throws where it previously did not. If all you want is a disposal Listenable with no DI container, use a DisposeStateNotifier in a StatefulWidget directly. 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 a context.store call 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). space re-checks the ambient Store on every read and releases the superseded scope via addPostFrameCallback, which is correct during a build — running instance disposers mid-build can trip "markNeedsBuild called during build" — but addPostFrameCallback does 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 GlobalKey reparent beneath a different StoreScope that no intervening build notices (easy for a widget that reads space conditionally), followed by a read of space from an onPressed, a Timer, or a stream listener. ScopedStateMixin is the exposed one, since its space is a public getter; on the StatelessWidget side the space is only ever read from build, 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 later addCloseable / addKeyedCloseable callback and super.dispose() — leaving the notifier alive with its listeners attached, while the Store's catch reduced the whole thing to one log line. Failures are now collected, the teardown (including the owning provider's disposer) runs to completion, and only then is the failure surfaced per StoreScopeConfig.throwOnCloseError.
  • addKeyedCloseable no 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 at dispose().
  • DisposeStateNotifier.dispose() is now fully idempotent. A second call used to reach ChangeNotifier.dispose() again, tripping a debug-only assertion — so defensive double-teardown (and re-entrant disposal from inside notifyListeners()) crashed in development and passed silently in release.
  • A provider whose value is legitimately null (nullable T) 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 returned 42 (or a const object, 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.
  • addSubscription now honours the disposed state, as its docs always claimed. It appended straight to the internal set instead of going through addCloseable, 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 in init() that only completes after the binding scope died. It also takes subscription.cancel as 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.create allocated the instance scope, ran the creator, and only then registered the scope with the instance-scope manager — so when the creator (or a ViewModel.init()) failed after a space.bind, that scope never reached the manager and could never be disposed. The child stayed in the store until unmount(), and its refcount gained a permanent +1 on 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 dispose is now reported through FlutterError.reportError instead 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 with expectLater(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; if FlutterError.onError itself throws (e.g. (d) => throw d.exception), reporting falls back to StoreScopeConfig.log rather 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: hook FlutterError.onError instead.
  • BREAKING: during scope-driven teardown, the instance is removed from the store before its dispose runs. store.exists(p) / store.find(p) observed from inside p's own disposer now report "gone" rather than "still there", matching what the unmount() path already reported.

  • StoreScopeConfig is exported from package:store_scope/store_scope.dart (was reachable only via an implementation import). Exported with show StoreScopeConfig on purpose: the top-level LogWriterCallback typedef and defaultLogWriterCallback function carry GetX's exact names and would make any file importing both packages unprefixed fail with ambiguous_import.

  • BREAKING: when StoreScopeConfig.throwOnCloseError surfaces a cleanup failure, the original error is rethrown with its original stack trace instead of an Exception wrapping a stringified one. Tests can now match on the type the callback actually threw, and the stack points at the callback instead of at ViewModel.dispose — but an existing throwsA(isA<Exception>()) now fails for a callback that threw an Error subtype (StateError is an Error, not an Exception). Migration: match the real type, e.g. throwsStateError.

  • When several cleanup callbacks fail in one teardown, each is reported as its own StoreScopeConfig.log entry 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 with throwOnCloseError the first is rethrown and the rest are logged.

  • The equatable constraint is now ^2.1.0 (was ^2.0.7). Argument providers mix in Equatable instead of the deprecated EquatableMixin; Equatable only became usable as a mixin in 2.1.0. The comparison semantics are unchanged — EquatableMixin and Equatable have identical == / hashCode / toString implementations.

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(), no dispose(), even for a ViewModelProvider target. Added the two recipes (run the hooks yourself; or overrideWith((space) => Fake(space)..init(), dispose: (vm) => vm.dispose()) when the fake's setup needs a live StoreSpace) and the guidance to override a ViewModel's dependencies when the real lifecycle is what's under test.
  • Documented that a StoreScopeConfig.log implementation must not throw. It is called from the middle of binding and teardown — including the last-resort branch that runs after FlutterError.onError has already failed — and those call sites are deliberately unguarded, so a throwing sink would break bindWith outright 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: [...]) or StoreImpl(overrides: [...]), built with provider.overrideWithValue(fake) (caller owns the instance's lifecycle) or provider.overrideWith((space) => fake, dispose: ...).
  • BREAKING: acquiring a store-lifetime instance is now Store.share / StoreSpace.share / context.share (was read). It accepts only a SharedProvider and pairs with the Provider.shared(...) definition. Migration: replace read( with share( at those call sites.
  • Added store-lifetime argument providers: mark a withArgument factory .asShared at the definition site, then acquire per-argument singletons with store.share(p(arg)). Keyed by the argument's value and alive until the Store is unmounted; available on every arity (withArgument..withArgument6) for both Provider and ViewModelProvider. 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 / createViewModel helper methods were removed. Acquire instances the usual way — call the factory (factory(arg)) then space.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-existent DisposeStateAwareMixin).

0.1.0 #

  • BREAKING: Instance lifetime is now declared on the provider. Store.shared() is removed — define store-lifetime instances with Provider.shared(...) / ViewModelProvider.shared(...) and read them via the scope-free, statically typed Store.read / context.read. Scoped providers keep using bind / bindWith.
  • Fix: Store.unmount() now disposes every instance; ViewModels and their subscriptions were previously leaked on teardown.
  • Fix: ViewModel.dispose() no longer throws ConcurrentModificationError when a closeable registers another closeable.
  • Fix: swapping StoreScope.storeOwner no longer throws LateInitializationError.
  • 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.
0
likes
160
points
235
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Flutter's Jetpack ViewModel — widget-tree-scoped dependency injection with automatic disposal, zero codegen, and bring-your-own reactivity.

Repository (GitHub)
View/report issues
Contributing

License

MIT (license)

Dependencies

equatable, flutter

More

Packages that depend on store_scope