How to implement popUntil in GoRouter 14.1.0
LogoRuben Lopez's Flutter Blog

How to implement popUntil in GoRouter 14.1.0


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.