disposito 1.0.1
disposito: ^1.0.1 copied to clipboard
Analyzer plugin for object lifetimes: fields annotated with @Disposable must be cleaned up by the class that declares them, objects that own a lifecycle must not be created during a Flutter build, and [...]
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 missingdisposeat 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. Pindisposito: ^0.2.3to stay on the runtime library; its source remains on theruntime-library-v0.2.3branch.
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 adetach; otherwisedispose,closeandcancelare tried in that order, including methods inherited from a supertype. A nullable field is cleaned up with?.. - Where the call goes. An existing
dispose,closeorcancelgains the statement;void dispose() => _a.dispose();becomes a block holding both calls. The call is inserted before a trailingsuper.dispose(), which has to run last. - What the new method looks like. In a Flutter
State, or any class that inherits adispose, it is written as an@overrideending insuper.dispose(). A cleanup that returns aFuturemakes the new methodFuture<void> dispose() asyncand the callawaited; 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
SingleTickerProviderStateMixinthrow"... is a SingleTickerProviderStateMixin but multiple tickers were created"from insidedispose; - an initializer that reads
contextthrows"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:
- the field is
lateand has an initializer (a barelate final X _x;is assigned explicitly, so it is not lazy); - the initializer depends on the state object, through
this(which coversvsync: this),contextorwidget; - no member other than
disposereads 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_disposecovers instance fields only. Static fields and top-level variables are ignored.stateful_in_buildjudges 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_buildonly 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 andStream.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.