disposito

Analyzer plugin for object lifetimes in Dart and Flutter, delivered as an analyzer plugin, so the rules run in your IDE and in dart analyze / flutter analyze with no extra tooling.

A resource you allocate has to be released, and the compiler will not remind you. Mark a field with @Disposable(), and the analyzer objects when nothing in the class ever cleans it up. Three further rules catch the Flutter-specific ways an object outlives or underlives the thing that owns it.

Rule What it catches
missing_dispose An instance field marked @Disposable() that the declaring class never cleans up.
stateful_in_build An object that owns a lifecycle (a ChangeNotifier, a controller, a StreamController, a Timer) created inside a Flutter build.
this_in_dispose A State handing out a reference to itself from its own dispose, where it is already being torn down.
late_initialized_in_dispose A late field of a State whose initializer needs the state object, and which nothing reads before dispose, so it is built during teardown.

Requires Dart 3.11 or later (analyzer plugins are not supported before that).

Note on version 1.0.0. Up to 0.2.3 this package was a runtime disposal library (DisposeHolder, DisposeHolderHostMixin, GC-triggered cleanup). It is now an analyzer plugin instead: static rules that catch a missing dispose at author time rather than machinery that runs one for you. The two have nothing in common, so 1.0.0 shares no API with 0.2.3. Pin disposito: ^0.2.3 to stay on the runtime library; its source remains on the runtime-library-v0.2.3 branch.

Installation

The package ships both the annotation and the rules, so it is listed twice: once as a dependency (you write @Disposable in your code) and once as a plugin (the analyzer runs the rules).

# pubspec.yaml
dependencies:
  disposito: ^1.0.0
# analysis_options.yaml
plugins:
  disposito: ^1.0.0

Every rule is registered as a warning rule, so they are all active as soon as the plugin is enabled. Restart the Dart Analysis Server after changing the plugins section.

stateful_in_build, this_in_dispose and late_initialized_in_dispose are Flutter-specific, and cost nothing in a package that does not depend on Flutter: with no Widget or State in scope, nothing they look for can exist.

missing_dispose

Annotate a field that owns a resource:

class Editor {
  @Disposable()
  final StreamSubscription<Event> _events;

  void dispose() => _events.cancel();  // no warning
}

The rule accepts dispose, close and cancel by default. Add one more name with a symbol:

@Disposable(#detach)
final Listener _listener;

void dispose() => _listener.detach();  // no warning

Cleanup is recognised anywhere in the class (not only in a method called dispose), and through all of these forms:

_c.dispose();          // direct
this._c.dispose();     // explicitly qualified
_c?.dispose();         // null-aware
_c!.dispose();         // null-asserted
_c..dispose();         // cascade
await _c.close();      // awaited
unawaited(_c.close()); // wrapped

final alias = _c;      // local alias, and chains of them
alias.dispose();

Conn get conn => _c;   // a getter that forwards to the field
void dispose() => conn.dispose();

(flag ? _a : _b).dispose();      // either branch counts
for (final c in [_a, _b]) {      // every element of the literal counts
  c.dispose();
}

_disposeIt(_c);        // a helper whose body disposes the parameter
manager.release(_c);   // a callee whose *name* is a cleanup verb

A field is reported when none of the above applies, for example when only a different field is cleaned up, when the field is merely passed to print, or when an alias is reassigned before being disposed.

Quick fix

The warning comes with a fix that writes the cleanup for you. It puts the call in the disposal method the class already has, creating one when there is none:

class Editor {
  @Disposable()
  final TextEditingController _controller = TextEditingController();
}

// After applying the fix:
class Editor {
  @Disposable()
  final TextEditingController _controller = TextEditingController();

  void dispose() {
    _controller.dispose();
  }
}

What it works out for you:

  • Which method to call. @Disposable(#detach) wins when the type really has a detach; otherwise dispose, close and cancel are tried in that order, including methods inherited from a supertype. A nullable field is cleaned up with ?..
  • Where the call goes. An existing dispose, close or cancel gains the statement; void dispose() => _a.dispose(); becomes a block holding both calls. The call is inserted before a trailing super.dispose(), which has to run last.
  • What the new method looks like. In a Flutter State, or any class that inherits a dispose, it is written as an @override ending in super.dispose(). A cleanup that returns a Future makes the new method Future<void> dispose() async and the call awaited; inside an existing synchronous method the call is left unawaited rather than changing the method's signature.

No fix is offered when the field's type has no method that can be called without arguments (so void close(int code) does not count, and neither does a @Disposable(#detach) on a type with no detach, where writing the call would replace the warning with a compile error), or when the class's dispose is abstract or external and so has no body to write into.

Quick fixes from an analyzer plugin are offered by the IDE, through the same lightbulb as the built-in ones. They are not applied by dart fix, which only knows about the SDK's own fixes; this is a limitation of the plugin API as of analysis_server_plugin 0.3.x, not of a particular fix's applicability.

stateful_in_build

A build method runs again on every rebuild. An object created inside one is therefore thrown away and replaced on each frame, and the replaced instance keeps its listeners, timers and subscriptions alive:

class _PageState extends State<Page> {
  @override
  Widget build(BuildContext context) {
    // reported: a new controller every frame, and the old one is never
    // disposed. The text field also resets its content on every rebuild.
    final controller = TextEditingController();
    return TextField(controller: controller);
  }
}

The fix is the ordinary Flutter shape, which the rule accepts:

class _PageState extends State<Page> {
  late final TextEditingController _controller;

  @override
  void initState() {
    super.initState();
    _controller = TextEditingController();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => TextField(controller: _controller);
}

What counts as owning a lifecycle

A type owns a lifecycle when it declares or inherits a dispose, close or cancel method that can be called with no arguments, or when it is annotated with @Disposable(). That is the same definition missing_dispose uses, so the two rules always agree about which objects are resources.

Recognising the shape rather than a list of names means there is no catalogue to maintain, and your own types are covered for free:

ChangeNotifier         // dispose
ValueNotifier          // dispose
TextEditingController  // dispose
ScrollController       // dispose
TabController          // dispose
AnimationController    // dispose
FocusNode              // dispose
StreamController       // close
Timer                  // cancel
YourBloc               // whatever you called it, as long as it cleans up

Widgets are excluded, because rebuilding them is precisely what a build is for, even when the matching State declares dispose.

What counts as a build

Any function that produces widgets, identified by its return type rather than by its name:

Widget build(BuildContext context) { ... }     // the build method itself
Widget _buildHeader() { ... }                  // a widget-returning helper
List<Widget> _buildActions() { ... }           // a helper returning several
Builder(builder: (context) { ... })            // a builder callback

A callback that returns something else is not a build, so creating a controller in response to an event is accepted:

GestureDetector(
  onTap: () {
    final controller = TextEditingController(); // fine: runs on a tap
  },
  child: child,
)

What counts as creating

Constructor calls, including named ones and dot shorthands, plus the two shapes that create without looking like a constructor:

Ticker()                        // reported
Ticker.named()                  // reported
StreamController.broadcast()    // reported: a static returning its own class
stream.listen(onEvent)          // reported: a fresh subscription every call

A static method that returns somebody else's object is a lookup, not a creation, so the ubiquitous service-locator shape is left alone:

final model = Provider.of<Model>(context);  // fine: finds an existing object
final theme = Theme.of(context);            // fine

Two further forms are accepted because they create at most one object across every rebuild:

const Frozen();                       // canonicalized: always the same instance
_controller ??= TextEditingController();  // lazy: created once, then reused

this_in_dispose

The mirror image of stateful_in_build: there an object is created too late, here a reference to one escapes too late.

By the time State.dispose runs the object is being torn down. Its context is about to become unusable, widget is about to be detached, and calling setState on it afterwards throws. Handing this to somebody else at exactly that moment either pins the whole element tree through the retained reference, or gives the receiver an object that is already unusable:

class _PageState extends State<Page> {
  @override
  void dispose() {
    // reported: the registry outlives this page and now holds a dead State.
    PageRegistry.remove(this);
    // reported: the callback runs after dispose has already returned.
    scheduleMicrotask(() => PageRegistry.add(this));
    super.dispose();
  }
}

Reading your own members is not the problem, and dispose exists to do exactly that, so ordinary cleanup stays clean:

@override
void dispose() {
  _focus.removeListener(_onFocusChange); // tear-off, no `this` expression
  _focus.dispose();
  _controller.dispose();
  this._other.dispose();                 // explicit `this.` is still a read
  super.dispose();
}

What counts as escaping

Only the ways the reference leaves the object:

register(this);                  // an argument
Ticker(vsync: this);             // a named argument is an argument
holder = this;                   // an assigned value
final self = this;               // an assigned value
() => this                       // a returned value
register([this]);                // a collection element

(this), this! and this as Foo are looked through, because they all still denote the same object.

Where it applies

Only in dispose of a Flutter State, found by walking the class hierarchy, so a subclass of a subclass is still covered. The same code elsewhere is left alone, because registering yourself is the normal lifecycle:

@override
void initState() {
  super.initState();
  PageRegistry.add(this);   // fine: this is what dispose is meant to undo
}

A plain class with a dispose method is not a Flutter State, so the framework is not tearing it down and what it does with this is its own business.

late_initialized_in_dispose

A late field with an initializer is lazy: the initializer runs on the first read. When the only read is in dispose, the object is therefore built while the State is being torn down:

class _PageState extends State<Page> with SingleTickerProviderStateMixin {
  // reported: nothing reads this before dispose, so the controller is
  // CREATED inside dispose, registering a ticker on a dying State.
  late final AnimationController _fade = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 300),
  );

  @override
  void dispose() {
    _fade.dispose();   // the only read: this line also constructs it
    super.dispose();
  }
}

This is not theoretical. Mounting and unmounting that widget in a flutter test shows the field constructed after dispose has begun, and the two concrete failure modes are:

  • two such controllers on a SingleTickerProviderStateMixin throw "... is a SingleTickerProviderStateMixin but multiple tickers were created" from inside dispose;
  • an initializer that reads context throws "Null check operator used on a null value" while the tree is being finalized, because the element's widget is already detached.

Those three behaviours are asserted against the real framework in example_flutter/test/late_initialized_in_dispose_runtime_test.dart, so the justification for the rule is a test rather than a claim. dart analyze reports the rule on exactly the three fixtures that misbehave at runtime.

The fix is to stop being lazy:

late final AnimationController _fade;

@override
void initState() {
  super.initState();
  _fade = AnimationController(vsync: this, duration: _kFade);
}

or simply to read the field where it is actually used, which is what makes the lazy idiom legitimate:

@override
Widget build(BuildContext context) =>
    FadeTransition(opacity: _fade, child: child);  // read while alive

When it fires

All three must hold, which keeps the rule quiet on ordinary code:

  1. the field is late and has an initializer (a bare late final X _x; is assigned explicitly, so it is not lazy);
  2. the initializer depends on the state object, through this (which covers vsync: this), context or widget;
  3. no member other than dispose reads it.

A read anywhere else clears it, including initState, build, another method and a closure. A write does not, because assigning a late field never runs its initializer, while a compound assignment such as _n += 1 does read first and therefore counts.

Turning rules off

Disable a rule for the whole package:

plugins:
  disposito:
    diagnostics:
      missing_dispose: false

Suppress one diagnostic with a comment, prefixed by the plugin name. The comment applies to the line below it, and missing_dispose reports on the field's name, so place it under the annotation:

@Disposable()
// ignore: disposito/missing_dispose
final Connection _connection;

// ignore_for_file: disposito/missing_dispose works as well.

Known limits

These are deliberate boundaries of the first version, not bugs:

  • Helper bodies are only read inside the library being analyzed. When a helper lives in another library, the rule falls back to trusting its name (dispose, close, cancel, cleanup, release, shutdown). Helper chains are followed four functions deep; a fifth hop is reported even if it does dispose the resource.
  • Aliases are local and single-assignment. An alias is tracked only within one function body, and only when the local is never reassigned. Aliasing via another field, or via a parameter, is not tracked. A loop variable aliases the elements of a literal collection only (for (final c in [_a, _b])), not of an arbitrary expression.
  • missing_dispose covers instance fields only. Static fields and top-level variables are ignored.
  • stateful_in_build judges each function on its own signature. A closure that returns a widget is treated as a build wherever it is written, so a builder stored in a variable is covered, but one that is built lazily and genuinely runs only once is reported too. A helper that returns something other than widgets hides its body from the rule, even when the build calls it, since the rule does not follow calls across functions.
  • stateful_in_build only sees creation at the call site. A factory function of your own (makeController()) is not reported, because its return type does not tell the rule whether it allocated or looked up. The two recognised indirect forms are a static on the class being created and Stream.listen.
  • No quick fixes yet. The rules report, they do not rewrite code.

Examples

Two example packages are wired to this one by path, and are the end-to-end check that the plugin loads and fires:

cd example          # pure Dart: missing_dispose
dart pub get
dart analyze lib/example.dart
cd example_flutter  # the real Flutter SDK: stateful_in_build,
flutter pub get     # this_in_dispose and late_initialized_in_dispose
dart analyze lib/example.dart   # against real controllers and State
flutter test        # the runtime proof behind late_initialized_in_dispose

Both deliberately contain violations, so dart analyze exits non-zero there. Use dart analyze, not flutter analyze: the Flutter wrapper runs its own bundled analysis and drops diagnostics that come from a third-party analyzer plugin.

Development

dart analyze   # must be clean
dart test      # rule matrices, quick fixes, plugin registration

License

MIT. See LICENSE.

Libraries

disposito
The @Disposable annotation for the disposito analyzer plugin.
main
The entry point of the disposito analyzer plugin.