Provider: dispose() of ChangeNotifier is called sometimes after the notifier was disposed of if popUntil() is used

Created on 30 Jul 2020  ยท  5Comments  ยท  Source: rrousselGit/provider

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.

bug

All 5 comments

@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.

https://github.com/rrousselGit/provider/blob/500fa54a6fc2a4b946b729c84cc1c128b5fcbd53/lib/src/listenable_provider.dart#L88-L94

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.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

stocksp picture stocksp  ยท  5Comments

mhd-barikhan picture mhd-barikhan  ยท  5Comments

BazinC picture BazinC  ยท  6Comments

usherwalce picture usherwalce  ยท  5Comments

sanekyy picture sanekyy  ยท  5Comments