Skip to content

Instantly share code, notes, and snippets.

@jjmerino
Created November 23, 2022 00:07
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jjmerino/fe55c717f2c144936493f0fb49802289 to your computer and use it in GitHub Desktop.
Save jjmerino/fe55c717f2c144936493f0fb49802289 to your computer and use it in GitHub Desktop.
FutureBuilder over Navigator 2.0 may freeze on showDialog
import 'package:flutter/material.dart';
const goodRoute = 'good-route';
const brokenRoute = 'broken-route';
const initialRoute = 'initial-route';
void main() => runApp(MyApp());
/// Small app to reproduce an issue with the Navigator 2.0 API:
/// - When popping the dialog from Navigator.pop(context), it may get stuck.
/// and require the app to restart. Suspending and resuming is not enough.
/// - This app is able to reproduce the problem by creating a tree of the form:
/// [FutureBuilder] -> [Navigator] > [showDialog]
///
/// Seems reproducible under two conditions. (There may be more):
/// - A) Building the navigator pages under a FutureBuilder. This builds the
/// Navigator multiple times with a "different" List that contains the same
/// elements. Navigator checks "pages != oldPages" and the dialog gets
/// removed. This isn't surprising if the dialog was considered part of the
/// prior Paged route, but it is surprising that the overlay remains stuck.
/// - B) The route is replacing a previous page.
///
/// App details:
/// The app implements an [AppRouterDelegate] which uses a plain [String] as its
/// state: 'initial-route/good-route' and so on.
///
/// The UI exposes 3 combinations to test, :
/// - good-route: It works, creating the pages above the Future Builder (A).
/// - initial-route/broken-route: It works when it results in routes added (B).
/// - broken-route: shows a dialog that cannot be dismissed.
class MyApp extends StatefulWidget {
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late GlobalKey<NavigatorState> navigatorKey;
final ValueNotifier<String> stateNode = ValueNotifier<String>(initialRoute);
@override
void initState() {
super.initState();
navigatorKey = GlobalKey<NavigatorState>();
}
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Flutter Demo',
routerDelegate: AppRouterDelegate(
navigatorKey: navigatorKey,
stateNode: stateNode,
),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({
Key? key,
required this.title,
required this.stateNode,
required this.navigatorKey,
}) : super(key: key);
final String title;
final ValueNotifier<String> stateNode;
final GlobalKey<NavigatorState> navigatorKey;
@override
MyHomePageState createState() => MyHomePageState();
}
class MyHomePageState extends State<MyHomePage> {
bool willShowDialog = true;
void _navigate(String route) {
// Navigate to the route, replacing the current route.
// Note: the problem does not happen if we don't replace the route.
widget.stateNode.value = route;
}
@override
void initState() {
super.initState();
if (widget.title != initialRoute && willShowDialog) {
willShowDialog = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
showDialog(
context: context,
builder: (_) => AlertDialog(
content: Text('Route: ${widget.title}!'),
actions: [
TextButton(
child: const Text('Got it!'),
onPressed: () {
Navigator.pop(widget.navigatorKey.currentContext!);
},
)
],
));
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title,
style: const TextStyle(fontFamily: 'ProductSans')),
),
body: Center(
child: Column(children: [
const Spacer(),
if (widget.title != initialRoute)
TextButton(
child: const Text('Back'),
onPressed: () => _navigate('initial-route'),
),
const Spacer(),
if (widget.title == initialRoute) ...[
TextButton(
child: const Text(goodRoute),
onPressed: () => _navigate(goodRoute),
),
TextButton(
child: const Text('$initialRoute/$brokenRoute'),
onPressed: () => _navigate('$initialRoute/$brokenRoute'),
),
TextButton(
child: const Text(brokenRoute),
onPressed: () => _navigate(brokenRoute),
),
const Spacer(),
]
]),
),
);
}
}
/// A minimal [RouterDelegate] to navigate to a [goodRoute] or [brokenRoute] route.
class AppRouterDelegate extends RouterDelegate<String>
with ChangeNotifier, PopNavigatorRouterDelegateMixin {
final ValueNotifier<String> stateNode;
String get currentState => stateNode.value;
@override
final GlobalKey<NavigatorState> navigatorKey;
AppRouterDelegate({
required this.navigatorKey,
required this.stateNode,
}) {
stateNode.addListener(() {
notifyListeners();
});
}
@override
Future<void> setNewRoutePath(String configuration) async {}
@override
Widget build(BuildContext context) {
final dontMakePagesEachTime = makePages();
return FutureBuilder(
// Simulate a short async operation.
future: Future.delayed(const Duration(milliseconds: 5), () => '_'),
builder: (_, __) {
return Navigator(
key: navigatorKey,
pages: currentState.contains(brokenRoute)
// Creating a list of pages here causes the navigator
// _updatePages to run more than once due to the Future Builder
// above.
? makePages()
: dontMakePagesEachTime,
onPopPage: (route, result) {
if (currentState.split('/').length == 1) {
// Don't pop our last page.
return false;
}
route.didPop(result);
return true;
});
});
}
List<Page> makePages() => currentState
.split('/')
.map((name) => MaterialPage(
// Different page per app state.
key: ValueKey(name),
maintainState: true,
child: MyHomePage(
key: ValueKey(name),
title: name,
stateNode: stateNode,
navigatorKey: navigatorKey,
),
))
.toList();
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment