When screens using a local ChangeNotifier are popped with popUntil(), the dispose() method of the notifier is called not only by provider but also by Dart itself, and the order of the calls varies from time to time. If the notifier has already been disposed of when provider tries to call its dispose(), it causes an assertion error as follows:
โโโโโโโโ Exception caught by widgets library โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The following assertion was thrown while finalizing the widget tree:
A XxxxxNotifier was used after being disposed.
Once you have called dispose() on a XxxxxNotifier, it can no longer be used.
When the exception was thrown, this was the stack:
#0 ChangeNotifier._debugAssertNotDisposed.<anonymous closure> (package:flutter/src/foundation/change_notifier.dart:106:9)
#1 ChangeNotifier._debugAssertNotDisposed (package:flutter/src/foundation/change_notifier.dart:112:6)
#2 ChangeNotifier.removeListener (package:flutter/src/foundation/change_notifier.dart:167:12)
#3 ListenableProvider._startListening.<anonymous closure> (package:provider/src/listenable_provider.dart:93:25)
#4 _ValueInheritedProviderState.dispose (package:provider/src/inherited_provider.dart:820:22)
...
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
To Reproduce
class LocalNotifier with ChangeNotifier {
String text = 'Text stored in LocalNotifier.';
@override
void dispose() {
print('LocalNotifier was disposed.');
super.dispose();
}
}
void main() => runApp(App());
class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: FirstScreen(),
);
}
}
class FirstScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
print('FirstScreen');
return Scaffold(
appBar: AppBar(title: const Text('First screen')),
body: Center(
child: RaisedButton(
child: const Text('To second screen'),
onPressed: () => Navigator.of(context)
.push(
MaterialPageRoute<void>(
builder: (_) => ChangeNotifierProvider<LocalNotifier>(
create: (_) => LocalNotifier(),
child: SecondScreen(),
),
),
)
.then((_) => print('Popped to FirstScreen.')),
),
),
);
}
}
class SecondScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
print('SecondScreen');
return Scaffold(
appBar: AppBar(title: const Text('Second screen')),
body: Center(
child: RaisedButton(
child: const Text('To third screen'),
onPressed: () => Navigator.of(context)
.push(
MaterialPageRoute<void>(
builder: (_) => ChangeNotifierProvider<LocalNotifier>.value(
value: context.read<LocalNotifier>(),
child: ThirdScreen(),
),
),
)
.then((_) => print('Popped to SecondScreen.')),
),
),
);
}
}
class ThirdScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
print('ThirdScreen');
// The error does not occur without this.
final text = context.select((LocalNotifier notifier) => notifier.text);
print(text);
return Scaffold(
appBar: AppBar(title: const Text('Third screen')),
body: Center(
child: RaisedButton(
child: const Text('Back to first screen'),
onPressed: () => Navigator.of(context).popUntil((route) => route.isFirst),
),
),
);
}
}
In order to confirm what is happening, I added print("$T's dispose() was called by provider."); to the beginning of dispose() of _ValueInheritedProviderState in src/inherited_provider.dart.
The output of the above code:
I/flutter (23644): FirstScreen
I/flutter (23644): SecondScreen
I/flutter (23644): ThirdScreen
I/flutter (23644): Obtained text from LocalNotifier: "Text stored in LocalNotifier."
I/flutter (23644): Popped to SecondScreen.
I/flutter (23644): Popped to FirstScreen.
I/flutter (23644): LocalNotifier was disposed.
I/flutter (23644): LocalNotifier's dispose() was called by provider.
โโโโโโโโ Exception caught by widgets library โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
...
If the last two lines before the error description are in the opposite order like below, no error is printed.
I/flutter (23644): LocalNotifier's dispose() was called by provider.
I/flutter (23644): LocalNotifier was disposed.
Expected behavior
No error.
https://github.com/flutter/flutter/issues/34693 seems related.
@mono0926 Wow, it definitely is!
I'm getting the same error in a different situation, I think the main problem here is that ListenableProvider is not taking into consideration whether the Listenable has already been disposed of.
The example below will trigger the error:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
void main() => runApp(const App());
class App extends StatelessWidget {
const App({Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(
body: Center(
child: SomeContainer(),
),
),
);
}
}
class SomeContainer extends StatefulWidget {
const SomeContainer({Key key}) : super(key: key);
@override
_SomeContainerState createState() => _SomeContainerState();
}
class _SomeContainerState extends State<SomeContainer> {
bool _beGone = false;
@override
Widget build(BuildContext context) {
return _beGone
? const Text('TestPage is gone')
: TestPage(
closeMe: () => setState(() => _beGone = true),
);
}
}
class TestPage extends StatefulWidget {
const TestPage({
Key key,
@required this.closeMe,
}) : super(key: key);
final VoidCallback closeMe;
@override
_TestPageState createState() => _TestPageState();
}
class _TestPageState extends State<TestPage> {
ValueNotifier<int> state = ValueNotifier<int>(0);
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider<ValueNotifier<int>>.value(
value: state,
child: Consumer<ValueNotifier<int>>(
builder: (_, _state, __) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
RaisedButton(
onPressed: _disposeState,
child: Text(
'Press me (${_state.value})',
),
),
],
);
},
),
);
}
void _disposeState() {
// I own the state, I close the state.
state.dispose();
// cause widget containing the change notifier provider
// and consumer to be removed.
widget.closeMe();
}
}
I think the main problem here is that ListenableProvider is not taking into consideration whether the Listenable has already been disposed of.
It is your responsibility to not dispose the Listenable before ListenableProvider is removed from the widget tree.
If you want to both dispose the Listenable and unmount the widget in a quick succession, delay the dispose to the end of the frame:
void _disposeState() {
// I own the state, I close the state.
Future.microtask(state.dispose);
// Now works correctly
widget.closeMe();
}
I'll close this issue since this is a bug in Flutter rather than a bug in Provider.
If this still happens after https://github.com/flutter/flutter/issues/34693, I will come back to this issue to investigate more.