Waiting asynchronously for Navigator.push() - linter warning appears: use_build_context_synchronously

663

Short answer:

It's NOT SAFE to always ignore this warning, even in a Stateless Widget.

A workaround in this case is to use the context before the async call. For example, find the Navigator and store it as a variable. This way you are passing the Navigator around, not passing the BuildContext around, like so:

onPressed: () async {
  final navigator = Navigator.of(context); // store the Navigator
  await showDialog(
    context: context,
    builder: (_) => AlertDialog(
      title: Text('Dialog Title'),
    ),
  );
  navigator.pop(); // use the Navigator, not the BuildContext
},

Long answer:

This warning essentially reminds you that, after an async call, the BuildContext might not be valid anymore. There are several reasons for the BuildContext to become invalid, for example, having the original widget destroyed during the waiting, could be one of the (leading) reasons. This is why it's a good idea to check if your stateful widget is still mounted.

However, we cannot check mounted on stateless widgets, but it absolutely does not mean they cannot become unmounted during the wait. If conditions are met, they can become unmounted too! For example, if their parent widget is stateful, and if their parent triggered a rebuild during the wait, and if somehow a stateless widget's parameter is changed, or if its key is different, it will be destroyed and recreated. This will make the old BuildContext invalid, and will result in a crash if you try to use the old context.

To demonstrate the danger, I created a small project. In the TestPage (Stateful Widget), I'm refreshing it every 500 ms, so the build function is called frequently. Then I made 2 buttons, both open a dialog then try to pop the current page (like you described in the question). One of them stores the Navigator before opening the dialog, the other one dangerously uses the BuildContext after the async call (like you described in the question). After clicking a button, if you sit and wait on the alert dialog for a few seconds, then exit it (by clicking anywhere outside the dialog), the safer button works as expected and pops the current page, while the other button does not.

The error it prints out is:

[VERBOSE-2:ui_dart_state.cc(209)] Unhandled Exception: Looking up a deactivated widget's ancestor is unsafe. At this point the state of the widget's element tree is no longer stable. To safely refer to a widget's ancestor in its dispose() method, save a reference to the ancestor by calling dependOnInheritedWidgetOfExactType() in the widget's didChangeDependencies() method. #0 Element._debugCheckStateIsActiveForAncestorLookup. (package:flutter/src/widgets/framework.dart:4032:9) #1 Element._debugCheckStateIsActiveForAncestorLookup (package:flutter/src/widgets/framework.dart:4046:6) #2 Element.findAncestorStateOfType (package:flutter/src/widgets/framework.dart:4093:12) #3 Navigator.of (package:flutter/src/widgets/navigator.dart:2736:40) #4 MyDangerousButton.build. (package:helloworld/main.dart:114:19)

Full source code demonstrating the problem:

import 'dart:async';

import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: HomePage(),
    );
  }
}

class HomePage extends StatelessWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Home Page')),
      body: Center(
        child: ElevatedButton(
          child: Text('Open Test Page'),
          onPressed: () {
            Navigator.of(context).push(
              MaterialPageRoute(builder: (_) => TestPage()),
            );
          },
        ),
      ),
    );
  }
}

class TestPage extends StatefulWidget {
  @override
  State<TestPage> createState() => _TestPageState();
}

class _TestPageState extends State<TestPage> {
  late final Timer timer;

  @override
  void initState() {
    super.initState();
    timer = Timer.periodic(Duration(milliseconds: 500), (timer) {
      setState(() {});
    });
  }

  @override
  void dispose() {
    timer.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final time = DateTime.now().millisecondsSinceEpoch;
    return Scaffold(
      appBar: AppBar(title: Text('Test Page')),
      body: Center(
        child: Column(
          children: [
            Text('Current Time: $time'),
            MySafeButton(key: UniqueKey()),
            MyDangerousButton(key: UniqueKey()),
          ],
        ),
      ),
    );
  }
}

class MySafeButton extends StatelessWidget {
  const MySafeButton({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      child: Text('Open Dialog Then Pop Safely'),
      onPressed: () async {
        final navigator = Navigator.of(context);
        await showDialog(
          context: context,
          builder: (_) => AlertDialog(
            title: Text('Dialog Title'),
          ),
        );
        navigator.pop();
      },
    );
  }
}

class MyDangerousButton extends StatelessWidget {
  const MyDangerousButton({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      child: Text('Open Dialog Then Pop Dangerously'),
      onPressed: () async {
        await showDialog(
          context: context,
          builder: (_) => AlertDialog(
            title: Text('Dialog Title'),
          ),
        );
        Navigator.of(context).pop();
      },
    );
  }
}
Share:
663
Schnodderbalken
Author by

Schnodderbalken

I am the liquid that comes out of your nose. My shape is a timber. In Germany we call that thing SCHNODDERBALKEN!

Updated on January 01, 2023

Comments

  • Schnodderbalken
    Schnodderbalken 10 months

    In Flutter, all Navigator functions that push a new element onto the navigation stack return a Future as it's possible for the caller to wait for the execution and handle the result.

    I make heavy use of it e. g. when redirecting the user (via push()) to a new page. As the user finishes the interaction with that page I sometimes want the original page to also pop():

    onTap: () async {
      await Navigator.of(context).pushNamed(
        RoomAddPage.routeName,
        arguments: room,
      );
    
      Navigator.of(context).pop();
    },
    

    A common example is the usage of a bottom sheet with a button with a sensitive action (like deleting an entity). When a user clicks the button, another bottom sheet is opened that asks for the confirmation. When the user confirms, the confirm dialog is to be dismissed, as well as the first bottom sheet that opened the confirm bottom sheet.

    So basically the onTap property of the DELETE button inside the bottom sheet looks like this:

    onTap: () async {
      bool deleteConfirmed = await showModalBottomSheet<bool>(/* open the confirm dialog */);
      if (deleteConfirmed) {
        Navigator.of(context).pop();
      }
    },
    

    Everything is fine with this approach. The only problem I have is that the linter raises a warning: use_build_context_synchronously because I use the same BuildContext after the completion of an async function.

    Is it safe for me to ignore / suspend this warning? But how would I wait for a push action on the navigation stack with a follow-up code where I use the same BuildContext? Is there a proper alternative? There has to be a possibility to do that, right?

    PS: I can not and I do not want to check for the mounted property as I am not using StatefulWidget.

  • Schnodderbalken
    Schnodderbalken about 2 years
    Excellent explanation and understandable minimal example! Thank you. The bounty is yours :). One question though: you label your solution as a "quick workaround". Does it mean that there is a cleaner solution?
  • WSBT
    WSBT about 2 years
    @Schnodderbalken Haha, I was mostly focusing on the word "quick" - in your case (of getting the navigator), this is already a very clean solution. However we might not always be this lucky. For example, if you have a custom function that takes in a BuildContext as a parameter, this "workaround" won't work, so you may have to do some not-so-quick refactoring or think of another workaround. BTW, excellent question tho, it's my first time hearing about this lint rule. Hopefully your question can help more people once this lint is released as a default rule.
  • Schnodderbalken
    Schnodderbalken about 2 years
    I am using the community-driven lint package called lint: pub.dev/packages/lint. This package is more strict than the official one and so I came across this warning. Anyways, thank you again. Have the bounty now ;)
  • Matthew Sisinni
    Matthew Sisinni over 1 year
    I'd like to add that the linter might not catch instances where context is used asynchronously in a Future's .then() or .whenComplete() blocks, but using the context in those blocks may still lead to an error.
  • Wesley Barnes
    Wesley Barnes over 1 year
    Checking if mounted seems to be available inside my Stateless widget -> if (!navigator.mounted) return; Could this maybe solve the issue commented above that you cannot check if mounted in stateless widgets?
  • markhorrocks
    markhorrocks over 1 year
    final navigator = Navigator.of(context); produces a linter error.
  • WSBT
    WSBT over 1 year
    @markhorrocks What's the linter warning message and which lint rule is that?
  • markhorrocks
    markhorrocks over 1 year
    The lint warning is Do not use context across async gaps. On reflection this case may not apply as it is not an async function.
  • Masahiro Aoki
    Masahiro Aoki over 1 year
    Great answer! I made a dartpad based on your sample code. dartpad.dev/?id=0215a7e3c67347c84450697c824842ef