flutterstreamfirebase-cloud-messagingnavigatorflutter-state

State is reassociated after disposed() rather than create a new one


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.


Solution

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