[go_router] Allows redirect only GoRoute to be part of RouteMatchList (#4315)

fixes https://github.com/flutter/flutter/issues/114807
diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md
index bdde3c2..0d1f319 100644
--- a/packages/go_router/CHANGELOG.md
+++ b/packages/go_router/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 9.0.1
+
+- Allows redirect only GoRoute to be part of RouteMatchList.
+
 ## 9.0.0
 
 - **BREAKING CHANGE**:
diff --git a/packages/go_router/lib/src/builder.dart b/packages/go_router/lib/src/builder.dart
index 26d70ae..7746165 100644
--- a/packages/go_router/lib/src/builder.dart
+++ b/packages/go_router/lib/src/builder.dart
@@ -88,6 +88,8 @@
       // empty box until then.
       return const SizedBox.shrink();
     }
+    assert(
+        matchList.isError || !(matchList.last.route as GoRoute).redirectOnly);
     return builderWithNav(
       context,
       Builder(
@@ -212,8 +214,12 @@
       if (route is GoRoute) {
         page =
             _buildPageForGoRoute(context, state, match, route, pagePopContext);
-
-        keyToPages.putIfAbsent(routeNavKey, () => <Page<Object?>>[]).add(page);
+        assert(page != null || route.redirectOnly);
+        if (page != null) {
+          keyToPages
+              .putIfAbsent(routeNavKey, () => <Page<Object?>>[])
+              .add(page);
+        }
 
         _buildRecursive(context, matchList, startIndex + 1, pagePopContext,
             routerNeglect, keyToPages, navigatorKey, registry);
@@ -275,8 +281,6 @@
     if (page != null) {
       registry[page] = state;
       pagePopContext._setRouteMatchForPage(page, match);
-    } else {
-      throw GoError('Unsupported route type $route');
     }
   }
 
@@ -344,36 +348,30 @@
   }
 
   /// Builds a [Page] for [GoRoute]
-  Page<Object?> _buildPageForGoRoute(BuildContext context, GoRouterState state,
+  Page<Object?>? _buildPageForGoRoute(BuildContext context, GoRouterState state,
       RouteMatch match, GoRoute route, _PagePopContext pagePopContext) {
-    Page<Object?>? page;
-
     // Call the pageBuilder if it's non-null
     final GoRouterPageBuilder? pageBuilder = route.pageBuilder;
     if (pageBuilder != null) {
-      page = pageBuilder(context, state);
-      if (page is NoOpPage) {
-        page = null;
+      final Page<Object?> page = pageBuilder(context, state);
+      if (page is! NoOpPage) {
+        return page;
       }
     }
-
-    // Return the result of the route's builder() or pageBuilder()
-    return page ??
-        buildPage(context, state, Builder(builder: (BuildContext context) {
-          return _callGoRouteBuilder(context, state, route);
-        }));
+    return _callGoRouteBuilder(context, state, route);
   }
 
   /// Calls the user-provided route builder from the [GoRoute].
-  Widget _callGoRouteBuilder(
+  Page<Object?>? _callGoRouteBuilder(
       BuildContext context, GoRouterState state, GoRoute route) {
     final GoRouterWidgetBuilder? builder = route.builder;
 
     if (builder == null) {
-      throw GoError('No routeBuilder provided to GoRoute: $route');
+      return null;
     }
-
-    return builder(context, state);
+    return buildPage(context, state, Builder(builder: (BuildContext context) {
+      return builder(context, state);
+    }));
   }
 
   /// Builds a [Page] for [ShellRouteBase]
diff --git a/packages/go_router/lib/src/match.dart b/packages/go_router/lib/src/match.dart
index 865ab0b..9106b1c 100644
--- a/packages/go_router/lib/src/match.dart
+++ b/packages/go_router/lib/src/match.dart
@@ -263,8 +263,11 @@
     assert(index != -1);
     newMatches.removeRange(index, newMatches.length);
 
-    // Also pop ShellRoutes when there are no subsequent route matches
-    while (newMatches.isNotEmpty && newMatches.last.route is ShellRouteBase) {
+    // Also pop ShellRoutes that have no subsequent route matches and GoRoutes
+    // that only have redirect.
+    while (newMatches.isNotEmpty &&
+        (newMatches.last.route is ShellRouteBase ||
+            (newMatches.last.route as GoRoute).redirectOnly)) {
       newMatches.removeLast();
     }
     // Removing ImperativeRouteMatch should not change uri and pathParameters.
diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart
index 1a9dcca..778d099 100644
--- a/packages/go_router/lib/src/parser.dart
+++ b/packages/go_router/lib/src/parser.dart
@@ -98,6 +98,14 @@
       if (matchList.isError && onParserException != null) {
         return onParserException!(context, matchList);
       }
+
+      assert(() {
+        if (matchList.isNotEmpty) {
+          assert(!(matchList.last.route as GoRoute).redirectOnly,
+              'A redirect-only route must redirect to location different from itself.\n The offending route: ${matchList.last.route}');
+        }
+        return true;
+      }());
       return _updateRouteMatchList(
         matchList,
         baseRouteMatchList: state.baseRouteMatchList,
diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart
index 9d33e88..b7ba3af 100644
--- a/packages/go_router/lib/src/route.dart
+++ b/packages/go_router/lib/src/route.dart
@@ -3,6 +3,7 @@
 // found in the LICENSE file.
 
 import 'package:collection/collection.dart';
+import 'package:flutter/foundation.dart';
 import 'package:flutter/widgets.dart';
 import 'package:meta/meta.dart';
 
@@ -96,7 +97,7 @@
 /// ///
 /// See [main.dart](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/main.dart)
 @immutable
-abstract class RouteBase {
+abstract class RouteBase with Diagnosticable {
   const RouteBase._({
     required this.routes,
     required this.parentNavigatorKey,
@@ -118,6 +119,15 @@
     return routes.expand(
         (RouteBase e) => <RouteBase>[e, ...routesRecursively(e.routes)]);
   }
+
+  @override
+  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+    super.debugFillProperties(properties);
+    if (parentNavigatorKey != null) {
+      properties.add(DiagnosticsProperty<GlobalKey<NavigatorState>>(
+          'parentNavKey', parentNavigatorKey));
+    }
+  }
 }
 
 /// A route that is displayed visually above the matching parent route using the
@@ -157,6 +167,11 @@
     _pathRE = patternToRegExp(path, pathParameters);
   }
 
+  /// Whether this [GoRoute] only redirects to another route.
+  ///
+  /// If this is true, this route must redirect location other than itself.
+  bool get redirectOnly => pageBuilder == null && builder == null;
+
   /// Optional name of the route.
   ///
   /// If used, a unique string name must be provided and it can not be empty.
@@ -323,8 +338,12 @@
   final List<String> pathParameters = <String>[];
 
   @override
-  String toString() {
-    return 'GoRoute(name: $name, path: $path)';
+  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+    super.debugFillProperties(properties);
+    properties.add(StringProperty('name', name));
+    properties.add(StringProperty('path', path));
+    properties.add(
+        FlagProperty('redirect', value: redirectOnly, ifTrue: 'Redirect Only'));
   }
 
   late final RegExp _pathRE;
@@ -338,6 +357,21 @@
       {required super.routes, required super.parentNavigatorKey})
       : super._();
 
+  static void _debugCheckSubRouteParentNavigatorKeys(
+      List<RouteBase> subRoutes, GlobalKey<NavigatorState> navigatorKey) {
+    for (final RouteBase route in subRoutes) {
+      assert(
+          route.parentNavigatorKey == null ||
+              route.parentNavigatorKey == navigatorKey,
+          "sub-route's parent navigator key must either be null or has the same navigator key as parent's key");
+      if (route is GoRoute && route.redirectOnly) {
+        // This route does not produce a page, need to check its sub-routes
+        // instead.
+        _debugCheckSubRouteParentNavigatorKeys(route.routes, navigatorKey);
+      }
+    }
+  }
+
   /// Attempts to build the Widget representing this shell route.
   ///
   /// Returns null if this shell route does not build a Widget, but instead uses
@@ -506,12 +540,11 @@
   })  : assert(routes.isNotEmpty),
         navigatorKey = navigatorKey ?? GlobalKey<NavigatorState>(),
         super._() {
-    for (final RouteBase route in routes) {
-      if (route is GoRoute) {
-        assert(route.parentNavigatorKey == null ||
-            route.parentNavigatorKey == navigatorKey);
-      }
-    }
+    assert(() {
+      ShellRouteBase._debugCheckSubRouteParentNavigatorKeys(
+          routes, this.navigatorKey);
+      return true;
+    }());
   }
 
   /// The widget builder for a shell route.
@@ -576,6 +609,13 @@
   @override
   Iterable<GlobalKey<NavigatorState>> get _navigatorKeys =>
       <GlobalKey<NavigatorState>>[navigatorKey];
+
+  @override
+  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+    super.debugFillProperties(properties);
+    properties.add(DiagnosticsProperty<GlobalKey<NavigatorState>>(
+        'navigatorKey', navigatorKey));
+  }
 }
 
 /// A route that displays a UI shell with separate [Navigator]s for its
@@ -828,6 +868,13 @@
     }
     return true;
   }
+
+  @override
+  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
+    super.debugFillProperties(properties);
+    properties.add(DiagnosticsProperty<Iterable<GlobalKey<NavigatorState>>>(
+        'navigatorKeys', _navigatorKeys));
+  }
 }
 
 /// Representation of a separate branch in a stateful navigation tree, used to
@@ -854,7 +901,13 @@
     this.initialLocation,
     this.restorationScopeId,
     this.observers,
-  }) : navigatorKey = navigatorKey ?? GlobalKey<NavigatorState>();
+  }) : navigatorKey = navigatorKey ?? GlobalKey<NavigatorState>() {
+    assert(() {
+      ShellRouteBase._debugCheckSubRouteParentNavigatorKeys(
+          routes, this.navigatorKey);
+      return true;
+    }());
+  }
 
   /// The [GlobalKey] to be used by the [Navigator] built for this branch.
   ///
diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml
index f977eb9..83bebb1 100644
--- a/packages/go_router/pubspec.yaml
+++ b/packages/go_router/pubspec.yaml
@@ -1,7 +1,7 @@
 name: go_router
 description: A declarative router for Flutter based on Navigation 2 supporting
   deep linking, data-driven routes and more
-version: 9.0.0
+version: 9.0.1
 repository: https://github.com/flutter/packages/tree/main/packages/go_router
 issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+go_router%22
 
diff --git a/packages/go_router/test/go_route_test.dart b/packages/go_router/test/go_route_test.dart
index f7ffe70..0ff437c 100644
--- a/packages/go_router/test/go_route_test.dart
+++ b/packages/go_router/test/go_route_test.dart
@@ -155,4 +155,127 @@
     expect(find.text('Screen D'), findsOneWidget);
     expect(find.text('Screen C'), findsOneWidget);
   });
+
+  test('ShellRoute parent navigator key throw if not match', () async {
+    final GlobalKey<NavigatorState> key1 = GlobalKey<NavigatorState>();
+    final GlobalKey<NavigatorState> key2 = GlobalKey<NavigatorState>();
+    bool hasError = false;
+    try {
+      ShellRoute(
+        navigatorKey: key1,
+        builder: (_, __, Widget child) => child,
+        routes: <RouteBase>[
+          ShellRoute(
+            parentNavigatorKey: key2,
+            builder: (_, __, Widget child) => child,
+            routes: <RouteBase>[
+              GoRoute(
+                path: '1',
+                builder: (_, __) => const Text('/route/1'),
+              ),
+            ],
+          ),
+        ],
+      );
+    } on AssertionError catch (_) {
+      hasError = true;
+    }
+    expect(hasError, isTrue);
+  });
+
+  group('Redirect only GoRoute', () {
+    testWidgets('can redirect to subroute', (WidgetTester tester) async {
+      final GoRouter router = await createRouter(
+        <RouteBase>[
+          GoRoute(
+            path: '/',
+            builder: (_, __) => const Text('home'),
+            routes: <RouteBase>[
+              GoRoute(
+                path: 'route',
+                redirect: (_, __) => '/route/1',
+                routes: <RouteBase>[
+                  GoRoute(
+                    path: '1',
+                    builder: (_, __) => const Text('/route/1'),
+                  ),
+                ],
+              ),
+            ],
+          ),
+        ],
+        tester,
+      );
+      expect(find.text('home'), findsOneWidget);
+
+      router.go('/route');
+      await tester.pumpAndSettle();
+      // Should redirect to /route/1 without error.
+      expect(find.text('/route/1'), findsOneWidget);
+
+      router.pop();
+      await tester.pumpAndSettle();
+      // Should go back directly to home page.
+      expect(find.text('home'), findsOneWidget);
+    });
+
+    testWidgets('throw if redirect to itself.', (WidgetTester tester) async {
+      final GoRouter router = await createRouter(
+        <RouteBase>[
+          GoRoute(
+            path: '/',
+            builder: (_, __) => const Text('home'),
+            routes: <RouteBase>[
+              GoRoute(
+                path: 'route',
+                redirect: (_, __) => '/route',
+                routes: <RouteBase>[
+                  GoRoute(
+                    path: '1',
+                    builder: (_, __) => const Text('/route/1'),
+                  ),
+                ],
+              ),
+            ],
+          ),
+        ],
+        tester,
+      );
+      expect(find.text('home'), findsOneWidget);
+
+      router.go('/route');
+      await tester.pumpAndSettle();
+      // Should redirect to /route/1 without error.
+      expect(tester.takeException(), isAssertionError);
+    });
+
+    testWidgets('throw if sub route does not conform with parent navigator key',
+        (WidgetTester tester) async {
+      final GlobalKey<NavigatorState> key1 = GlobalKey<NavigatorState>();
+      final GlobalKey<NavigatorState> key2 = GlobalKey<NavigatorState>();
+      bool hasError = false;
+      try {
+        ShellRoute(
+          navigatorKey: key1,
+          builder: (_, __, Widget child) => child,
+          routes: <RouteBase>[
+            GoRoute(
+              path: '/',
+              redirect: (_, __) => '/route',
+              routes: <RouteBase>[
+                GoRoute(
+                  parentNavigatorKey: key2,
+                  path: 'route',
+                  builder: (_, __) => const Text('/route/1'),
+                ),
+              ],
+            ),
+          ],
+        );
+      } on AssertionError catch (_) {
+        hasError = true;
+      }
+      expect(hasError, isTrue);
+    });
+  });
 }
diff --git a/packages/go_router/test/matching_test.dart b/packages/go_router/test/matching_test.dart
index 7784b85..640a467 100644
--- a/packages/go_router/test/matching_test.dart
+++ b/packages/go_router/test/matching_test.dart
@@ -22,9 +22,8 @@
               const Placeholder()),
     ];
 
-    final GoRouter router = await createRouter(routes, tester);
-    router.go('/page-0');
-    await tester.pumpAndSettle();
+    final GoRouter router =
+        await createRouter(routes, tester, initialLocation: '/page-0');
 
     final RouteMatchList matches = router.routerDelegate.currentConfiguration;
     expect(matches.toString(), contains('/page-0'));
diff --git a/packages/go_router/test/route_data_test.dart b/packages/go_router/test/route_data_test.dart
index 1541940..43299ec 100644
--- a/packages/go_router/test/route_data_test.dart
+++ b/packages/go_router/test/route_data_test.dart
@@ -189,9 +189,7 @@
         routes: _routes,
       );
       await tester.pumpWidget(MaterialApp.router(
-        routeInformationProvider: goRouter.routeInformationProvider,
-        routeInformationParser: goRouter.routeInformationParser,
-        routerDelegate: goRouter.routerDelegate,
+        routerConfig: goRouter,
       ));
       expect(find.byKey(const Key('build')), findsNothing);
       expect(find.byKey(const Key('buildPage')), findsOneWidget);
@@ -206,9 +204,7 @@
         routes: _routes,
       );
       await tester.pumpWidget(MaterialApp.router(
-        routeInformationProvider: goRouter.routeInformationProvider,
-        routeInformationParser: goRouter.routeInformationParser,
-        routerDelegate: goRouter.routerDelegate,
+        routerConfig: goRouter,
       ));
       expect(find.byKey(const Key('build')), findsNothing);
       expect(find.byKey(const Key('buildPage')), findsNothing);