0

I have app with a simple login system based in named routes and Navigator. When login is successful, the loggin route si pop from stack and the first route (home) is pushed, using Navigator.popAndPushNamed,'/first'). When the user is logged the routes of the app (except from login route) are correctly push and pop from stack to allow a smooth navigation. When the user decides to log out, all routes are removed from stack and the login route is pushed, using Navigator.pushNamedAndRemoveUntil(context, '/', (route) => false). All of that is working fine, but the problem is the user logs again, because the first route (a statefulwidget) is being associated with its previous State which was previously disposed, so the mounted property is false. That's generating that the State properties not being correctly initialized and the error "setState() calls after dispose()" is being shown. It's an example of the login system based in named routes and Navigator that I'm using in my app.

import 'dart:async';
import 'package:flutter/material.dart';

void main() {
  runApp(
    MaterialApp(
      title: 'Named Routes Demo',
      initialRoute: '/',
      routes: {
        '/': (context) => const LoginScreen(),
        '/first': (context) => FirstScreen(),
        '/second': (context) => const SecondScreen(),
      },
    ),
  );
}

class LoginScreen extends StatelessWidget {
  const LoginScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Loggin screen'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () => Navigator.popAndPushNamed(context, '/first'),
          child: const Text('Launch app'),
        ),
      ),
    );
  }
}

class FirstScreen extends StatefulWidget {
  const FirstScreen({super.key});
  FirstState createState() => FirstState();
}

class FirstState extends State<FirstScreen> {
  int cont;
  Timer? t;
  final String a;

  FirstState() : cont = 0, a='a' {
    debugPrint("Creando estado de First screen");
  }

  @override
  void initState() {
    super.initState();
    debugPrint("Inicializando estado de First Screen");
    cont = 10;
    t = Timer.periodic(Duration(seconds: 1), (timer) => setState(() => cont++));
  }

  @override
  void dispose() {
    debugPrint("Eliminando estado de First Screen");
    cont = 0;
    t?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('First Screen'),
      ),
      body: Center(child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text("Contador: $cont"),
            SizedBox(height: 50,),
            ElevatedButton(
              // Within the `FirstScreen` widget
              onPressed: () {
                // Navigate to the second screen using a named route.
                Navigator.pushNamed(context, '/second');
              },
              child: const Text('Go to second screen'),
            ),
          ]
        )
      ),
    );
  }
}

class SecondScreen extends StatelessWidget {
  const SecondScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Second Screen'),
      ),
      body: Center(child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              ElevatedButton(
                // Within the SecondScreen widget
                onPressed: () {
                  // Navigate back to the first screen by popping the current route
                  // off the stack.
                  Navigator.pop(context);
                },
                child: const Text('Go back'),
              ),
              ElevatedButton(
                // Within the SecondScreen widget
                onPressed: () {
                  // Navigate back to the first screen by popping the current route
                  // off the stack.
                  Navigator.pushNamedAndRemoveUntil(context, '/', (route) => false);
                },
                child: const Text('Logout'),
              )
            ]
        )
      )
    );
  }
}

However, the example is not showing the described error, so I'm suspecting the cause could be an uncaught exception during the state dispose. I'm using Flutter 3.7.12 (Dart 2.19.6). I've not updated to avoid to restructure code to be compatible with Dart 3 (null safety). Another detail is that the error appears sometimes and mainly in Android.

2 Answers2

0

You are probably using setState in some async method and with some unlucky timing your setState call happens after some delay while the state object is already disposed.

You can use the mounted flag inside of your async method to check if the state is still mounted instead of manually cancelling all futures, etc inside of dispose.

Without seeing your concrete code, it's tough to say exactly when and where your error is happening.

Regarding your example, there is nothing wrong with it and flutter will never associate a widget with a dismounted state object.

Niko
  • 550
  • 1
  • 6
  • Hi Niko, I'm checking `mounted` before every calling to `setState()`. I'm also printing to console if some code tries to set state when the State is unmounted, but I'm seeing any log about that. I have several timers in the State which execute periodically async functions, but I cancel all of them in the `dispose()` method. Could it be because of the emulator? – MauroDiamantino Jul 04 '23 at 19:48
  • Can you maybe include a snippet of the affected widget inside of your initial question if you know where the error happens? Some async method is most likely the cause, but its hard to say without seeing your code. – Niko Jul 04 '23 at 19:59
  • Is your error resolved? – Niko Jul 18 '23 at 03:00
  • Hi Niko, yes, I was able to solve it as you can see in my own answer. – MauroDiamantino Jul 19 '23 at 13:10
0

The problem was that I was not closing the subscriptions to the Firebase Cloud Messaging (FCM) streams. Thanks to this post I could discover that State objects remain alive after they are disposed, so if you leave active a timer, stream subscription or sth like that, it will continue executing and generating results. So it is very important to close or cancel that kind of State properties. With respect to FCM stream subscriptions, they should be handled as following:

class _AppState extends State<_App> {

    @override
    void initState() {
        ...
        FirebaseMessaging.instance.getInitialMessage().then(handleInteraction);
        _suscrStreamFCMAppBackgnd = FirebaseMessaging.onMessageOpenedApp.listen(handleInteraction);
        FirebaseMessaging.onBackgroundMessage(procesarNotificacion);
        _suscrStreamFCMAppForegnd = FirebaseMessaging.onMessage.listen(_procesarNotificacionAppPrimerPlano);
        for (final topic in TOPICS_FIREBASE) {
          FirebaseMessaging.instance.subscribeToTopic(topic);
        }
        ...
    }
    
    @override
    void dispose() {
        ...
        _suscrStreamFCMAppBackgnd?.cancel();
        _suscrStreamFCMAppForegnd?.cancel();
        ...
    }
}

So, the old State (unmounted) hadn't been reassociated to the StatefulWidget object, but it remained alive and executing code in the background beacuse of the stream subscriptions.