GoRouter is the official routing library recommended by the Flutter team. The goal of GoRouter is to make it easier to implement declarative navigation, as stated in the official
docs. Like us at
Cuballama, many projects that where using imperative navigation (pop
,
push
,
popUntil
), decided to migrate to GoRouter, in part because of this recommendation. But there was a piece missing, GoRouter does not have a
popUntil
method. There is an open
issue
about it, but it is very unlikely that it will ever be implemented because GoRouter is now considered "feature complete."
That’s why in our code we kept using
Navigator.popUntil
when needed, but starting with GoRouter 14.1.0, it was causing an infinite loop and crashing the app. A lot of people also implemented their own versions of
popUntil
, like the one shown in this
post, but all of them started failing too, as reported in this
issue. Furthermore, the problem only seemed to appear when using GoRouter together with GoRouterBuilder, as stated in this
comment.
So, what’s the problem?
In our case, the reason why
popUntil
was causing the infinite loop was that we were using GoRouterBuilder to generate routes, and the generated code calls a static method in
route_data.dart
to create
GoRoute
objects with a non-null
onExit
callback.
/// A helper function used by generated code.
///
/// Should not be used directly.
static GoRoute $route<T extends GoRouteData>({
required String path,
String? name,
required T Function(GoRouterState) factory,
GlobalKey<NavigatorState>? parentNavigatorKey,
List<RouteBase> routes = const <RouteBase>[],
}) {
...
FutureOr<bool> onExit(BuildContext context, GoRouterState state) =>
factoryImpl(state).onExit(context, state);
return GoRoute(
path: path,
name: name,
builder: builder,
pageBuilder: pageBuilder,
redirect: redirect,
routes: routes,
parentNavigatorKey: parentNavigatorKey,
onExit: onExit, // THIS LINE WAS ADDED
);
}
This prevents
_completeRouteMatch
from being called immediately in
_handlePopPageWithRouteMatch
. The next call to
pop
in the
NavigatorState.popUntil
implementation occurs before the scheduled microtask runs, when the route is not popped yet, causing an infinite loop.
bool _handlePopPageWithRouteMatch(
Route<Object?> route, Object? result, RouteMatchBase match) {
...
final RouteBase routeBase = match.route;
if (routeBase is! GoRoute || routeBase.onExit == null) { // onExit IS NEVER NULL
route.didPop(result);
_completeRouteMatch(result, match); // THIS WILL NEVER BE CALLED
return true;
}
// The _handlePopPageWithRouteMatch is called during draw frame, schedule
// a microtask in case the onExit callback want to launch dialog or other
// navigator operations.
scheduleMicrotask(() async {
...
});
}
The same problem occurs in all the custom implementations of
popUntil
that rely on a
pop
call.
And what’s the solution?
The simplest way I found to implement
popUntil
correctly without using any
pop
invocations is to use
RouteMatchBase.remove
to "pop" the routes (except the very first one), and then call
GoRouterDelegate.setNewRoutePath
to set the new routes list.
extension ContextExtension on BuildContext {
void popUntil(bool Function(GoRoute route) predicate) {
final delegate = GoRouter.of(this).routerDelegate;
var config = delegate.currentConfiguration;
while (!predicate(config.last.route) && config.routes.length > 1) {
config = config.remove(config.last);
}
delegate.setNewRoutePath(config);
}
}
I implemented a full example in this repo.
Conclusion
GoRouter was not built specifically to do imperative navigation, but in legacy code that only supports Android and iOS, sometimes it is necessary to use
popUntil
. For those cases the usual implementations that use
pop
under the hood do not work after upgrading to GoRouter 14.1.0. Fortunately, we were able to find a solution that works for us. I hope it can work for you too.