diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index d0cc3ce5cdc..3c9ce2c40b3 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,3 +1,10 @@ +## 12.0.0 + +- Adds ability to dynamically update routing table. +- **BREAKING CHANGE**: + - The function signature of constructor of `RouteConfiguration` is updated. + - Adds a required `matchedPath` named parameter to `RouteMatch.match`. + ## 11.1.4 - Fixes missing parameters in the type-safe routes topic documentation. diff --git a/packages/go_router/README.md b/packages/go_router/README.md index f2cd542f95b..51cda37b0b9 100644 --- a/packages/go_router/README.md +++ b/packages/go_router/README.md @@ -37,6 +37,7 @@ See the API documentation for details on the following topics: - [Error handling](https://pub.dev/documentation/go_router/latest/topics/Error%20handling-topic.html) ## Migration Guides +- [Migrating to 12.0.0](https://flutter.dev/go/go-router-v12-breaking-changes). - [Migrating to 11.0.0](https://flutter.dev/go/go-router-v11-breaking-changes). - [Migrating to 10.0.0](https://flutter.dev/go/go-router-v10-breaking-changes). - [Migrating to 9.0.0](https://flutter.dev/go/go-router-v9-breaking-changes). diff --git a/packages/go_router/doc/configuration.md b/packages/go_router/doc/configuration.md index 99680c779d6..bb3dba5e6ec 100644 --- a/packages/go_router/doc/configuration.md +++ b/packages/go_router/doc/configuration.md @@ -85,6 +85,35 @@ GoRoute( ) ``` +# Dynamic RoutingConfig +The [RoutingConfig][] provides a way to update the GoRoute\[s\] after +the [GoRouter][] has already created. This can be done by creating a GoRouter +with special constructor [GoRouter.routingConfig][] + +```dart +final ValueNotifier myRoutingConfig = ValueNotifier( + RoutingConfig( + routes: [GoRoute(path: '/', builder: (_, __) => HomeScreen())], + ), +); +final GoRouter router = GoRouter.routingConfig(routingConfig: myRoutingConfig); +``` + +To change the GoRoute later, modify the value of the [ValueNotifier][] directly. + +```dart +myRoutingConfig.value = RoutingConfig( + routes: [ + GoRoute(path: '/', builder: (_, __) => AlternativeHomeScreen()), + GoRoute(path: '/a-new-route', builder: (_, __) => SomeScreen()), + ], +); +``` + +The value change is automatically picked up by GoRouter and causes it to reparse +the current routes, i.e. RouteMatchList, stored in GoRouter. The RouteMatchList will +reflect the latest change of the `RoutingConfig`. + # Nested navigation Some apps display destinations in a subsection of the screen, for example, an app using a BottomNavigationBar that stays on-screen when navigating between diff --git a/packages/go_router/example/lib/routing_config.dart b/packages/go_router/example/lib/routing_config.dart new file mode 100644 index 00000000000..34255208d1e --- /dev/null +++ b/packages/go_router/example/lib/routing_config.dart @@ -0,0 +1,108 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// This app shows how to dynamically add more route into routing config +void main() => runApp(const MyApp()); + +/// The main app. +class MyApp extends StatefulWidget { + /// Constructs a [MyApp] + const MyApp({super.key}); + + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + bool isNewRouteAdded = false; + + late final ValueNotifier myConfig = + ValueNotifier(_generateRoutingConfig()); + + late final GoRouter router = GoRouter.routingConfig( + routingConfig: myConfig, + errorBuilder: (_, GoRouterState state) => Scaffold( + appBar: AppBar(title: const Text('Page not found')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('${state.uri} does not exist'), + ElevatedButton( + onPressed: () => router.go('/'), + child: const Text('Go to home')), + ], + )), + )); + + RoutingConfig _generateRoutingConfig() { + return RoutingConfig( + routes: [ + GoRoute( + path: '/', + builder: (_, __) { + return Scaffold( + appBar: AppBar(title: const Text('Home')), + body: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: isNewRouteAdded + ? null + : () { + setState(() { + isNewRouteAdded = true; + // Modify the routing config. + myConfig.value = _generateRoutingConfig(); + }); + }, + child: isNewRouteAdded + ? const Text('A route has been added') + : const Text('Add a new route'), + ), + ElevatedButton( + onPressed: () { + router.go('/new-route'); + }, + child: const Text('Try going to /new-route'), + ) + ], + ), + ), + ); + }, + ), + if (isNewRouteAdded) + GoRoute( + path: '/new-route', + builder: (_, __) { + return Scaffold( + appBar: AppBar(title: const Text('A new Route')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () => router.go('/'), + child: const Text('Go to home')), + ], + )), + ); + }, + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + routerConfig: router, + ); + } +} diff --git a/packages/go_router/example/test/routing_config_test.dart b/packages/go_router/example/test/routing_config_test.dart new file mode 100644 index 00000000000..bd288e39b82 --- /dev/null +++ b/packages/go_router/example/test/routing_config_test.dart @@ -0,0 +1,31 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router_examples/routing_config.dart' as example; + +void main() { + testWidgets('example works', (WidgetTester tester) async { + await tester.pumpWidget(const example.MyApp()); + expect(find.text('Add a new route'), findsOneWidget); + + await tester.tap(find.text('Try going to /new-route')); + await tester.pumpAndSettle(); + + expect(find.text('Page not found'), findsOneWidget); + + await tester.tap(find.text('Go to home')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Add a new route')); + await tester.pumpAndSettle(); + + expect(find.text('A route has been added'), findsOneWidget); + + await tester.tap(find.text('Try going to /new-route')); + await tester.pumpAndSettle(); + + expect(find.text('A new Route'), findsOneWidget); + }); +} diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart index 24c16e342b8..8e6393503a5 100644 --- a/packages/go_router/lib/src/configuration.dart +++ b/packages/go_router/lib/src/configuration.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'logging.dart'; @@ -11,6 +12,7 @@ import 'match.dart'; import 'misc/errors.dart'; import 'path_utils.dart'; import 'route.dart'; +import 'router.dart'; import 'state.dart'; /// The signature of the redirect callback. @@ -20,19 +22,12 @@ typedef GoRouterRedirect = FutureOr Function( /// The route configuration for GoRouter configured by the app. class RouteConfiguration { /// Constructs a [RouteConfiguration]. - RouteConfiguration({ - required this.routes, - required this.redirectLimit, - required this.topRedirect, + RouteConfiguration( + this._routingConfig, { required this.navigatorKey, - }) : assert(_debugCheckPath(routes, true)), - assert( - _debugVerifyNoDuplicatePathParameter(routes, {})), - assert(_debugCheckParentNavigatorKeys( - routes, >[navigatorKey])) { - assert(_debugCheckStatefulShellBranchDefaultLocations(routes)); - _cacheNameToPath('', routes); - log(debugKnownRoutes()); + }) { + _onRoutingTableChanged(); + _routingConfig.addListener(_onRoutingTableChanged); } static bool _debugCheckPath(List routes, bool isTopLevel) { @@ -40,15 +35,11 @@ class RouteConfiguration { late bool subRouteIsTopLevel; if (route is GoRoute) { if (isTopLevel) { - if (!route.path.startsWith('/')) { - throw GoError('top-level path must start with "/": $route'); - } + assert(route.path.startsWith('/'), + 'top-level path must start with "/": $route'); } else { - if (route.path.startsWith('/') || route.path.endsWith('/')) { - throw GoError( - 'sub-route path may not start or end with "/": $route', - ); - } + assert(!route.path.startsWith('/') && !route.path.endsWith('/'), + 'sub-route path may not start or end with "/": $route'); } subRouteIsTopLevel = false; } else if (route is ShellRouteBase) { @@ -69,11 +60,11 @@ class RouteConfiguration { if (parentKey != null) { // Verify that the root navigator or a ShellRoute ancestor has a // matching navigator key. - if (!allowedKeys.contains(parentKey)) { - throw GoError('parentNavigatorKey $parentKey must refer to' - " an ancestor ShellRoute's navigatorKey or GoRouter's" - ' navigatorKey'); - } + assert( + allowedKeys.contains(parentKey), + 'parentNavigatorKey $parentKey must refer to' + " an ancestor ShellRoute's navigatorKey or GoRouter's" + ' navigatorKey'); _debugCheckParentNavigatorKeys( route.routes, @@ -98,11 +89,10 @@ class RouteConfiguration { ); } else if (route is StatefulShellRoute) { for (final StatefulShellBranch branch in route.branches) { - if (allowedKeys.contains(branch.navigatorKey)) { - throw GoError( - 'StatefulShellBranch must not reuse an ancestor navigatorKey ' - '(${branch.navigatorKey})'); - } + assert( + !allowedKeys.contains(branch.navigatorKey), + 'StatefulShellBranch must not reuse an ancestor navigatorKey ' + '(${branch.navigatorKey})'); _debugCheckParentNavigatorKeys( branch.routes, @@ -149,23 +139,20 @@ class RouteConfiguration { final GoRoute? route = branch.defaultRoute; final String? initialLocation = route != null ? locationForRoute(route) : null; - if (initialLocation == null) { - throw GoError( - 'The default location of a StatefulShellBranch must be ' - 'derivable from GoRoute descendant'); - } - if (route!.pathParameters.isNotEmpty) { - throw GoError( - 'The default location of a StatefulShellBranch cannot be ' - 'a parameterized route'); - } + assert( + initialLocation != null, + 'The default location of a StatefulShellBranch must be ' + 'derivable from GoRoute descendant'); + assert( + route!.pathParameters.isEmpty, + 'The default location of a StatefulShellBranch cannot be ' + 'a parameterized route'); } else { final RouteMatchList matchList = findMatch(branch.initialLocation!); - if (matchList.isError) { - throw GoError( - 'initialLocation (${matchList.uri}) of StatefulShellBranch must ' - 'be a valid location'); - } + assert( + !matchList.isError, + 'initialLocation (${matchList.uri}) of StatefulShellBranch must ' + 'be a valid location'); final List matchRoutes = matchList.routes; final int shellIndex = matchRoutes.indexOf(route); bool matchFound = false; @@ -173,12 +160,11 @@ class RouteConfiguration { final RouteBase branchRoot = matchRoutes[shellIndex + 1]; matchFound = branch.routes.contains(branchRoot); } - if (!matchFound) { - throw GoError( - 'The initialLocation (${branch.initialLocation}) of ' - 'StatefulShellBranch must match a descendant route of the ' - 'branch'); - } + assert( + matchFound, + 'The initialLocation (${branch.initialLocation}) of ' + 'StatefulShellBranch must match a descendant route of the ' + 'branch'); } } } @@ -202,6 +188,18 @@ class RouteConfiguration { ); } + void _onRoutingTableChanged() { + final RoutingConfig routingTable = _routingConfig.value; + assert(_debugCheckPath(routingTable.routes, true)); + assert(_debugVerifyNoDuplicatePathParameter( + routingTable.routes, {})); + assert(_debugCheckParentNavigatorKeys( + routingTable.routes, >[navigatorKey])); + assert(_debugCheckStatefulShellBranchDefaultLocations(routingTable.routes)); + _cacheNameToPath('', routingTable.routes); + log(debugKnownRoutes()); + } + /// Builds a [GoRouterState] suitable for top level callback such as /// `GoRouter.redirect` or `GoRouter.onException`. GoRouterState buildTopLevelGoRouterState(RouteMatchList matchList) { @@ -218,18 +216,21 @@ class RouteConfiguration { ); } + /// The routing table. + final ValueListenable _routingConfig; + /// The list of top level routes used by [GoRouterDelegate]. - final List routes; + List get routes => _routingConfig.value.routes; + + /// Top level page redirect. + GoRouterRedirect get topRedirect => _routingConfig.value.redirect; /// The limit for the number of consecutive redirects. - final int redirectLimit; + int get redirectLimit => _routingConfig.value.redirectLimit; /// The global key for top level navigator. final GlobalKey navigatorKey; - /// Top level page redirect. - final GoRouterRedirect topRedirect; - final Map _nameToPath = {}; /// Looks up the url location by a [GoRoute]'s name. @@ -294,14 +295,32 @@ class RouteConfiguration { extra: extra); } + /// Reparse the input RouteMatchList + RouteMatchList reparse(RouteMatchList matchList) { + RouteMatchList result = + findMatch(matchList.uri.toString(), extra: matchList.extra); + + for (final ImperativeRouteMatch imperativeMatch + in matchList.matches.whereType()) { + final ImperativeRouteMatch match = ImperativeRouteMatch( + pageKey: imperativeMatch.pageKey, + matches: findMatch(imperativeMatch.matches.uri.toString(), + extra: imperativeMatch.matches.extra), + completer: imperativeMatch.completer); + result = result.push(match); + } + return result; + } + List? _getLocRouteMatches( Uri uri, Map pathParameters) { final List? result = _getLocRouteRecursively( location: uri.path, remainingLocation: uri.path, matchedLocation: '', + matchedPath: '', pathParameters: pathParameters, - routes: routes, + routes: _routingConfig.value.routes, ); return result; } @@ -310,6 +329,7 @@ class RouteConfiguration { required String location, required String remainingLocation, required String matchedLocation, + required String matchedPath, required Map pathParameters, required List routes, }) { @@ -323,6 +343,7 @@ class RouteConfiguration { route: route, remainingLocation: remainingLocation, matchedLocation: matchedLocation, + matchedPath: matchedPath, pathParameters: subPathParameters, ); @@ -343,9 +364,11 @@ class RouteConfiguration { // Otherwise, recurse final String childRestLoc; final String newParentSubLoc; + final String newParentPath; if (match.route is ShellRouteBase) { childRestLoc = remainingLocation; newParentSubLoc = matchedLocation; + newParentPath = matchedPath; } else { assert(location.startsWith(match.matchedLocation)); assert(remainingLocation.isNotEmpty); @@ -353,12 +376,15 @@ class RouteConfiguration { childRestLoc = location.substring(match.matchedLocation.length + (match.matchedLocation == '/' ? 0 : 1)); newParentSubLoc = match.matchedLocation; + newParentPath = + concatenatePaths(matchedPath, (match.route as GoRoute).path); } final List? subRouteMatch = _getLocRouteRecursively( location: location, remainingLocation: childRestLoc, matchedLocation: newParentSubLoc, + matchedPath: newParentPath, pathParameters: subPathParameters, routes: route.routes, ); @@ -437,7 +463,7 @@ class RouteConfiguration { redirectHistory.add(prevMatchList); // Check for top-level redirect - final FutureOr topRedirectResult = topRedirect( + final FutureOr topRedirectResult = _routingConfig.value.redirect( context, buildTopLevelGoRouterState(prevMatchList), ); @@ -520,7 +546,7 @@ class RouteConfiguration { newMatch ])}'); } - if (redirects.length > redirectLimit) { + if (redirects.length > _routingConfig.value.redirectLimit) { throw GoException( 'too many redirects ${_formatRedirectionHistory([ ...redirects, @@ -545,11 +571,11 @@ class RouteConfiguration { /// Builds the absolute path for the route, by concatenating the paths of the /// route and all its ancestors. String? locationForRoute(RouteBase route) => - fullPathForRoute(route, '', routes); + fullPathForRoute(route, '', _routingConfig.value.routes); @override String toString() { - return 'RouterConfiguration: $routes'; + return 'RouterConfiguration: ${_routingConfig.value.routes}'; } /// Returns the full path of [routes]. @@ -560,7 +586,7 @@ class RouteConfiguration { String debugKnownRoutes() { final StringBuffer sb = StringBuffer(); sb.writeln('Full paths for routes:'); - _debugFullPathsFor(routes, '', 0, sb); + _debugFullPathsFor(_routingConfig.value.routes, '', 0, sb); if (_nameToPath.isNotEmpty) { sb.writeln('known full paths for route names:'); diff --git a/packages/go_router/lib/src/match.dart b/packages/go_router/lib/src/match.dart index 9fef0af95b4..e5ffdec31d7 100644 --- a/packages/go_router/lib/src/match.dart +++ b/packages/go_router/lib/src/match.dart @@ -34,6 +34,7 @@ class RouteMatch { /// into `pathParameters`. static RouteMatch? match({ required RouteBase route, + required String matchedPath, // e.g. /family/:fid required String remainingLocation, // e.g. person/p1 required String matchedLocation, // e.g. /family/f2 required Map pathParameters, @@ -59,10 +60,11 @@ class RouteMatch { final String pathLoc = patternToPath(route.path, encodedParams); final String newMatchedLocation = concatenatePaths(matchedLocation, pathLoc); + final String newMatchedPath = concatenatePaths(matchedPath, route.path); return RouteMatch( route: route, matchedLocation: newMatchedLocation, - pageKey: ValueKey(route.hashCode.toString()), + pageKey: ValueKey(newMatchedPath), ); } assert(false, 'Unexpected route type: $route'); @@ -136,16 +138,16 @@ class ImperativeRouteMatch extends RouteMatch { completer.complete(value); } - // An ImperativeRouteMatch has its own life cycle due the the _completer. - // comparing _completer between instances would be the same thing as - // comparing object reference. @override bool operator ==(Object other) { - return identical(this, other); + return other is ImperativeRouteMatch && + completer == other.completer && + matches == other.matches && + super == other; } @override - int get hashCode => identityHashCode(this); + int get hashCode => Object.hash(super.hashCode, completer, matches.hashCode); } /// The list of [RouteMatch] objects. diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index 32860014f78..27485f08837 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -195,9 +195,9 @@ class GoRouteInformationParser extends RouteInformationParser { return newMatchList; case NavigatingType.restore: // Still need to consider redirection. - return baseRouteMatchList!.uri.toString() == newMatchList.uri.toString() - ? baseRouteMatchList - : newMatchList; + return baseRouteMatchList!.uri.toString() != newMatchList.uri.toString() + ? newMatchList + : baseRouteMatchList; } } diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index 44a95f387e6..2e90474be89 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -2,6 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'configuration.dart'; @@ -9,7 +12,6 @@ import 'delegate.dart'; import 'information_provider.dart'; import 'logging.dart'; import 'match.dart'; -import 'misc/errors.dart'; import 'misc/inherited_router.dart'; import 'parser.dart'; import 'route.dart'; @@ -24,10 +26,57 @@ typedef GoExceptionHandler = void Function( GoRouter router, ); +/// A set of parameters that defines routing in GoRouter. +/// +/// This is typically used with [GoRouter.routingConfig] to create a go router +/// with dynamic routing config. +/// +/// See [routing_config.dart](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/routing_config.dart). +/// +/// {@category Configuration} +class RoutingConfig { + /// Creates a routing config. + /// + /// The [routes] must not be empty. + const RoutingConfig({ + required this.routes, + this.redirect = _defaultRedirect, + this.redirectLimit = 5, + }); + + static FutureOr _defaultRedirect( + BuildContext context, GoRouterState state) => + null; + + /// The supported routes. + /// + /// The `routes` list specifies the top-level routes for the app. It must not be + /// empty and must contain an [GoRoute] to match `/`. + /// + /// See [GoRouter]. + final List routes; + + /// The top-level callback allows the app to redirect to a new location. + /// + /// Alternatively, you can specify a redirect for an individual route using + /// [GoRoute.redirect]. If [BuildContext.dependOnInheritedWidgetOfExactType] is + /// used during the redirection (which is how `of` methods are usually + /// implemented), a re-evaluation will be triggered when the [InheritedWidget] + /// changes. + /// + /// See [GoRouter]. + final GoRouterRedirect redirect; + + /// The maximum number of redirection allowed. + /// + /// See [GoRouter]. + final int redirectLimit; +} + /// The route configuration for the app. /// /// The `routes` list specifies the top-level routes for the app. It must not be -/// empty and must contain an [GoRouter] to match `/`. +/// empty and must contain an [GoRoute] to match `/`. /// /// See the [Get /// started](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/main.dart) @@ -70,7 +119,7 @@ class GoRouter implements RouterConfig { /// and an error page builder. /// /// The `routes` must not be null and must contain an [GoRouter] to match `/`. - GoRouter({ + factory GoRouter({ required List routes, GoExceptionHandler? onException, GoRouterPageBuilder? errorPageBuilder, @@ -80,6 +129,48 @@ class GoRouter implements RouterConfig { int redirectLimit = 5, bool routerNeglect = false, String? initialLocation, + bool overridePlatformDefaultLocation = false, + Object? initialExtra, + List? observers, + bool debugLogDiagnostics = false, + GlobalKey? navigatorKey, + String? restorationScopeId, + bool requestFocus = true, + }) { + return GoRouter.routingConfig( + routingConfig: _ConstantRoutingConfig( + RoutingConfig( + routes: routes, + redirect: redirect ?? RoutingConfig._defaultRedirect, + redirectLimit: redirectLimit), + ), + onException: onException, + errorPageBuilder: errorPageBuilder, + errorBuilder: errorBuilder, + refreshListenable: refreshListenable, + routerNeglect: routerNeglect, + initialLocation: initialLocation, + overridePlatformDefaultLocation: overridePlatformDefaultLocation, + initialExtra: initialExtra, + observers: observers, + debugLogDiagnostics: debugLogDiagnostics, + navigatorKey: navigatorKey, + restorationScopeId: restorationScopeId, + requestFocus: requestFocus, + ); + } + + /// Creates a [GoRouter] with a dynamic [RoutingConfig]. + /// + /// See [routing_config.dart](https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/routing_config.dart). + GoRouter.routingConfig({ + required ValueListenable routingConfig, + GoExceptionHandler? onException, + GoRouterPageBuilder? errorPageBuilder, + GoRouterWidgetBuilder? errorBuilder, + Listenable? refreshListenable, + bool routerNeglect = false, + String? initialLocation, this.overridePlatformDefaultLocation = false, Object? initialExtra, List? observers, @@ -87,7 +178,8 @@ class GoRouter implements RouterConfig { GlobalKey? navigatorKey, String? restorationScopeId, bool requestFocus = true, - }) : backButtonDispatcher = RootBackButtonDispatcher(), + }) : _routingConfig = routingConfig, + backButtonDispatcher = RootBackButtonDispatcher(), assert( initialExtra == null || initialLocation != null, 'initialLocation must be set in order to use initialExtra', @@ -99,24 +191,15 @@ class GoRouter implements RouterConfig { (errorPageBuilder == null ? 0 : 1) + (errorBuilder == null ? 0 : 1) < 2, - 'Only one of onException, errorPageBuilder, or errorBuilder can be provided.'), - assert(_debugCheckPath(routes, true)), - assert( - _debugVerifyNoDuplicatePathParameter(routes, {})), - assert(_debugCheckParentNavigatorKeys( - routes, - navigatorKey == null - ? >[] - : >[navigatorKey])) { + 'Only one of onException, errorPageBuilder, or errorBuilder can be provided.') { setLogging(enabled: debugLogDiagnostics); WidgetsFlutterBinding.ensureInitialized(); navigatorKey ??= GlobalKey(); + _routingConfig.addListener(_handleRoutingConfigChanged); configuration = RouteConfiguration( - routes: routes, - topRedirect: redirect ?? (_, __) => null, - redirectLimit: redirectLimit, + _routingConfig, navigatorKey: navigatorKey, ); @@ -166,103 +249,6 @@ class GoRouter implements RouterConfig { }()); } - static bool _debugCheckPath(List routes, bool isTopLevel) { - for (final RouteBase route in routes) { - late bool subRouteIsTopLevel; - if (route is GoRoute) { - if (isTopLevel) { - assert(route.path.startsWith('/'), - 'top-level path must start with "/": $route'); - } else { - assert(!route.path.startsWith('/') && !route.path.endsWith('/'), - 'sub-route path may not start or end with "/": $route'); - } - subRouteIsTopLevel = false; - } else if (route is ShellRouteBase) { - subRouteIsTopLevel = isTopLevel; - } - _debugCheckPath(route.routes, subRouteIsTopLevel); - } - return true; - } - - // Check that each parentNavigatorKey refers to either a ShellRoute's - // navigatorKey or the root navigator key. - static bool _debugCheckParentNavigatorKeys( - List routes, List> allowedKeys) { - for (final RouteBase route in routes) { - if (route is GoRoute) { - final GlobalKey? parentKey = route.parentNavigatorKey; - if (parentKey != null) { - // Verify that the root navigator or a ShellRoute ancestor has a - // matching navigator key. - assert( - allowedKeys.contains(parentKey), - 'parentNavigatorKey $parentKey must refer to' - " an ancestor ShellRoute's navigatorKey or GoRouter's" - ' navigatorKey'); - - _debugCheckParentNavigatorKeys( - route.routes, - >[ - // Once a parentNavigatorKey is used, only that navigator key - // or keys above it can be used. - ...allowedKeys.sublist(0, allowedKeys.indexOf(parentKey) + 1), - ], - ); - } else { - _debugCheckParentNavigatorKeys( - route.routes, - >[ - ...allowedKeys, - ], - ); - } - } else if (route is ShellRoute) { - _debugCheckParentNavigatorKeys( - route.routes, - >[...allowedKeys..add(route.navigatorKey)], - ); - } else if (route is StatefulShellRoute) { - for (final StatefulShellBranch branch in route.branches) { - assert( - !allowedKeys.contains(branch.navigatorKey), - 'StatefulShellBranch must not reuse an ancestor navigatorKey ' - '(${branch.navigatorKey})'); - - _debugCheckParentNavigatorKeys( - branch.routes, - >[ - ...allowedKeys, - branch.navigatorKey, - ], - ); - } - } - } - return true; - } - - static bool _debugVerifyNoDuplicatePathParameter( - List routes, Map usedPathParams) { - for (final RouteBase route in routes) { - if (route is! GoRoute) { - continue; - } - for (final String pathParam in route.pathParameters) { - if (usedPathParams.containsKey(pathParam)) { - final bool sameRoute = usedPathParams[pathParam] == route; - throw GoError( - "duplicate path parameter, '$pathParam' found in ${sameRoute ? '$route' : '${usedPathParams[pathParam]}, and $route'}"); - } - usedPathParams[pathParam] = route; - } - _debugVerifyNoDuplicatePathParameter(route.routes, usedPathParams); - route.pathParameters.forEach(usedPathParams.remove); - } - return true; - } - /// Whether the imperative API affects browser URL bar. /// /// The Imperative APIs refer to [push], [pushReplacement], or [replace]. @@ -302,6 +288,11 @@ class GoRouter implements RouterConfig { @override late final GoRouteInformationParser routeInformationParser; + void _handleRoutingConfigChanged() { + // Reparse is needed to update its builder + restore(configuration.reparse(routerDelegate.currentConfiguration)); + } + /// Whether to ignore platform's default initial location when /// `initialLocation` is set. /// @@ -319,6 +310,8 @@ class GoRouter implements RouterConfig { /// It's advisable to only set this to [true] if one explicitly wants to. final bool overridePlatformDefaultLocation; + final ValueListenable _routingConfig; + /// Returns `true` if there is at least two or more route can be pop. bool canPop() => routerDelegate.canPop(); @@ -522,6 +515,7 @@ class GoRouter implements RouterConfig { /// Disposes resource created by this object. void dispose() { + _routingConfig.removeListener(_handleRoutingConfigChanged); routeInformationProvider.dispose(); routerDelegate.dispose(); } @@ -543,3 +537,20 @@ class GoRouter implements RouterConfig { } } } + +/// A routing config that is never going to change. +class _ConstantRoutingConfig extends ValueListenable { + const _ConstantRoutingConfig(this.value); + @override + void addListener(VoidCallback listener) { + // Intentionally empty because listener will never be called. + } + + @override + void removeListener(VoidCallback listener) { + // Intentionally empty because listener will never be called. + } + + @override + final RoutingConfig value; +} diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index 3b3da6c85f0..b7c93c1ad88 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: 11.1.4 +version: 12.0.0 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/builder_test.dart b/packages/go_router/test/builder_test.dart index d3bfb3d6bd2..f1a08eef426 100644 --- a/packages/go_router/test/builder_test.dart +++ b/packages/go_router/test/builder_test.dart @@ -11,7 +11,7 @@ import 'test_helpers.dart'; void main() { group('RouteBuilder', () { testWidgets('Builds GoRoute', (WidgetTester tester) async { - final RouteConfiguration config = RouteConfiguration( + final RouteConfiguration config = createRouteConfiguration( routes: [ GoRoute( path: '/', @@ -49,7 +49,7 @@ void main() { }); testWidgets('Builds ShellRoute', (WidgetTester tester) async { - final RouteConfiguration config = RouteConfiguration( + final RouteConfiguration config = createRouteConfiguration( routes: [ ShellRoute( builder: @@ -94,7 +94,7 @@ void main() { testWidgets('Uses the correct navigatorKey', (WidgetTester tester) async { final GlobalKey rootNavigatorKey = GlobalKey(); - final RouteConfiguration config = RouteConfiguration( + final RouteConfiguration config = createRouteConfiguration( navigatorKey: rootNavigatorKey, routes: [ GoRoute( @@ -137,7 +137,7 @@ void main() { GlobalKey(debugLabel: 'root'); final GlobalKey shellNavigatorKey = GlobalKey(debugLabel: 'shell'); - final RouteConfiguration config = RouteConfiguration( + final RouteConfiguration config = createRouteConfiguration( navigatorKey: rootNavigatorKey, routes: [ ShellRoute( @@ -198,7 +198,7 @@ void main() { GlobalKey(debugLabel: 'root'); final GlobalKey shellNavigatorKey = GlobalKey(debugLabel: 'shell'); - final RouteConfiguration config = RouteConfiguration( + final RouteConfiguration config = createRouteConfiguration( navigatorKey: rootNavigatorKey, routes: [ ShellRoute( @@ -264,7 +264,7 @@ void main() { GlobalKey(debugLabel: 'root'); final GlobalKey shellNavigatorKey = GlobalKey(debugLabel: 'shell'); - final RouteConfiguration config = RouteConfiguration( + final RouteConfiguration config = createRouteConfiguration( navigatorKey: rootNavigatorKey, routes: [ ShellRoute( diff --git a/packages/go_router/test/configuration_test.dart b/packages/go_router/test/configuration_test.dart index a5ed6f19b74..a35f2b87e81 100644 --- a/packages/go_router/test/configuration_test.dart +++ b/packages/go_router/test/configuration_test.dart @@ -20,7 +20,7 @@ void main() { expect( () { - RouteConfiguration( + createRouteConfiguration( navigatorKey: root, routes: [ GoRoute( @@ -67,7 +67,7 @@ void main() { final List shellRouteChildren = []; expect( () { - RouteConfiguration( + createRouteConfiguration( navigatorKey: root, routes: [ ShellRoute(routes: shellRouteChildren), @@ -94,7 +94,7 @@ void main() { expect( () { - RouteConfiguration( + createRouteConfiguration( navigatorKey: root, routes: [ StatefulShellRoute.indexedStack(branches: [ @@ -120,7 +120,7 @@ void main() { }, ); }, - throwsA(isA()), + throwsA(isA()), ); }); @@ -132,7 +132,7 @@ void main() { final GlobalKey keyA = GlobalKey(debugLabel: 'A'); - RouteConfiguration( + createRouteConfiguration( navigatorKey: root, routes: [ StatefulShellRoute.indexedStack(branches: [ @@ -168,7 +168,7 @@ void main() { GlobalKey(); expect( () { - RouteConfiguration( + createRouteConfiguration( navigatorKey: root, routes: [ StatefulShellRoute.indexedStack(branches: [ @@ -218,7 +218,7 @@ void main() { ]; expect( () { - RouteConfiguration( + createRouteConfiguration( navigatorKey: root, routes: [ StatefulShellRoute.indexedStack(branches: [ @@ -254,7 +254,7 @@ void main() { parentNavigatorKey: sectionANavigatorKey); expect( () { - RouteConfiguration( + createRouteConfiguration( navigatorKey: root, routes: [ StatefulShellRoute.indexedStack(branches: [ @@ -287,7 +287,7 @@ void main() { GlobalKey(); expect( () { - RouteConfiguration( + createRouteConfiguration( navigatorKey: root, routes: [ StatefulShellRoute.indexedStack(branches: [ @@ -318,7 +318,7 @@ void main() { }, ); }, - throwsA(isA()), + throwsA(isA()), ); }); @@ -333,7 +333,7 @@ void main() { GlobalKey(); expect( () { - RouteConfiguration( + createRouteConfiguration( navigatorKey: root, routes: [ StatefulShellRoute.indexedStack(branches: [ @@ -373,7 +373,7 @@ void main() { }, ); }, - throwsA(isA()), + throwsA(isA()), ); }); @@ -383,7 +383,7 @@ void main() { final GlobalKey root = GlobalKey(debugLabel: 'root'); - RouteConfiguration( + createRouteConfiguration( navigatorKey: root, routes: [ StatefulShellRoute.indexedStack(branches: [ @@ -480,7 +480,7 @@ void main() { final StatefulShellBranch branchY; final StatefulShellBranch branchB; - final RouteConfiguration config = RouteConfiguration( + final RouteConfiguration config = createRouteConfiguration( navigatorKey: GlobalKey(debugLabel: 'root'), routes: [ StatefulShellRoute.indexedStack( @@ -568,7 +568,7 @@ void main() { GlobalKey(debugLabel: 'shell'); expect( () { - RouteConfiguration( + createRouteConfiguration( navigatorKey: root, routes: [ ShellRoute( @@ -608,7 +608,7 @@ void main() { GlobalKey(debugLabel: 'shell'); final GlobalKey shell2 = GlobalKey(debugLabel: 'shell2'); - RouteConfiguration( + createRouteConfiguration( navigatorKey: root, routes: [ ShellRoute( @@ -661,7 +661,7 @@ void main() { GlobalKey(debugLabel: 'root'); final GlobalKey shell = GlobalKey(debugLabel: 'shell'); - RouteConfiguration( + createRouteConfiguration( navigatorKey: root, routes: [ ShellRoute( @@ -711,7 +711,7 @@ void main() { final GlobalKey shell = GlobalKey(debugLabel: 'shell'); expect( - () => RouteConfiguration( + () => createRouteConfiguration( navigatorKey: root, routes: [ ShellRoute( @@ -749,7 +749,7 @@ void main() { return null; }, ), - throwsA(isA()), + throwsA(isA()), ); }, ); @@ -761,7 +761,7 @@ void main() { GlobalKey(debugLabel: 'root'); final GlobalKey shell = GlobalKey(debugLabel: 'shell'); - RouteConfiguration( + createRouteConfiguration( navigatorKey: root, routes: [ ShellRoute( @@ -814,7 +814,7 @@ void main() { final GlobalKey shell2 = GlobalKey(debugLabel: 'shell2'); expect( - () => RouteConfiguration( + () => createRouteConfiguration( navigatorKey: root, routes: [ ShellRoute( @@ -857,7 +857,7 @@ void main() { return null; }, ), - throwsA(isA()), + throwsA(isA()), ); }, ); @@ -865,7 +865,7 @@ void main() { () { final GlobalKey root = GlobalKey(debugLabel: 'root'); - RouteConfiguration( + createRouteConfiguration( routes: [ ShellRoute( builder: _mockShellBuilder, @@ -907,7 +907,7 @@ void main() { GlobalKey(debugLabel: 'shell'); final GlobalKey shell2 = GlobalKey(debugLabel: 'shell2'); - RouteConfiguration( + createRouteConfiguration( navigatorKey: root, routes: [ ShellRoute( @@ -959,7 +959,7 @@ void main() { GlobalKey(debugLabel: 'root'); expect( () { - RouteConfiguration( + createRouteConfiguration( navigatorKey: root, routes: [ ShellRoute( @@ -991,7 +991,7 @@ void main() { GlobalKey(debugLabel: 'shell'); expect( - RouteConfiguration( + createRouteConfiguration( navigatorKey: root, routes: [ GoRoute( diff --git a/packages/go_router/test/inherited_test.dart b/packages/go_router/test/inherited_test.dart index 62b7b541c90..673462f09bf 100644 --- a/packages/go_router/test/inherited_test.dart +++ b/packages/go_router/test/inherited_test.dart @@ -7,6 +7,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; +import 'test_helpers.dart'; + void main() { group('updateShouldNotify', () { test('does not update when goRouter does not change', () { @@ -124,7 +126,10 @@ class _MyWidget extends StatelessWidget { } class MockGoRouter extends GoRouter { - MockGoRouter() : super(routes: []); + MockGoRouter() + : super.routingConfig( + routingConfig: const ConstantRoutingConfig( + RoutingConfig(routes: []))); late String latestPushedName; diff --git a/packages/go_router/test/match_test.dart b/packages/go_router/test/match_test.dart index d99e8edf561..ea6d72db0d5 100644 --- a/packages/go_router/test/match_test.dart +++ b/packages/go_router/test/match_test.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; @@ -18,6 +20,7 @@ void main() { route: route, remainingLocation: '/users/123', matchedLocation: '', + matchedPath: '', pathParameters: pathParameters, ); if (match == null) { @@ -39,6 +42,7 @@ void main() { route: route, remainingLocation: 'users/123', matchedLocation: '/home', + matchedPath: '/home', pathParameters: pathParameters, ); if (match == null) { @@ -65,6 +69,7 @@ void main() { route: route, remainingLocation: 'users/123', matchedLocation: '/home', + matchedPath: '/home', pathParameters: pathParameters, ); if (match == null) { @@ -88,6 +93,7 @@ void main() { route: route, remainingLocation: 'users/123', matchedLocation: '/home', + matchedPath: '/home', pathParameters: pathParameters, ); @@ -95,6 +101,7 @@ void main() { route: route, remainingLocation: 'users/1234', matchedLocation: '/home', + matchedPath: '/home', pathParameters: pathParameters, ); @@ -111,6 +118,7 @@ void main() { route: route, remainingLocation: 'users/123', matchedLocation: '/home', + matchedPath: '/home', pathParameters: pathParameters, ); @@ -118,12 +126,73 @@ void main() { route: route, remainingLocation: 'users/1234', matchedLocation: '/home', + matchedPath: '/home', pathParameters: pathParameters, ); expect(match1!.pageKey, match2!.pageKey); }); }); + + group('ImperativeRouteMatch', () { + final RouteMatchList matchList1 = RouteMatchList( + matches: [ + RouteMatch( + route: GoRoute(path: '/', builder: (_, __) => const Text('hi')), + matchedLocation: '/', + pageKey: const ValueKey('dummy'), + ), + ], + uri: Uri.parse('/'), + pathParameters: const {}); + + final RouteMatchList matchList2 = RouteMatchList( + matches: [ + RouteMatch( + route: GoRoute(path: '/a', builder: (_, __) => const Text('a')), + matchedLocation: '/a', + pageKey: const ValueKey('dummy'), + ), + ], + uri: Uri.parse('/a'), + pathParameters: const {}); + + const ValueKey key1 = ValueKey('key1'); + const ValueKey key2 = ValueKey('key2'); + + final Completer completer1 = Completer(); + final Completer completer2 = Completer(); + + test('can equal and has', () async { + ImperativeRouteMatch match1 = ImperativeRouteMatch( + pageKey: key1, matches: matchList1, completer: completer1); + ImperativeRouteMatch match2 = ImperativeRouteMatch( + pageKey: key1, matches: matchList1, completer: completer1); + expect(match1 == match2, isTrue); + expect(match1.hashCode == match2.hashCode, isTrue); + + match1 = ImperativeRouteMatch( + pageKey: key1, matches: matchList1, completer: completer1); + match2 = ImperativeRouteMatch( + pageKey: key2, matches: matchList1, completer: completer1); + expect(match1 == match2, isFalse); + expect(match1.hashCode == match2.hashCode, isFalse); + + match1 = ImperativeRouteMatch( + pageKey: key1, matches: matchList1, completer: completer1); + match2 = ImperativeRouteMatch( + pageKey: key1, matches: matchList2, completer: completer1); + expect(match1 == match2, isFalse); + expect(match1.hashCode == match2.hashCode, isFalse); + + match1 = ImperativeRouteMatch( + pageKey: key1, matches: matchList1, completer: completer1); + match2 = ImperativeRouteMatch( + pageKey: key1, matches: matchList1, completer: completer2); + expect(match1 == match2, isFalse); + expect(match1.hashCode == match2.hashCode, isFalse); + }); + }); } Widget _builder(BuildContext context, GoRouterState state) => diff --git a/packages/go_router/test/matching_test.dart b/packages/go_router/test/matching_test.dart index b6337f47c81..c53ca13bf72 100644 --- a/packages/go_router/test/matching_test.dart +++ b/packages/go_router/test/matching_test.dart @@ -39,6 +39,7 @@ void main() { route: route, remainingLocation: '/page-0', matchedLocation: '', + matchedPath: '', pathParameters: params1, )!; @@ -47,6 +48,7 @@ void main() { route: route, remainingLocation: '/page-0', matchedLocation: '', + matchedPath: '', pathParameters: params2, )!; @@ -73,7 +75,7 @@ void main() { }); test('RouteMatchList is encoded and decoded correctly', () { - final RouteConfiguration configuration = RouteConfiguration( + final RouteConfiguration configuration = createRouteConfiguration( routes: [ GoRoute( path: '/a', diff --git a/packages/go_router/test/parser_test.dart b/packages/go_router/test/parser_test.dart index 8e5463b0cb8..e5b68fa0b43 100644 --- a/packages/go_router/test/parser_test.dart +++ b/packages/go_router/test/parser_test.dart @@ -155,7 +155,7 @@ void main() { ), ]; - final RouteConfiguration configuration = RouteConfiguration( + final RouteConfiguration configuration = createRouteConfiguration( routes: routes, redirectLimit: 100, topRedirect: (_, __) => null, @@ -195,7 +195,7 @@ void main() { ), ]; - final RouteConfiguration configuration = RouteConfiguration( + final RouteConfiguration configuration = createRouteConfiguration( routes: routes, redirectLimit: 100, topRedirect: (_, __) => null, diff --git a/packages/go_router/test/routing_config_test.dart b/packages/go_router/test/routing_config_test.dart new file mode 100644 index 00000000000..87a866dd646 --- /dev/null +++ b/packages/go_router/test/routing_config_test.dart @@ -0,0 +1,109 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import 'test_helpers.dart'; + +void main() { + testWidgets('routing config works', (WidgetTester tester) async { + final ValueNotifier config = ValueNotifier( + RoutingConfig( + routes: [ + GoRoute(path: '/', builder: (_, __) => const Text('home')), + ], + redirect: (_, __) => '/', + ), + ); + final GoRouter router = await createRouterWithRoutingConfig(config, tester); + expect(find.text('home'), findsOneWidget); + + router.go('/abcd'); // should be redirected to home + await tester.pumpAndSettle(); + expect(find.text('home'), findsOneWidget); + }); + + testWidgets('routing config works after builder changes', + (WidgetTester tester) async { + final ValueNotifier config = ValueNotifier( + RoutingConfig( + routes: [ + GoRoute(path: '/', builder: (_, __) => const Text('home')), + ], + ), + ); + await createRouterWithRoutingConfig(config, tester); + expect(find.text('home'), findsOneWidget); + + config.value = RoutingConfig( + routes: [ + GoRoute(path: '/', builder: (_, __) => const Text('home1')), + ], + ); + await tester.pumpAndSettle(); + expect(find.text('home1'), findsOneWidget); + }); + + testWidgets('routing config works after routing changes', + (WidgetTester tester) async { + final ValueNotifier config = ValueNotifier( + RoutingConfig( + routes: [ + GoRoute(path: '/', builder: (_, __) => const Text('home')), + ], + ), + ); + final GoRouter router = await createRouterWithRoutingConfig( + config, + tester, + errorBuilder: (_, __) => const Text('error'), + ); + expect(find.text('home'), findsOneWidget); + // Sanity check. + router.go('/abc'); + await tester.pumpAndSettle(); + expect(find.text('error'), findsOneWidget); + + config.value = RoutingConfig( + routes: [ + GoRoute(path: '/', builder: (_, __) => const Text('home')), + GoRoute(path: '/abc', builder: (_, __) => const Text('/abc')), + ], + ); + await tester.pumpAndSettle(); + expect(find.text('/abc'), findsOneWidget); + }); + + testWidgets('routing config works after routing changes case 2', + (WidgetTester tester) async { + final ValueNotifier config = ValueNotifier( + RoutingConfig( + routes: [ + GoRoute(path: '/', builder: (_, __) => const Text('home')), + GoRoute(path: '/abc', builder: (_, __) => const Text('/abc')), + ], + ), + ); + final GoRouter router = await createRouterWithRoutingConfig( + config, + tester, + errorBuilder: (_, __) => const Text('error'), + ); + expect(find.text('home'), findsOneWidget); + // Sanity check. + router.go('/abc'); + await tester.pumpAndSettle(); + expect(find.text('/abc'), findsOneWidget); + + config.value = RoutingConfig( + routes: [ + GoRoute(path: '/', builder: (_, __) => const Text('home')), + ], + ); + await tester.pumpAndSettle(); + expect(find.text('error'), findsOneWidget); + }); +} diff --git a/packages/go_router/test/test_helpers.dart b/packages/go_router/test/test_helpers.dart index 650fa13a2af..76ec2874a7a 100644 --- a/packages/go_router/test/test_helpers.dart +++ b/packages/go_router/test/test_helpers.dart @@ -4,6 +4,7 @@ // ignore_for_file: cascade_invocations, diagnostic_describe_all_properties +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -34,7 +35,10 @@ Widget fakeNavigationBuilder( child; class GoRouterNamedLocationSpy extends GoRouter { - GoRouterNamedLocationSpy({required super.routes}); + GoRouterNamedLocationSpy({required List routes}) + : super.routingConfig( + routingConfig: + ConstantRoutingConfig(RoutingConfig(routes: routes))); String? name; Map? pathParameters; @@ -54,7 +58,10 @@ class GoRouterNamedLocationSpy extends GoRouter { } class GoRouterGoSpy extends GoRouter { - GoRouterGoSpy({required super.routes}); + GoRouterGoSpy({required List routes}) + : super.routingConfig( + routingConfig: + ConstantRoutingConfig(RoutingConfig(routes: routes))); String? myLocation; Object? extra; @@ -67,7 +74,10 @@ class GoRouterGoSpy extends GoRouter { } class GoRouterGoNamedSpy extends GoRouter { - GoRouterGoNamedSpy({required super.routes}); + GoRouterGoNamedSpy({required List routes}) + : super.routingConfig( + routingConfig: + ConstantRoutingConfig(RoutingConfig(routes: routes))); String? name; Map? pathParameters; @@ -89,7 +99,10 @@ class GoRouterGoNamedSpy extends GoRouter { } class GoRouterPushSpy extends GoRouter { - GoRouterPushSpy({required super.routes}); + GoRouterPushSpy({required List routes}) + : super.routingConfig( + routingConfig: + ConstantRoutingConfig(RoutingConfig(routes: routes))); String? myLocation; Object? extra; @@ -103,7 +116,10 @@ class GoRouterPushSpy extends GoRouter { } class GoRouterPushNamedSpy extends GoRouter { - GoRouterPushNamedSpy({required super.routes}); + GoRouterPushNamedSpy({required List routes}) + : super.routingConfig( + routingConfig: + ConstantRoutingConfig(RoutingConfig(routes: routes))); String? name; Map? pathParameters; @@ -126,7 +142,10 @@ class GoRouterPushNamedSpy extends GoRouter { } class GoRouterPopSpy extends GoRouter { - GoRouterPopSpy({required super.routes}); + GoRouterPopSpy({required List routes}) + : super.routingConfig( + routingConfig: + ConstantRoutingConfig(RoutingConfig(routes: routes))); bool popped = false; Object? poppedResult; @@ -175,6 +194,39 @@ Future createRouter( return goRouter; } +Future createRouterWithRoutingConfig( + ValueListenable config, + WidgetTester tester, { + String initialLocation = '/', + Object? initialExtra, + GlobalKey? navigatorKey, + GoRouterWidgetBuilder? errorBuilder, + String? restorationScopeId, + GoExceptionHandler? onException, + bool requestFocus = true, + bool overridePlatformDefaultLocation = false, +}) async { + final GoRouter goRouter = GoRouter.routingConfig( + routingConfig: config, + initialLocation: initialLocation, + onException: onException, + initialExtra: initialExtra, + errorBuilder: errorBuilder, + navigatorKey: navigatorKey, + restorationScopeId: restorationScopeId, + requestFocus: requestFocus, + overridePlatformDefaultLocation: overridePlatformDefaultLocation, + ); + await tester.pumpWidget( + MaterialApp.router( + restorationScopeId: + restorationScopeId != null ? '$restorationScopeId-root' : null, + routerConfig: goRouter, + ), + ); + return goRouter; +} + class TestErrorScreen extends DummyScreen { const TestErrorScreen(this.ex, {super.key}); @@ -307,3 +359,35 @@ RouteMatch createRouteMatch(RouteBase route, String location) { pageKey: ValueKey(location), ); } + +/// A routing config that is never going to change. +class ConstantRoutingConfig extends ValueListenable { + const ConstantRoutingConfig(this.value); + @override + void addListener(VoidCallback listener) { + // Intentionally empty because listener will never be called. + } + + @override + void removeListener(VoidCallback listener) { + // Intentionally empty because listener will never be called. + } + + @override + final RoutingConfig value; +} + +RouteConfiguration createRouteConfiguration({ + required List routes, + required GlobalKey navigatorKey, + required GoRouterRedirect topRedirect, + required int redirectLimit, +}) { + return RouteConfiguration( + ConstantRoutingConfig(RoutingConfig( + routes: routes, + redirect: topRedirect, + redirectLimit: redirectLimit, + )), + navigatorKey: navigatorKey); +}