Skip to content

[go_router]: Timing Inconsistency: Generated Routes vs Manual Routes in _handlePopPageWithRouteMatch Due to onExit Callback Behavior #174525

@kylianSalomon

Description

@kylianSalomon

Steps to reproduce

  1. Create two similar routes and enable debugLogDiagnostics in your GoRouter config:
   // Generated route
   @TypedGoRoute<MyGeneratedRoute>(path: '/generated')
   class MyGeneratedRoute extends GoRouteData {
     @override
     Widget build(BuildContext context, GoRouterState state) {
       return MyPage();
     }
   }
   
   // Manual route
   GoRoute(
     path: '/manual',
     builder: (context, state) => MyPage(),
     // Note: no onExit callback
   )
  1. Navigate to each route and then pop back
  2. Observe the timing difference in when route configuration updates occur and the popping and restoring logs.

Expected results

Both generated and manual routes should have consistent timing for route configuration updates during navigation operations.

Actual results

There's a significant behavioral difference between routes created with GoRouteData.$route (generated routes) and manually defined GoRoutes in how they handle route popping, specifically in the _handlePopPageWithRouteMatch method. This leads to different timing for when _completeRouteMatch is called, which can cause issues with state management and route configuration updates. Mostly on pop() where restore(currentConfiguration)will be called before _completeRouteMatch execution.

Root Cause Analysis
In delegate.dart at line 152-171, the logic branches based on whether routeBase.onExit == null:

bool _handlePopPageWithRouteMatch(
  Route<Object?> route,
  Object? result,
  RouteMatchBase match,
) {
  // ... willHandlePopInternally check ...
  
  final RouteBase routeBase = match.route;
  if (routeBase is! GoRoute || routeBase.onExit == null) {
    // MANUAL ROUTES: Execute immediately
    route.didPop(result);
    _completeRouteMatch(result, match);  // ← Synchronous execution
    return true;
  }

  // GENERATED ROUTES: Always go through this path
  scheduleMicrotask(() async {
    final bool onExitResult = await routeBase.onExit!(
      navigatorKey.currentContext!,
      match.buildState(_configuration, currentConfiguration),
    );
    if (onExitResult) {
      _completeRouteMatch(result, match);  // ← Asynchronous execution
    }
  });
  return false;
}

Code sample

main.dart
void main() {
  runApp(
    ProviderScope(
      child: MaterialApp.router(
        routerConfig: router,
      ),
    ),
  );
}

class GroupsPage extends ConsumerWidget {
  const GroupsPage({super.key, required this.groupId});

  final String groupId;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Group $groupId'),
        actions: [
          IconButton(
            onPressed: () => context.go('/groups/$groupId/settings'),
            icon: const Icon(Icons.settings),
          ),
        ],
      ),
      body: ListView.builder(
        itemCount: 10,
        itemBuilder: (context, index) => ListTile(
          onTap: () => context.go('/groups/$groupId/posts/$index'),
          tileColor: [Colors.red.shade300, Colors.blue.shade300][index % 2],
          title: Text('Post of group $groupId n°$index'),
        ),
      ),
    );
  }
}

class PostPage extends ConsumerWidget {
  const PostPage({super.key, required this.postId});

  final String postId;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(
        leading: IconButton(
          onPressed: () => context.pop(),
          icon: const Icon(Icons.arrow_back),
        ),
        title: Text('Post $postId'),
      ),
      body: Text('This is the content of post $postId'),
    );
  }
}

class SettingsPage extends ConsumerWidget {
  const SettingsPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(
        leading: IconButton(
          onPressed: () => context.pop(),
          icon: const Icon(Icons.arrow_back),
        ),
      ),
      body: const Center(
        child: Column(
          children: [
            Text('Settings'),
          ],
        ),
      ),
    );
  }
}
router.dart
part 'router.g.dart';

final router = GoRouter(
  debugLogDiagnostics: true,
  initialLocation: '/groups/1',
  routes: [
    StatefulShellRoute.indexedStack(
      builder: (context, state, child) => Scaffold(
        appBar: AppBar(
          title: const Text('Test App'),
        ),
        body: child,
      ),
      branches: [
        StatefulShellBranch(
          routes: [
            ShellRoute(
              builder: (context, state, child) => Scaffold(
                appBar: AppBar(
                  title: const Text('Group List'),
                ),
                body: child,
              ),
              routes: [
                GoRoute(
                  path: '/groups/empty',
                  builder: (context, state) => const GroupsPage(
                    groupId: 'all',
                  ),
                  redirect: (context, state) => '/groups/1',
                ),
                GoRoute(
                  path: '/groups/:id',
                  builder: (context, state) => GroupsPage(
                    groupId: state.pathParameters['id']!,
                  ),
                  routes: [
                    PostDetailPage.route,
                    GoRoute(
                      path: 'settings',
                      builder: (context, state) => const SettingsPage(),
                    ),
                  ],
                ),
              ],
            ),
          ],
        )
      ],
    )
  ],
);

@TypedGoRoute<PostDetailPage>(path: 'posts/:id')
class PostDetailPage extends GoRouteData with _$PostDetailPage {
  const PostDetailPage({required this.id});

  static final route = $postDetailPage;

  final String id;

  @override
  Widget build(BuildContext context, GoRouterState state) {
    return PostPage(
      postId: id,
    );
  }
}

Screenshots or Video

Screenshots / Video demonstration

[Upload media here]

Logs

Logs
[GoRouter] Full paths for routes:
           └─ (ShellRoute)
             └─ (ShellRoute)
               ├─/groups/empty (GroupsPage)
               └─/groups/:id (GroupsPage)
                 ├─/groups/:id/posts/:id (Widget)
                 └─/groups/:id/settings (SettingsPage)
[GoRouter] setting initial location /groups/1
[GoRouter] Using MaterialApp configuration
[GoRouter] going to /groups/1/settings
[GoRouter] popping /groups/1/settings
[GoRouter] restoring /groups/1
[GoRouter] going to /groups/1/posts/0
[GoRouter] popping /groups/1/posts/0
[GoRouter] restoring /groups/1/posts/0

Flutter Doctor output

Doctor output
[✓] Flutter (Channel stable, 3.35.1, on macOS 15.4.1 24E263 darwin-arm64, locale fr-FR) [277ms]
    • Flutter version 3.35.1 on channel stable
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 20f8274939 (13 days ago), 2025-08-14 10:53:09 -0700
    • Engine revision 1e9a811bf8
    • Dart version 3.9.0
    • DevTools version 2.48.0
    • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android,
      enable-ios, cli-animations, enable-lldb-debugging

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Important issues not at the top of the work listfound in release: 3.35Found to occur in 3.35found in release: 3.36Found to occur in 3.36has reproducible stepsThe issue has been confirmed reproducible and is ready to work onp: go_routerThe go_router packagepackageflutter/packages repository. See also p: labels.team-frameworkOwned by Framework teamtriaged-frameworkTriaged by Framework team

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions