diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index a37fabe336..ea232e2066 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,10 +1,14 @@ +## 4.0.0 + +- Refactors go_router and introduces GoRouteInformationProvider. [Migration Doc](http://flutter.dev/go/go-router-v4-breaking-changes) + ## 3.1.1 - Uses first match if there are more than one route to match. [ [#99833](https://github.com/flutter/flutter/issues/99833) ## 3.1.0 -- Added `GoRouteData` and `TypedGoRoute` to support `package:go_router_builder`. +- Adds `GoRouteData` and `TypedGoRoute` to support `package:go_router_builder`. ## 3.0.7 diff --git a/packages/go_router/README.md b/packages/go_router/README.md index 85f0712bb8..83dbc209b7 100644 --- a/packages/go_router/README.md +++ b/packages/go_router/README.md @@ -15,6 +15,7 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp.router( + routeInformationProvider: _router.routeInformationProvider, routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: 'GoRouter Example', diff --git a/packages/go_router/example/lib/async_data.dart b/packages/go_router/example/lib/async_data.dart index 2a87a83cdc..5dfa0037fb 100644 --- a/packages/go_router/example/lib/async_data.dart +++ b/packages/go_router/example/lib/async_data.dart @@ -26,6 +26,7 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp.router( + routeInformationProvider: _router.routeInformationProvider, routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: title, diff --git a/packages/go_router/example/lib/books/main.dart b/packages/go_router/example/lib/books/main.dart index 7f2059408d..241c36861a 100644 --- a/packages/go_router/example/lib/books/main.dart +++ b/packages/go_router/example/lib/books/main.dart @@ -31,6 +31,7 @@ class Bookstore extends StatelessWidget { Widget build(BuildContext context) => BookstoreAuthScope( notifier: _auth, child: MaterialApp.router( + routeInformationProvider: _router.routeInformationProvider, routerDelegate: _router.routerDelegate, routeInformationParser: _router.routeInformationParser, ), diff --git a/packages/go_router/example/lib/cupertino.dart b/packages/go_router/example/lib/cupertino.dart index 35112957d9..3f4bd10184 100644 --- a/packages/go_router/example/lib/cupertino.dart +++ b/packages/go_router/example/lib/cupertino.dart @@ -17,6 +17,7 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) => CupertinoApp.router( + routeInformationProvider: _router.routeInformationProvider, routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: title, diff --git a/packages/go_router/example/lib/error_screen.dart b/packages/go_router/example/lib/error_screen.dart index 984441bdf8..a578a832e9 100644 --- a/packages/go_router/example/lib/error_screen.dart +++ b/packages/go_router/example/lib/error_screen.dart @@ -17,6 +17,7 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp.router( + routeInformationProvider: _router.routeInformationProvider, routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: title, diff --git a/packages/go_router/example/lib/extra_param.dart b/packages/go_router/example/lib/extra_param.dart index 5e9141aff2..efab7bec9e 100644 --- a/packages/go_router/example/lib/extra_param.dart +++ b/packages/go_router/example/lib/extra_param.dart @@ -27,6 +27,7 @@ class App extends StatelessWidget { home: NoExtraParamOnWebScreen(), ) : MaterialApp.router( + routeInformationProvider: _router.routeInformationProvider, routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: title, diff --git a/packages/go_router/example/lib/init_loc.dart b/packages/go_router/example/lib/init_loc.dart index 4bef656a90..899c3c13cf 100644 --- a/packages/go_router/example/lib/init_loc.dart +++ b/packages/go_router/example/lib/init_loc.dart @@ -17,6 +17,7 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp.router( + routeInformationProvider: _router.routeInformationProvider, routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: title, diff --git a/packages/go_router/example/lib/loading_page.dart b/packages/go_router/example/lib/loading_page.dart index 2afb21e96c..9f61726087 100644 --- a/packages/go_router/example/lib/loading_page.dart +++ b/packages/go_router/example/lib/loading_page.dart @@ -55,6 +55,7 @@ class App extends StatelessWidget { Widget build(BuildContext context) => ChangeNotifierProvider.value( value: _appState, child: MaterialApp.router( + routeInformationProvider: _router.routeInformationProvider, routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: title, diff --git a/packages/go_router/example/lib/main.dart b/packages/go_router/example/lib/main.dart index ef33494a57..91b827b501 100644 --- a/packages/go_router/example/lib/main.dart +++ b/packages/go_router/example/lib/main.dart @@ -17,6 +17,7 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp.router( + routeInformationProvider: _router.routeInformationProvider, routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: title, diff --git a/packages/go_router/example/lib/named_routes.dart b/packages/go_router/example/lib/named_routes.dart index 648610d2ec..859a4bc5d9 100644 --- a/packages/go_router/example/lib/named_routes.dart +++ b/packages/go_router/example/lib/named_routes.dart @@ -24,6 +24,7 @@ class App extends StatelessWidget { Widget build(BuildContext context) => ChangeNotifierProvider.value( value: _loginInfo, child: MaterialApp.router( + routeInformationProvider: _router.routeInformationProvider, routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: title, diff --git a/packages/go_router/example/lib/nav_builder.dart b/packages/go_router/example/lib/nav_builder.dart index ba8818f4d7..8ec6b07e7d 100644 --- a/packages/go_router/example/lib/nav_builder.dart +++ b/packages/go_router/example/lib/nav_builder.dart @@ -22,6 +22,7 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp.router( + routeInformationProvider: _router.routeInformationProvider, routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: title, diff --git a/packages/go_router/example/lib/nav_observer.dart b/packages/go_router/example/lib/nav_observer.dart index 51cf45abb7..5afd016a45 100644 --- a/packages/go_router/example/lib/nav_observer.dart +++ b/packages/go_router/example/lib/nav_observer.dart @@ -18,6 +18,7 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp.router( + routeInformationProvider: _router.routeInformationProvider, routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: title, diff --git a/packages/go_router/example/lib/nested_nav.dart b/packages/go_router/example/lib/nested_nav.dart index 5940f73c19..04b8285ec6 100644 --- a/packages/go_router/example/lib/nested_nav.dart +++ b/packages/go_router/example/lib/nested_nav.dart @@ -19,6 +19,7 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp.router( + routeInformationProvider: _router.routeInformationProvider, routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: title, diff --git a/packages/go_router/example/lib/push.dart b/packages/go_router/example/lib/push.dart index 9d24495494..1288f12f6d 100644 --- a/packages/go_router/example/lib/push.dart +++ b/packages/go_router/example/lib/push.dart @@ -17,6 +17,7 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp.router( + routeInformationProvider: _router.routeInformationProvider, routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: title, diff --git a/packages/go_router/example/lib/query_params.dart b/packages/go_router/example/lib/query_params.dart index 9a660ae4d1..656273e24c 100644 --- a/packages/go_router/example/lib/query_params.dart +++ b/packages/go_router/example/lib/query_params.dart @@ -25,6 +25,7 @@ class App extends StatelessWidget { Widget build(BuildContext context) => ChangeNotifierProvider.value( value: _loginInfo, child: MaterialApp.router( + routeInformationProvider: _router.routeInformationProvider, routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: title, diff --git a/packages/go_router/example/lib/redirection.dart b/packages/go_router/example/lib/redirection.dart index 395e72719c..0cbe6ed127 100644 --- a/packages/go_router/example/lib/redirection.dart +++ b/packages/go_router/example/lib/redirection.dart @@ -25,6 +25,7 @@ class App extends StatelessWidget { Widget build(BuildContext context) => ChangeNotifierProvider.value( value: _loginInfo, child: MaterialApp.router( + routeInformationProvider: _router.routeInformationProvider, routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: title, diff --git a/packages/go_router/example/lib/router_neglect.dart b/packages/go_router/example/lib/router_neglect.dart index 0e3ebaccee..4f4acfb825 100644 --- a/packages/go_router/example/lib/router_neglect.dart +++ b/packages/go_router/example/lib/router_neglect.dart @@ -17,6 +17,7 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp.router( + routeInformationProvider: _router.routeInformationProvider, routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: title, diff --git a/packages/go_router/example/lib/router_stream_refresh.dart b/packages/go_router/example/lib/router_stream_refresh.dart index cb4607edd3..2d00dbe4ef 100644 --- a/packages/go_router/example/lib/router_stream_refresh.dart +++ b/packages/go_router/example/lib/router_stream_refresh.dart @@ -94,6 +94,7 @@ class _AppState extends State { Widget build(BuildContext context) => Provider.value( value: loggedInState, child: MaterialApp.router( + routeInformationProvider: router.routeInformationProvider, routeInformationParser: router.routeInformationParser, routerDelegate: router.routerDelegate, title: App.title, diff --git a/packages/go_router/example/lib/shared_scaffold.dart b/packages/go_router/example/lib/shared_scaffold.dart index 8699f67233..3dc0c109b3 100644 --- a/packages/go_router/example/lib/shared_scaffold.dart +++ b/packages/go_router/example/lib/shared_scaffold.dart @@ -19,6 +19,7 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp.router( + routeInformationProvider: _router.routeInformationProvider, routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: title, diff --git a/packages/go_router/example/lib/state_restoration.dart b/packages/go_router/example/lib/state_restoration.dart index 695fa86a96..33d64cac58 100644 --- a/packages/go_router/example/lib/state_restoration.dart +++ b/packages/go_router/example/lib/state_restoration.dart @@ -32,6 +32,7 @@ class _AppState extends State with RestorationMixin { @override Widget build(BuildContext context) => MaterialApp.router( + routeInformationProvider: _router.routeInformationProvider, routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: App.title, diff --git a/packages/go_router/example/lib/sub_routes.dart b/packages/go_router/example/lib/sub_routes.dart index 204fc30a44..68288dd4da 100644 --- a/packages/go_router/example/lib/sub_routes.dart +++ b/packages/go_router/example/lib/sub_routes.dart @@ -19,6 +19,7 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp.router( + routeInformationProvider: _router.routeInformationProvider, routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: title, diff --git a/packages/go_router/example/lib/transitions.dart b/packages/go_router/example/lib/transitions.dart index 2ccbcef9a4..08c8faa28d 100644 --- a/packages/go_router/example/lib/transitions.dart +++ b/packages/go_router/example/lib/transitions.dart @@ -17,6 +17,7 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp.router( + routeInformationProvider: _router.routeInformationProvider, routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: title, diff --git a/packages/go_router/example/lib/url_strategy.dart b/packages/go_router/example/lib/url_strategy.dart index f3b618a367..0bebb1b306 100644 --- a/packages/go_router/example/lib/url_strategy.dart +++ b/packages/go_router/example/lib/url_strategy.dart @@ -25,6 +25,7 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp.router( + routeInformationProvider: _router.routeInformationProvider, routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: App.title, diff --git a/packages/go_router/example/lib/user_input.dart b/packages/go_router/example/lib/user_input.dart index 3054cb7339..56cf9a95ae 100644 --- a/packages/go_router/example/lib/user_input.dart +++ b/packages/go_router/example/lib/user_input.dart @@ -20,6 +20,7 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp.router( + routeInformationProvider: _router.routeInformationProvider, routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: title, diff --git a/packages/go_router/example/lib/widgets_app.dart b/packages/go_router/example/lib/widgets_app.dart index 6474f3c89a..cd4cc50b42 100644 --- a/packages/go_router/example/lib/widgets_app.dart +++ b/packages/go_router/example/lib/widgets_app.dart @@ -21,6 +21,7 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) => WidgetsApp.router( + routeInformationProvider: _router.routeInformationProvider, routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: title, diff --git a/packages/go_router/lib/src/go_route_information_parser.dart b/packages/go_router/lib/src/go_route_information_parser.dart index d5a7b1549e..04de89a20f 100644 --- a/packages/go_router/lib/src/go_route_information_parser.dart +++ b/packages/go_router/lib/src/go_route_information_parser.dart @@ -4,20 +4,435 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import 'package:go_router/src/go_route_information_provider.dart'; + +import 'go_route.dart'; +import 'go_route_match.dart'; +import 'go_router_state.dart'; +import 'logging.dart'; +import 'path_parser.dart'; +import 'typedefs.dart'; + +class _ParserError extends Error implements UnsupportedError { + _ParserError(this.message); + + @override + final String? message; +} /// GoRouter implementation of the RouteInformationParser base class -class GoRouteInformationParser extends RouteInformationParser { - /// for use by the Router architecture as part of the RouteInformationParser - @override - Future parseRouteInformation( - RouteInformation routeInformation, - ) => - // Use [SynchronousFuture] so that the initial url is processed - // synchronously and remove unwanted initial animations on deep-linking - SynchronousFuture(Uri.parse(routeInformation.location!)); +class GoRouteInformationParser + extends RouteInformationParser> { + /// Creates a [GoRouteInformationParser]. + GoRouteInformationParser({ + required this.routes, + required this.redirectLimit, + required this.topRedirect, + this.debugRequireGoRouteInformationProvider = false, + }) : assert(() { + // check top-level route paths are valid + for (final GoRoute route in routes) { + assert(route.path.startsWith('/'), + 'top-level path must start with "/": ${route.path}'); + } + return true; + }()) { + _cacheNameToPath('', routes); + assert(() { + _debugLogKnownRoutes(); + return true; + }()); + } + + /// List of top level routes used by the go router delegate. + final List routes; + + /// The limit for the number of consecutive redirects. + final int redirectLimit; + + /// Top level page redirect. + final GoRouterRedirect topRedirect; + + /// A debug property to assert [GoRouteInformationProvider] is in use along + /// with this parser. + /// + /// An assertion error will be thrown if this property set to true and the + /// [GoRouteInformationProvider] is in not in use. + /// + /// Defaults to false. + final bool debugRequireGoRouteInformationProvider; + + final Map _nameToPath = {}; + + void _cacheNameToPath(String parentFullPath, List childRoutes) { + for (final GoRoute route in childRoutes) { + final String fullPath = concatenatePaths(parentFullPath, route.path); + + if (route.name != null) { + final String name = route.name!.toLowerCase(); + assert(!_nameToPath.containsKey(name), + 'duplication fullpaths for name "$name":${_nameToPath[name]}, $fullPath'); + _nameToPath[name] = fullPath; + } + + if (route.routes.isNotEmpty) { + _cacheNameToPath(fullPath, route.routes); + } + } + } + + /// Looks up the url location by a [GoRoute]'s name. + String namedLocation( + String name, { + Map params = const {}, + Map queryParams = const {}, + }) { + assert(() { + log.info('getting location for name: ' + '"$name"' + '${params.isEmpty ? '' : ', params: $params'}' + '${queryParams.isEmpty ? '' : ', queryParams: $queryParams'}'); + return true; + }()); + assert(_nameToPath.containsKey(name), 'unknown route name: $name'); + final String path = _nameToPath[name]!; + assert(() { + // Check that all required params are presented. + final List paramNames = []; + patternToRegExp(path, paramNames); + for (final String paramName in paramNames) { + assert(params.containsKey(paramName), + 'missing param "$paramName" for $path'); + } + + // Check that there are no extra params + for (final String key in params.keys) { + assert(paramNames.contains(key), 'unknown param "$key" for $path'); + } + return true; + }()); + final Map encodedParams = { + for (final MapEntry param in params.entries) + param.key: Uri.encodeComponent(param.value) + }; + final String location = patternToPath(path, encodedParams); + return Uri(path: location, queryParameters: queryParams).toString(); + } + + /// Concatenates two paths. + /// + /// e.g: pathA = /a, pathB = c/d, concatenatePaths(pathA, pathB) = /a/c/d. + static String concatenatePaths(String parentPath, String childPath) { + // at the root, just return the path + if (parentPath.isEmpty) { + assert(childPath.startsWith('/')); + assert(childPath == '/' || !childPath.endsWith('/')); + return childPath; + } + + // not at the root, so append the parent path + assert(childPath.isNotEmpty); + assert(!childPath.startsWith('/')); + assert(!childPath.endsWith('/')); + return '${parentPath == '/' ? '' : parentPath}/$childPath'; + } /// for use by the Router architecture as part of the RouteInformationParser @override - RouteInformation restoreRouteInformation(Uri configuration) => - RouteInformation(location: configuration.toString()); + Future> parseRouteInformation( + RouteInformation routeInformation, + ) { + assert(() { + if (debugRequireGoRouteInformationProvider) { + assert( + routeInformation is DebugGoRouteInformation, + 'This GoRouteInformationParser needs to be used with ' + 'GoRouteInformationProvider, do you forget to pass in ' + 'GoRouter.routeinformationProvider to the Router constructor?'); + } + return true; + }()); + final List matches = + _getLocRouteMatchesWithRedirects(routeInformation); + // Use [SynchronousFuture] so that the initial url is processed + // synchronously and remove unwanted initial animations on deep-linking + return SynchronousFuture>(matches); + } + + List _getLocRouteMatchesWithRedirects( + RouteInformation routeInformation) { + // start redirecting from the initial location + List matches; + final String location = routeInformation.location!; + try { + // watch redirects for loops + final List redirects = [_canonicalUri(location)]; + bool redirected(String? redir) { + if (redir == null) { + return false; + } + + assert(() { + if (Uri.tryParse(redir) == null) { + throw _ParserError('invalid redirect: $redir'); + } + if (redirects.contains(redir)) { + throw _ParserError('redirect loop detected: ${[ + ...redirects, + redir + ].join(' => ')}'); + } + if (redirects.length > redirectLimit) { + throw _ParserError('too many redirects: ${[ + ...redirects, + redir + ].join(' => ')}'); + } + return true; + }()); + + redirects.add(redir); + assert(() { + log.info('redirecting to $redir'); + return true; + }()); + return true; + } + + // keep looping till we're done redirecting + while (true) { + final String loc = redirects.last; + + // check for top-level redirect + final Uri uri = Uri.parse(loc); + if (redirected( + topRedirect( + GoRouterState( + this, + location: loc, + name: null, // no name available at the top level + // trim the query params off the subloc to match route.redirect + subloc: uri.path, + // pass along the query params 'cuz that's all we have right now + queryParams: uri.queryParameters, + extra: routeInformation.state, + ), + ), + )) { + continue; + } + + // get stack of route matches + matches = _getLocRouteMatches(loc, routeInformation.state); + + // merge new params to keep params from previously matched paths, e.g. + // /family/:fid/person/:pid provides fid and pid to person/:pid + Map previouslyMatchedParams = {}; + for (final GoRouteMatch match in matches) { + assert( + !previouslyMatchedParams.keys.any(match.encodedParams.containsKey), + 'Duplicated parameter names', + ); + match.encodedParams.addAll(previouslyMatchedParams); + previouslyMatchedParams = match.encodedParams; + } + + // check top route for redirect + final GoRouteMatch top = matches.last; + if (redirected( + top.route.redirect( + GoRouterState( + this, + location: loc, + subloc: top.subloc, + name: top.route.name, + path: top.route.path, + fullpath: top.fullpath, + params: top.decodedParams, + queryParams: top.queryParams, + ), + ), + )) { + continue; + } + + // no more redirects! + break; + } + + // note that we need to catch it this way to get all the info, e.g. the + // file/line info for an error in an inline function impl, e.g. an inline + // `redirect` impl + // ignore: avoid_catches_without_on_clauses + } on _ParserError catch (err) { + // create a match that routes to the error page + final Exception error = Exception(err.message); + final Uri uri = Uri.parse(location); + matches = [ + GoRouteMatch( + subloc: uri.path, + fullpath: uri.path, + encodedParams: {}, + queryParams: uri.queryParameters, + extra: null, + error: error, + route: GoRoute( + path: location, + pageBuilder: (BuildContext context, GoRouterState state) { + throw UnimplementedError(); + }), + ), + ]; + } + assert(matches.isNotEmpty); + return matches; + } + + List _getLocRouteMatches(String location, Object? extra) { + final Uri uri = Uri.parse(location); + return _getLocRouteRecursively( + loc: uri.path, + restLoc: uri.path, + routes: routes, + parentFullpath: '', + parentSubloc: '', + queryParams: uri.queryParameters, + extra: extra, + ); + } + + static List _getLocRouteRecursively({ + required String loc, + required String restLoc, + required String parentSubloc, + required List routes, + required String parentFullpath, + required Map queryParams, + required Object? extra, + }) { + bool debugGatherAllMatches = false; + assert(() { + debugGatherAllMatches = true; + return true; + }()); + final List> result = >[]; + // find the set of matches at this level of the tree + for (final GoRoute route in routes) { + final String fullpath = concatenatePaths(parentFullpath, route.path); + final GoRouteMatch? match = GoRouteMatch.match( + route: route, + restLoc: restLoc, + parentSubloc: parentSubloc, + fullpath: fullpath, + queryParams: queryParams, + extra: extra, + ); + + if (match == null) { + continue; + } + if (match.subloc.toLowerCase() == loc.toLowerCase()) { + // If it is a complete match, then return the matched route + // NOTE: need a lower case match because subloc is canonicalized to match + // the path case whereas the location can be of any case and still match + result.add([match]); + } else if (route.routes.isEmpty) { + // If it is partial match but no sub-routes, bail. + continue; + } else { + // otherwise recurse + final String childRestLoc = + loc.substring(match.subloc.length + (match.subloc == '/' ? 0 : 1)); + assert(loc.startsWith(match.subloc)); + assert(restLoc.isNotEmpty); + + final List subRouteMatch = _getLocRouteRecursively( + loc: loc, + restLoc: childRestLoc, + parentSubloc: match.subloc, + routes: route.routes, + parentFullpath: fullpath, + queryParams: queryParams, + extra: extra, + ).toList(); + + // if there's no sub-route matches, there is no match for this + // location + if (subRouteMatch.isEmpty) { + continue; + } + result.add([match, ...subRouteMatch]); + } + // Should only reach here if there is a match. + if (debugGatherAllMatches) { + continue; + } else { + break; + } + } + + if (result.isEmpty) { + throw _ParserError('no routes for location: $loc'); + } + + // If there are multiple routes that match the location, returning the first one. + // To make predefined routes to take precedence over dynamic routes eg. '/:id' + // consider adding the dynamic route at the end of the routes + return result.first; + } + + void _debugLogKnownRoutes() { + log.info('known full paths for routes:'); + _debugLogFullPathsFor(routes, '', 0); + + if (_nameToPath.isNotEmpty) { + log.info('known full paths for route names:'); + for (final MapEntry e in _nameToPath.entries) { + log.info(' ${e.key} => ${e.value}'); + } + } + } + + void _debugLogFullPathsFor( + List routes, + String parentFullpath, + int depth, + ) { + for (final GoRoute route in routes) { + final String fullpath = concatenatePaths(parentFullpath, route.path); + assert(() { + log.info(' => ${''.padLeft(depth * 2)}$fullpath'); + return true; + }()); + _debugLogFullPathsFor(route.routes, fullpath, depth + 1); + } + } + + /// for use by the Router architecture as part of the RouteInformationParser + @override + RouteInformation restoreRouteInformation(List configuration) { + return RouteInformation( + location: configuration.last.fullUriString, + state: configuration.last.extra); + } +} + +/// Normalizes the location string. +String _canonicalUri(String loc) { + String canon = Uri.parse(loc).toString(); + canon = canon.endsWith('?') ? canon.substring(0, canon.length - 1) : canon; + + // remove trailing slash except for when you shouldn't, e.g. + // /profile/ => /profile + // / => / + // /login?from=/ => login?from=/ + canon = canon.endsWith('/') && canon != '/' && !canon.contains('?') + ? canon.substring(0, canon.length - 1) + : canon; + + // /login/?from=/ => /login?from=/ + // /?from=/ => /?from=/ + canon = canon.replaceFirst('/?', '?', 1); + + return canon; } diff --git a/packages/go_router/lib/src/go_route_information_provider.dart b/packages/go_router/lib/src/go_route_information_provider.dart new file mode 100644 index 0000000000..549f6b6d75 --- /dev/null +++ b/packages/go_router/lib/src/go_route_information_provider.dart @@ -0,0 +1,120 @@ +// 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/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:go_router/src/go_route_information_parser.dart'; + +/// The route information provider created by go_router +class GoRouteInformationProvider extends RouteInformationProvider + with WidgetsBindingObserver, ChangeNotifier { + /// Creates a [GoRouteInformationProvider]. + GoRouteInformationProvider({ + required RouteInformation initialRouteInformation, + Listenable? refreshListenable, + }) : _refreshListenable = refreshListenable, + _value = initialRouteInformation { + _refreshListenable?.addListener(notifyListeners); + } + + final Listenable? _refreshListenable; + + // ignore: unnecessary_non_null_assertion + static WidgetsBinding get _binding => WidgetsBinding.instance!; + + @override + void routerReportsNewRouteInformation(RouteInformation routeInformation, + {RouteInformationReportingType type = + RouteInformationReportingType.none}) { + // Avoid adding a new history entry if the route is the same as before. + final bool replace = type == RouteInformationReportingType.neglect || + (type == RouteInformationReportingType.none && + _valueInEngine.location == routeInformation.location); + SystemNavigator.selectMultiEntryHistory(); + // TODO(chunhtai): should report extra to to browser through state if + // possible. + SystemNavigator.routeInformationUpdated( + location: routeInformation.location!, + replace: replace, + ); + _value = routeInformation; + _valueInEngine = routeInformation; + } + + @override + RouteInformation get value => DebugGoRouteInformation( + location: _value.location, + state: _value.state, + ); + RouteInformation _value; + set value(RouteInformation other) { + final bool shouldNotify = + value.location != other.location || value.state != other.state; + _value = other; + if (shouldNotify) { + notifyListeners(); + } + } + + RouteInformation _valueInEngine = + RouteInformation(location: _binding.platformDispatcher.defaultRouteName); + + void _platformReportsNewRouteInformation(RouteInformation routeInformation) { + if (_value == routeInformation) { + return; + } + _value = routeInformation; + _valueInEngine = routeInformation; + notifyListeners(); + } + + @override + void addListener(VoidCallback listener) { + if (!hasListeners) { + _binding.addObserver(this); + } + super.addListener(listener); + } + + @override + void removeListener(VoidCallback listener) { + super.removeListener(listener); + if (!hasListeners) { + _binding.removeObserver(this); + } + } + + @override + void dispose() { + if (hasListeners) { + _binding.removeObserver(this); + } + _refreshListenable?.removeListener(notifyListeners); + super.dispose(); + } + + @override + Future didPushRouteInformation( + RouteInformation routeInformation) async { + assert(hasListeners); + print('_platformReportsNewRouteInformation $routeInformation'); + _platformReportsNewRouteInformation(routeInformation); + return true; + } + + @override + Future didPushRoute(String route) async { + assert(hasListeners); + _platformReportsNewRouteInformation(RouteInformation(location: route)); + return true; + } +} + +/// A debug class that is used for asserting the [GoRouteInformationProvider] is +/// in use with the [GoRouteInformationParser]. +class DebugGoRouteInformation extends RouteInformation { + /// Creates + DebugGoRouteInformation({String? location, Object? state}) + : super(location: location, state: state); +} diff --git a/packages/go_router/lib/src/go_route_match.dart b/packages/go_router/lib/src/go_route_match.dart index 345aa5dfcc..35a03aa9c6 100644 --- a/packages/go_router/lib/src/go_route_match.dart +++ b/packages/go_router/lib/src/go_route_match.dart @@ -5,7 +5,7 @@ import 'package:flutter/foundation.dart'; import 'go_route.dart'; -import 'go_router_delegate.dart'; +import 'go_route_information_parser.dart'; import 'path_parser.dart'; /// Each GoRouteMatch instance represents an instance of a GoRoute for a @@ -22,7 +22,8 @@ class GoRouteMatch { required this.extra, required this.error, this.pageKey, - }) : assert(subloc.startsWith('/')), + }) : fullUriString = _addQueryParams(subloc, queryParams), + assert(subloc.startsWith('/')), assert(Uri.parse(subloc).queryParameters.isEmpty), assert(fullpath.startsWith('/')), assert(Uri.parse(fullpath).queryParameters.isEmpty), @@ -34,61 +35,16 @@ class GoRouteMatch { return true; }()); - // ignore: public_member_api_docs - factory GoRouteMatch.matchNamed({ - required GoRoute route, - required String name, // e.g. person - required String fullpath, // e.g. /family/:fid/person/:pid - required Map params, // e.g. {'fid': 'f2', 'pid': 'p1'} - required Map queryParams, // e.g. {'from': '/family/f2'} - required Object? extra, - }) { - assert(route.name != null); - assert(route.name!.toLowerCase() == name.toLowerCase()); - assert(() { - // check that we have all the params we need - final List paramNames = []; - patternToRegExp(fullpath, paramNames); - for (final String paramName in paramNames) { - assert(params.containsKey(paramName), - 'missing param "$paramName" for $fullpath'); - } - - // check that we have don't have extra params - for (final String key in params.keys) { - assert(paramNames.contains(key), 'unknown param "$key" for $fullpath'); - } - return true; - }()); - - final Map encodedParams = { - for (final MapEntry param in params.entries) - param.key: Uri.encodeComponent(param.value) - }; - - final String subloc = _locationFor(fullpath, encodedParams); - return GoRouteMatch( - route: route, - subloc: subloc, - fullpath: fullpath, - encodedParams: encodedParams, - queryParams: queryParams, - extra: extra, - error: null, - ); - } - // ignore: public_member_api_docs static GoRouteMatch? match({ required GoRoute route, required String restLoc, // e.g. person/p1 required String parentSubloc, // e.g. /family/f2 - required String path, // e.g. person/:pid required String fullpath, // e.g. /family/:fid/person/:pid required Map queryParams, required Object? extra, }) { - assert(!path.contains('//')); + assert(!route.path.contains('//')); final RegExpMatch? match = route.matchPatternAsPrefix(restLoc); if (match == null) { @@ -96,8 +52,9 @@ class GoRouteMatch { } final Map encodedParams = route.extractPathParams(match); - final String pathLoc = _locationFor(path, encodedParams); - final String subloc = GoRouterDelegate.fullLocFor(parentSubloc, pathLoc); + final String pathLoc = patternToPath(route.path, encodedParams); + final String subloc = + GoRouteInformationParser.concatenatePaths(parentSubloc, pathLoc); return GoRouteMatch( route: route, subloc: subloc, @@ -133,6 +90,18 @@ class GoRouteMatch { /// Optional value key of type string, to hold a unique reference to a page. final ValueKey? pageKey; + /// The full uri string + final String fullUriString; // e.g. /family/12?query=14 + + static String _addQueryParams(String loc, Map queryParams) { + final Uri uri = Uri.parse(loc); + assert(uri.queryParameters.isEmpty); + return Uri( + path: uri.path, + queryParameters: queryParams.isEmpty ? null : queryParams) + .toString(); + } + /// Parameters for the matched route, URI-decoded. Map get decodedParams => { for (final MapEntry param in encodedParams.entries) @@ -142,8 +111,4 @@ class GoRouteMatch { /// for use by the Router architecture as part of the GoRouteMatch @override String toString() => 'GoRouteMatch($fullpath, $encodedParams)'; - - /// expand a path w/ param slots using params, e.g. family/:fid => family/f1 - static String _locationFor(String pattern, Map params) => - patternToPath(pattern, params); } diff --git a/packages/go_router/lib/src/go_router.dart b/packages/go_router/lib/src/go_router.dart index 7ed7425f19..b3e4c3f814 100644 --- a/packages/go_router/lib/src/go_router.dart +++ b/packages/go_router/lib/src/go_router.dart @@ -6,6 +6,8 @@ import 'package:flutter/widgets.dart'; import 'go_route.dart'; import 'go_route_information_parser.dart'; +import 'go_route_information_provider.dart'; +import 'go_route_match.dart'; import 'go_router_delegate.dart'; import 'go_router_state.dart'; import 'inherited_go_router.dart'; @@ -30,7 +32,7 @@ class GoRouter extends ChangeNotifier with NavigatorObserver { Listenable? refreshListenable, int redirectLimit = 5, bool routerNeglect = false, - String initialLocation = '/', + String? initialLocation, UrlPathStrategy? urlPathStrategy, List? observers, bool debugLogDiagnostics = false, @@ -42,21 +44,31 @@ class GoRouter extends ChangeNotifier with NavigatorObserver { } setLogging(enabled: debugLogDiagnostics); + WidgetsFlutterBinding.ensureInitialized(); - routerDelegate = GoRouterDelegate( + final String _effectiveInitialLocation = initialLocation ?? + // ignore: unnecessary_non_null_assertion + WidgetsBinding.instance!.platformDispatcher.defaultRouteName; + routeInformationParser = GoRouteInformationParser( routes: routes, - errorPageBuilder: errorPageBuilder, - errorBuilder: errorBuilder, topRedirect: redirect ?? (_) => null, redirectLimit: redirectLimit, - refreshListenable: refreshListenable, + debugRequireGoRouteInformationProvider: true, + ); + routeInformationProvider = GoRouteInformationProvider( + initialRouteInformation: + RouteInformation(location: _effectiveInitialLocation), + refreshListenable: refreshListenable); + + routerDelegate = GoRouterDelegate( + routeInformationParser, + errorPageBuilder: errorPageBuilder, + errorBuilder: errorBuilder, routerNeglect: routerNeglect, - initUri: Uri.parse(initialLocation), observers: [ ...observers ?? [], this ], - debugLogDiagnostics: debugLogDiagnostics, restorationScopeId: restorationScopeId, // wrap the returned Navigator to enable GoRouter.of(context).go() et al, // allowing the caller to wrap the navigator themselves @@ -67,17 +79,23 @@ class GoRouter extends ChangeNotifier with NavigatorObserver { child: navigatorBuilder?.call(context, state, nav) ?? nav, ), ); + assert(() { + log.info('setting initial location $initialLocation'); + return true; + }()); } /// The route information parser used by the go router. - final GoRouteInformationParser routeInformationParser = - GoRouteInformationParser(); + late final GoRouteInformationParser routeInformationParser; /// The router delegate used by the go router. late final GoRouterDelegate routerDelegate; + /// The route information provider used by the go router. + late final GoRouteInformationProvider routeInformationProvider; + /// Get the current location. - String get location => routerDelegate.currentConfiguration.toString(); + String get location => routerDelegate.currentConfiguration.last.fullUriString; /// Get a location from route name and parameters. /// This is useful for redirecting to a named location. @@ -86,7 +104,7 @@ class GoRouter extends ChangeNotifier with NavigatorObserver { Map params = const {}, Map queryParams = const {}, }) => - routerDelegate.namedLocation( + routeInformationParser.namedLocation( name, params: params, queryParams: queryParams, @@ -94,8 +112,14 @@ class GoRouter extends ChangeNotifier with NavigatorObserver { /// Navigate to a URI location w/ optional query parameters, e.g. /// `/family/f2/person/p1?color=blue` - void go(String location, {Object? extra}) => - routerDelegate.go(location, extra: extra); + void go(String location, {Object? extra}) { + assert(() { + log.info('going to $location'); + return true; + }()); + routeInformationProvider.value = + RouteInformation(location: location, state: extra); + } /// Navigate to a named route w/ optional parameters, e.g. /// `name='person', params={'fid': 'f2', 'pid': 'p1'}` @@ -113,8 +137,18 @@ class GoRouter extends ChangeNotifier with NavigatorObserver { /// Push a URI location onto the page stack w/ optional query parameters, e.g. /// `/family/f2/person/p1?color=blue` - void push(String location, {Object? extra}) => - routerDelegate.push(location, extra: extra); + void push(String location, {Object? extra}) { + assert(() { + log.info('pushing $location'); + return true; + }()); + routeInformationParser + .parseRouteInformation( + DebugGoRouteInformation(location: location, state: extra)) + .then((List matches) { + routerDelegate.push(matches.last); + }); + } /// Push a named route onto the page stack w/ optional parameters, e.g. /// `name='person', params={'fid': 'f2', 'pid': 'p1'}` @@ -133,7 +167,13 @@ class GoRouter extends ChangeNotifier with NavigatorObserver { void pop() => routerDelegate.pop(); /// Refresh the route. - void refresh() => routerDelegate.refresh(); + void refresh() { + assert(() { + log.info('refreshing $location'); + return true; + }()); + routeInformationProvider.notifyListeners(); + } /// Set the app's URL path strategy (defaults to hash). call before runApp(). static void setUrlPathStrategy(UrlPathStrategy strategy) => @@ -166,4 +206,11 @@ class GoRouter extends ChangeNotifier with NavigatorObserver { @override void didReplace({Route? newRoute, Route? oldRoute}) => notifyListeners(); + + @override + void dispose() { + routeInformationProvider.dispose(); + routerDelegate.dispose(); + super.dispose(); + } } diff --git a/packages/go_router/lib/src/go_router_delegate.dart b/packages/go_router/lib/src/go_router_delegate.dart index f901a94de1..fed213ceca 100644 --- a/packages/go_router/lib/src/go_router_delegate.dart +++ b/packages/go_router/lib/src/go_router_delegate.dart @@ -8,7 +8,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'custom_transition_page.dart'; -import 'go_route.dart'; +import 'go_route_information_parser.dart'; import 'go_route_match.dart'; import 'go_router_cupertino.dart'; import 'go_router_error_page.dart'; @@ -19,76 +19,36 @@ import 'route_data.dart'; import 'typedefs.dart'; /// GoRouter implementation of the RouterDelegate base class. -class GoRouterDelegate extends RouterDelegate - with - PopNavigatorRouterDelegateMixin, - // ignore: prefer_mixin - ChangeNotifier { +class GoRouterDelegate extends RouterDelegate> + with PopNavigatorRouterDelegateMixin>, ChangeNotifier { /// Constructor for GoRouter's implementation of the /// RouterDelegate base class. - GoRouterDelegate({ + GoRouterDelegate( + this._parser, { required this.builderWithNav, - required this.routes, required this.errorPageBuilder, required this.errorBuilder, - required this.topRedirect, - required this.redirectLimit, - required this.refreshListenable, - required Uri initUri, required this.observers, - required this.debugLogDiagnostics, required this.routerNeglect, this.restorationScopeId, - }) : assert(() { - // check top-level route paths are valid - for (final GoRoute route in routes) { - assert(route.path.startsWith('/'), - 'top-level path must start with "/": ${route.path}'); - } - return true; - }()) { - // cache the set of named routes for fast lookup - _cacheNamedRoutes(routes, '', _namedMatches); + }); - // output known routes - _outputKnownRoutes(); - - // build the list of route matches - log.info('setting initial location $initUri'); - _go(initUri.toString()); - - // when the listener changes, refresh the route - refreshListenable?.addListener(refresh); - } + // TODO(chunhtai): remove this once namedLocation is removed from go_router. + final GoRouteInformationParser _parser; /// Builder function for a go router with Navigator. final GoRouterBuilderWithNav builderWithNav; - /// List of top level routes used by the go router delegate. - final List routes; - /// Error page builder for the go router delegate. final GoRouterPageBuilder? errorPageBuilder; /// Error widget builder for the go router delegate. final GoRouterWidgetBuilder? errorBuilder; - /// Top level page redirect. - final GoRouterRedirect topRedirect; - - /// The limit for the number of consecutive redirects. - final int redirectLimit; - - /// Listenable used to cause the router to refresh it's route. - final Listenable? refreshListenable; - /// NavigatorObserver used to receive change notifications when /// navigation changes. final List observers; - /// Set to true to log diagnostic info for your routes. - final bool debugLogDiagnostics; - /// Set to true to disable creating history entries on the web. final bool routerNeglect; @@ -97,78 +57,11 @@ class GoRouterDelegate extends RouterDelegate final String? restorationScopeId; final GlobalKey _key = GlobalKey(); - final List _matches = []; - final Map _namedMatches = {}; - final Map _pushCounts = {}; - - void _cacheNamedRoutes( - List routes, - String parentFullpath, - Map namedFullpaths, - ) { - for (final GoRoute route in routes) { - final String fullpath = fullLocFor(parentFullpath, route.path); - - if (route.name != null) { - final String name = route.name!.toLowerCase(); - assert(!namedFullpaths.containsKey(name), - 'duplication fullpaths for name "$name":${namedFullpaths[name]!.fullpath}, $fullpath'); - - // we only have a partial match until we have a location; - // we're really only caching the route and fullpath at this point - final GoRouteMatch match = GoRouteMatch( - route: route, - subloc: '/TBD', - fullpath: fullpath, - encodedParams: {}, - queryParams: {}, - extra: null, - error: null, - ); - - namedFullpaths[name] = match; - } - - if (route.routes.isNotEmpty) { - _cacheNamedRoutes(route.routes, fullpath, namedFullpaths); - } - } - } - - /// Get a location from route name and parameters. - /// This is useful for redirecting to a named location. - String namedLocation( - String name, { - required Map params, - required Map queryParams, - }) { - log.info('getting location for name: ' - '"$name"' - '${params.isEmpty ? '' : ', params: $params'}' - '${queryParams.isEmpty ? '' : ', queryParams: $queryParams'}'); - - // find route and build up the full path along the way - final GoRouteMatch? match = _getNameRouteMatch( - name.toLowerCase(), // case-insensitive name matching - params: params, - queryParams: queryParams, - ); - assert(match != null, 'unknown route name: $name'); - assert(identical(match!.queryParams, queryParams)); - return _addQueryParams(match!.subloc, queryParams); - } - - /// Navigate to the given location. - void go(String location, {Object? extra}) { - log.info('going to $location'); - _go(location, extra: extra); - notifyListeners(); - } + List _matches = const []; /// Push the given location onto the page stack - void push(String location, {Object? extra}) { - log.info('pushing $location'); - _push(location, extra: extra); + void push(GoRouteMatch match) { + _matches.add(match); notifyListeners(); } @@ -180,35 +73,17 @@ class GoRouterDelegate extends RouterDelegate notifyListeners(); } - /// Refresh the current location, including re-evaluating redirections. - void refresh() { - log.info('refreshing $location'); - _go(location, extra: _matches.last.extra); - notifyListeners(); - } - - /// Get the current location, e.g. /family/f2/person/p1 - String get location => - _addQueryParams(_matches.last.subloc, _matches.last.queryParams); - /// For internal use; visible for testing only. @visibleForTesting List get matches => _matches; - /// Dispose resources held by the router delegate. - @override - void dispose() { - refreshListenable?.removeListener(refresh); - super.dispose(); - } - /// For use by the Router architecture as part of the RouterDelegate. @override GlobalKey get navigatorKey => _key; /// For use by the Router architecture as part of the RouterDelegate. @override - Uri get currentConfiguration => Uri.parse(location); + List get currentConfiguration => _matches; /// For use by the Router architecture as part of the RouterDelegate. @override @@ -216,394 +91,17 @@ class GoRouterDelegate extends RouterDelegate /// For use by the Router architecture as part of the RouterDelegate. @override - Future setInitialRoutePath(Uri configuration) { - // if the initial location is /, then use the dev initial location; - // otherwise, we're cruising to a deep link, so ignore dev initial location - final String config = configuration.toString(); - if (config == '/') { - _go(location); - } else { - log.info('deep linking to $config'); - _go(config); - } - + Future setNewRoutePath(List configuration) { + _matches = configuration; // Use [SynchronousFuture] so that the initial url is processed // synchronously and remove unwanted initial animations on deep-linking return SynchronousFuture(null); } - /// For use by the Router architecture as part of the RouterDelegate. - @override - Future setNewRoutePath(Uri configuration) async { - final String config = configuration.toString(); - log.info('going to $config'); - _go(config); - } - - void _go(String location, {Object? extra}) { - final List matches = - _getLocRouteMatchesWithRedirects(location, extra: extra); - assert(matches.isNotEmpty); - - // replace the stack of matches w/ the new ones - _matches - ..clear() - ..addAll(matches); - } - - void _push(String location, {Object? extra}) { - final List matches = - _getLocRouteMatchesWithRedirects(location, extra: extra); - assert(matches.isNotEmpty); - final GoRouteMatch top = matches.last; - - // remap the pageKey so allow any number of the same page on the stack - final String fullpath = top.fullpath; - final int count = (_pushCounts[fullpath] ?? 0) + 1; - _pushCounts[fullpath] = count; - final ValueKey pageKey = ValueKey('$fullpath-p$count'); - final GoRouteMatch match = GoRouteMatch( - route: top.route, - subloc: top.subloc, - fullpath: top.fullpath, - encodedParams: top.encodedParams, - queryParams: top.queryParams, - extra: extra, - error: null, - pageKey: pageKey, - ); - - // add a new match onto the stack of matches - assert(matches.isNotEmpty); - _matches.add(match); - } - - List _getLocRouteMatchesWithRedirects( - String location, { - required Object? extra, - }) { - // start redirecting from the initial location - List matches; - - try { - // watch redirects for loops - final List redirects = [_canonicalUri(location)]; - bool redirected(String? redir) { - if (redir == null) { - return false; - } - - assert(Uri.tryParse(redir) != null, 'invalid redirect: $redir'); - - assert( - !redirects.contains(redir), - 'redirect loop detected: ${[ - ...redirects, - redir - ].join(' => ')}'); - assert( - redirects.length < redirectLimit, - 'too many redirects: ${[ - ...redirects, - redir - ].join(' => ')}'); - - redirects.add(redir); - log.info('redirecting to $redir'); - return true; - } - - // keep looping till we're done redirecting - while (true) { - final String loc = redirects.last; - - // check for top-level redirect - final Uri uri = Uri.parse(loc); - if (redirected( - topRedirect( - GoRouterState( - this, - location: loc, - name: null, // no name available at the top level - // trim the query params off the subloc to match route.redirect - subloc: uri.path, - // pass along the query params 'cuz that's all we have right now - queryParams: uri.queryParameters, - ), - ), - )) { - continue; - } - - // get stack of route matches - matches = _getLocRouteMatches(loc, extra: extra); - - // merge new params to keep params from previously matched paths, e.g. - // /family/:fid/person/:pid provides fid and pid to person/:pid - Map previouslyMatchedParams = {}; - for (final GoRouteMatch match in matches) { - assert( - !previouslyMatchedParams.keys.any(match.encodedParams.containsKey), - 'Duplicated parameter names', - ); - match.encodedParams.addAll(previouslyMatchedParams); - previouslyMatchedParams = match.encodedParams; - } - - // check top route for redirect - final GoRouteMatch top = matches.last; - if (redirected( - top.route.redirect( - GoRouterState( - this, - location: loc, - subloc: top.subloc, - name: top.route.name, - path: top.route.path, - fullpath: top.fullpath, - params: top.decodedParams, - queryParams: top.queryParams, - extra: extra, - ), - ), - )) { - continue; - } - - // let Router know to update the address bar - // (the initial route is not a redirect) - if (redirects.length > 1) { - notifyListeners(); - } - - // no more redirects! - break; - } - - // note that we need to catch it this way to get all the info, e.g. the - // file/line info for an error in an inline function impl, e.g. an inline - // `redirect` impl - // ignore: avoid_catches_without_on_clauses - } catch (err, stack) { - log.severe('Exception during GoRouter navigation', err, stack); - - // create a match that routes to the error page - final Exception error = err is Exception ? err : Exception(err); - final Uri uri = Uri.parse(location); - matches = [ - GoRouteMatch( - subloc: uri.path, - fullpath: uri.path, - encodedParams: {}, - queryParams: uri.queryParameters, - extra: null, - error: error, - route: GoRoute( - path: location, - pageBuilder: (BuildContext context, GoRouterState state) => - _errorPageBuilder( - context, - GoRouterState( - this, - location: state.location, - subloc: state.subloc, - name: state.name, - path: state.path, - error: error, - fullpath: state.path, - params: state.params, - queryParams: state.queryParams, - extra: state.extra, - ), - ), - ), - ), - ]; - } - - assert(matches.isNotEmpty); - return matches; - } - - List _getLocRouteMatches( - String location, { - Object? extra, - }) { - final Uri uri = Uri.parse(location); - final List> matchStacks = _getLocRouteMatchStacks( - loc: uri.path, - restLoc: uri.path, - routes: routes, - parentFullpath: '', - parentSubloc: '', - queryParams: uri.queryParameters, - extra: extra, - ).toList(); - - assert(matchStacks.isNotEmpty, 'no routes for location: $location'); - - // If there are multiple routes that match the location, returning the first one. - // To make predefined routes to take precedence over dynamic routes eg. '/:id' - // consider adding the dynamic route at the end of the routes - - return matchStacks.first; - } - - /// turns a list of routes into a list of routes match stacks for the location - /// e.g. routes: [ - /// / - /// family/:fid - /// /login - /// ] - /// - /// loc: / - /// stacks: [ - /// matches: [ - /// match(route.path=/, loc=/) - /// ] - /// ] - /// - /// loc: /login - /// stacks: [ - /// matches: [ - /// match(route.path=/login, loc=login) - /// ] - /// ] - /// - /// loc: /family/f2 - /// stacks: [ - /// matches: [ - /// match(route.path=/, loc=/), - /// match(route.path=family/:fid, loc=family/f2, params=[fid=f2]) - /// ] - /// ] - /// - /// loc: /family/f2/person/p1 - /// stacks: [ - /// matches: [ - /// match(route.path=/, loc=/), - /// match(route.path=family/:fid, loc=family/f2, params=[fid=f2]) - /// match(route.path=person/:pid, loc=person/p1, params=[fid=f2, pid=p1]) - /// ] - /// ] - /// - /// A stack count of 0 means there's no match. - /// A stack count of >1 means there's a malformed set of routes. - /// - /// NOTE: Uses recursion, which is why _getLocRouteMatchStacks calls this - /// function and does the actual error checking, using the returned stacks to - /// provide better errors - static Iterable> _getLocRouteMatchStacks({ - required String loc, - required String restLoc, - required String parentSubloc, - required List routes, - required String parentFullpath, - required Map queryParams, - required Object? extra, - }) sync* { - // find the set of matches at this level of the tree - for (final GoRoute route in routes) { - final String fullpath = fullLocFor(parentFullpath, route.path); - final GoRouteMatch? match = GoRouteMatch.match( - route: route, - restLoc: restLoc, - parentSubloc: parentSubloc, - path: route.path, - fullpath: fullpath, - queryParams: queryParams, - extra: extra, - ); - if (match == null) { - continue; - } - - // if we have a complete match, then return the matched route - // NOTE: need a lower case match because subloc is canonicalized to match - // the path case whereas the location can be of any case and still match - if (match.subloc.toLowerCase() == loc.toLowerCase()) { - yield [match]; - continue; - } - - // if we have a partial match but no sub-routes, bail - if (route.routes.isEmpty) { - continue; - } - - // otherwise recurse - final String childRestLoc = - loc.substring(match.subloc.length + (match.subloc == '/' ? 0 : 1)); - assert(loc.startsWith(match.subloc)); - assert(restLoc.isNotEmpty); - - // if there's no sub-route matches, then we don't have a match for this - // location - final List> subRouteMatchStacks = - _getLocRouteMatchStacks( - loc: loc, - restLoc: childRestLoc, - parentSubloc: match.subloc, - routes: route.routes, - parentFullpath: fullpath, - queryParams: queryParams, - extra: extra, - ).toList(); - if (subRouteMatchStacks.isEmpty) { - continue; - } - - // add the match to each of the sub-route match stacks and return them - for (final List stack in subRouteMatchStacks) { - yield [match, ...stack]; - } - } - } - - GoRouteMatch? _getNameRouteMatch( - String name, { - Map params = const {}, - Map queryParams = const {}, - Object? extra, - }) { - final GoRouteMatch? partialMatch = _namedMatches[name]; - return partialMatch == null - ? null - : GoRouteMatch.matchNamed( - name: name, - route: partialMatch.route, - fullpath: partialMatch.fullpath, - params: params, - queryParams: queryParams, - extra: extra, - ); - } - - // e.g. - // parentFullLoc: '', path => '/' - // parentFullLoc: '/', path => 'family/:fid' => '/family/:fid' - // parentFullLoc: '/', path => 'family/f2' => '/family/f2' - // parentFullLoc: '/family/f2', path => 'parent/p1' => '/family/f2/person/p1' - // ignore: public_member_api_docs - static String fullLocFor(String parentFullLoc, String path) { - // at the root, just return the path - if (parentFullLoc.isEmpty) { - assert(path.startsWith('/')); - assert(path == '/' || !path.endsWith('/')); - return path; - } - - // not at the root, so append the parent path - assert(path.isNotEmpty); - assert(!path.startsWith('/')); - assert(!path.endsWith('/')); - return '${parentFullLoc == '/' ? '' : parentFullLoc}/$path'; - } - Widget _builder(BuildContext context, Iterable matches) { List>? pages; Exception? error; - + final String location = matches.last.fullUriString; try { // build the stack of pages if (routerNeglect) { @@ -620,7 +118,10 @@ class GoRouterDelegate extends RouterDelegate // `redirect` impl // ignore: avoid_catches_without_on_clauses } catch (err, stack) { - log.severe('Exception during GoRouter navigation', err, stack); + assert(() { + log.severe('Exception during GoRouter navigation', err, stack); + return true; + }()); // if there's an error, show an error page error = err is Exception ? err : Exception(err); @@ -629,7 +130,7 @@ class GoRouterDelegate extends RouterDelegate _errorPageBuilder( context, GoRouterState( - this, + _parser, location: location, subloc: uri.path, name: null, @@ -654,7 +155,7 @@ class GoRouterDelegate extends RouterDelegate return builderWithNav( context, GoRouterState( - this, + _parser, location: location, name: null, // no name available at the top level // trim the query params off the subloc to match route.redirect @@ -715,20 +216,24 @@ class GoRouterDelegate extends RouterDelegate // get a page from the builder and associate it with a sub-location final GoRouterState state = GoRouterState( - this, - location: location, + _parser, + location: match.fullUriString, subloc: match.subloc, name: match.route.name, path: match.route.path, fullpath: match.fullpath, params: params, + error: match.error, queryParams: match.queryParams, extra: match.extra, pageKey: match.pageKey, // push() remaps the page key for uniqueness ); + if (match.error != null) { + yield _errorPageBuilder(context, state); + break; + } final GoRouterPageBuilder? pageBuilder = match.route.pageBuilder; - Page? page; if (pageBuilder != null) { page = pageBuilder(context, state); @@ -763,17 +268,26 @@ class GoRouterDelegate extends RouterDelegate final Element? elem = context is Element ? context : null; if (elem != null && isMaterialApp(elem)) { - log.info('MaterialApp found'); + assert(() { + log.info('MaterialApp found'); + return true; + }()); _pageBuilderForAppType = pageBuilderForMaterialApp; _errorBuilderForAppType = (BuildContext c, GoRouterState s) => GoRouterMaterialErrorScreen(s.error); } else if (elem != null && isCupertinoApp(elem)) { - log.info('CupertinoApp found'); + assert(() { + log.info('CupertinoApp found'); + return true; + }()); _pageBuilderForAppType = pageBuilderForCupertinoApp; _errorBuilderForAppType = (BuildContext c, GoRouterState s) => GoRouterCupertinoErrorScreen(s.error); } else { - log.info('WidgetsApp assumed'); + assert(() { + log.info('WidgetsApp found'); + return true; + }()); _pageBuilderForAppType = pageBuilderForWidgetApp; _errorBuilderForAppType = (BuildContext c, GoRouterState s) => GoRouterErrorScreen(s.error); @@ -835,54 +349,4 @@ class GoRouterDelegate extends RouterDelegate errorBuilder ?? _errorBuilderForAppType!, ); } - - void _outputKnownRoutes() { - log.info('known full paths for routes:'); - _outputFullPathsFor(routes, '', 0); - - if (_namedMatches.isNotEmpty) { - log.info('known full paths for route names:'); - for (final MapEntry e in _namedMatches.entries) { - log.info(' ${e.key} => ${e.value.fullpath}'); - } - } - } - - void _outputFullPathsFor( - List routes, - String parentFullpath, - int depth, - ) { - for (final GoRoute route in routes) { - final String fullpath = fullLocFor(parentFullpath, route.path); - log.info(' => ${''.padLeft(depth * 2)}$fullpath'); - _outputFullPathsFor(route.routes, fullpath, depth + 1); - } - } - - static String _canonicalUri(String loc) { - String canon = Uri.parse(loc).toString(); - canon = canon.endsWith('?') ? canon.substring(0, canon.length - 1) : canon; - - // remove trailing slash except for when you shouldn't, e.g. - // /profile/ => /profile - // / => / - // /login?from=/ => login?from=/ - canon = canon.endsWith('/') && canon != '/' && !canon.contains('?') - ? canon.substring(0, canon.length - 1) - : canon; - - // /login/?from=/ => /login?from=/ - // /?from=/ => /?from=/ - canon = canon.replaceFirst('/?', '?', 1); - - return canon; - } - - static String _addQueryParams(String loc, Map queryParams) { - final Uri uri = Uri.parse(loc); - assert(uri.queryParameters.isEmpty); - return _canonicalUri( - Uri(path: uri.path, queryParameters: queryParams).toString()); - } } diff --git a/packages/go_router/lib/src/go_router_state.dart b/packages/go_router/lib/src/go_router_state.dart index ffcb072900..9db78342fd 100644 --- a/packages/go_router/lib/src/go_router_state.dart +++ b/packages/go_router/lib/src/go_router_state.dart @@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart'; -import 'go_router_delegate.dart'; +import 'go_route_information_parser.dart'; /// The route state during routing. class GoRouterState { @@ -29,7 +29,8 @@ class GoRouterState { : subloc), assert((path ?? '').isEmpty == (fullpath ?? '').isEmpty); - final GoRouterDelegate _delegate; + // TODO(chunhtai): remove this once namedLocation is removed from go_router. + final GoRouteInformationParser _delegate; /// The full location of the route, e.g. /family/f2/person/p1 final String location; @@ -67,6 +68,8 @@ class GoRouterState { String name, { Map params = const {}, Map queryParams = const {}, - }) => - _delegate.namedLocation(name, params: params, queryParams: queryParams); + }) { + return _delegate.namedLocation(name, + params: params, queryParams: queryParams); + } } diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index 552c4313a8..0743dad434 100644 --- a/packages/go_router/pubspec.yaml +++ b/packages/go_router/pubspec.yaml @@ -1,13 +1,12 @@ name: go_router description: A declarative router for Flutter based on Navigation 2 supporting deep linking, data-driven routes and more -version: 3.1.1 +version: 4.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 environment: sdk: ">=2.12.0 <3.0.0" - flutter: ">=2.0.0" dependencies: collection: ^1.15.0 diff --git a/packages/go_router/test/custom_transition_page_test.dart b/packages/go_router/test/custom_transition_page_test.dart index 98ac8cd492..28c622ae5d 100644 --- a/packages/go_router/test/custom_transition_page_test.dart +++ b/packages/go_router/test/custom_transition_page_test.dart @@ -24,6 +24,7 @@ void main() { ); await tester.pumpWidget( MaterialApp.router( + routeInformationProvider: router.routeInformationProvider, routeInformationParser: router.routeInformationParser, routerDelegate: router.routerDelegate, title: 'GoRouter Example', diff --git a/packages/go_router/test/error_screen_helpers.dart b/packages/go_router/test/error_screen_helpers.dart index c8ae740311..c33d3282ae 100644 --- a/packages/go_router/test/error_screen_helpers.dart +++ b/packages/go_router/test/error_screen_helpers.dart @@ -51,6 +51,7 @@ WidgetTesterCallback testClickingTheButtonRedirectsToRoot({ Widget materialAppRouterBuilder(GoRouter router) { return MaterialApp.router( + routeInformationProvider: router.routeInformationProvider, routeInformationParser: router.routeInformationParser, routerDelegate: router.routerDelegate, title: 'GoRouter Example', @@ -59,6 +60,7 @@ Widget materialAppRouterBuilder(GoRouter router) { Widget cupertinoAppRouterBuilder(GoRouter router) { return CupertinoApp.router( + routeInformationProvider: router.routeInformationProvider, routeInformationParser: router.routeInformationParser, routerDelegate: router.routerDelegate, title: 'GoRouter Example', diff --git a/packages/go_router/test/go_route_information_parser_test.dart b/packages/go_router/test/go_route_information_parser_test.dart new file mode 100644 index 0000000000..426d5846ea --- /dev/null +++ b/packages/go_router/test/go_route_information_parser_test.dart @@ -0,0 +1,192 @@ +// 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 'package:go_router/src/go_route_information_parser.dart'; +import 'package:go_router/src/go_route_match.dart'; + +void main() { + test('GoRouteInformationParser can parse route', () async { + final List routes = [ + GoRoute( + path: '/', + builder: (_, __) => const Placeholder(), + routes: [ + GoRoute( + path: 'abc', + builder: (_, __) => const Placeholder(), + ), + ], + ), + ]; + final GoRouteInformationParser parser = GoRouteInformationParser( + routes: routes, + redirectLimit: 100, + topRedirect: (_) => null, + ); + + List matches = await parser + .parseRouteInformation(const RouteInformation(location: '/')); + expect(matches.length, 1); + expect(matches[0].queryParams.isEmpty, isTrue); + expect(matches[0].extra, isNull); + expect(matches[0].fullUriString, '/'); + expect(matches[0].subloc, '/'); + expect(matches[0].route, routes[0]); + + final Object extra = Object(); + matches = await parser.parseRouteInformation( + RouteInformation(location: '/abc?def=ghi', state: extra)); + expect(matches.length, 2); + expect(matches[0].queryParams.length, 1); + expect(matches[0].queryParams['def'], 'ghi'); + expect(matches[0].extra, extra); + expect(matches[0].fullUriString, '/?def=ghi'); + expect(matches[0].subloc, '/'); + expect(matches[0].route, routes[0]); + + expect(matches[1].queryParams.length, 1); + expect(matches[1].queryParams['def'], 'ghi'); + expect(matches[1].extra, extra); + expect(matches[1].fullUriString, '/abc?def=ghi'); + expect(matches[1].subloc, '/abc'); + expect(matches[1].route, routes[0].routes[0]); + }); + + test('GoRouteInformationParser returns error when unknown route', () async { + final List routes = [ + GoRoute( + path: '/', + builder: (_, __) => const Placeholder(), + routes: [ + GoRoute( + path: 'abc', + builder: (_, __) => const Placeholder(), + ), + ], + ), + ]; + final GoRouteInformationParser parser = GoRouteInformationParser( + routes: routes, + redirectLimit: 100, + topRedirect: (_) => null, + ); + + final List matches = await parser + .parseRouteInformation(const RouteInformation(location: '/def')); + expect(matches.length, 1); + expect(matches[0].queryParams.isEmpty, isTrue); + expect(matches[0].extra, isNull); + expect(matches[0].fullUriString, '/def'); + expect(matches[0].subloc, '/def'); + expect(matches[0].error!.toString(), + 'Exception: no routes for location: /def'); + }); + + test('GoRouteInformationParser can work with route parameters', () async { + final List routes = [ + GoRoute( + path: '/', + builder: (_, __) => const Placeholder(), + routes: [ + GoRoute( + path: ':uid/family/:fid', + builder: (_, __) => const Placeholder(), + ), + ], + ), + ]; + final GoRouteInformationParser parser = GoRouteInformationParser( + routes: routes, + redirectLimit: 100, + topRedirect: (_) => null, + ); + + final List matches = await parser.parseRouteInformation( + const RouteInformation(location: '/123/family/456')); + expect(matches.length, 2); + expect(matches[0].queryParams.isEmpty, isTrue); + expect(matches[0].extra, isNull); + expect(matches[0].fullUriString, '/'); + expect(matches[0].subloc, '/'); + + expect(matches[1].queryParams.isEmpty, isTrue); + expect(matches[1].extra, isNull); + expect(matches[1].fullUriString, '/123/family/456'); + expect(matches[1].subloc, '/123/family/456'); + expect(matches[1].encodedParams.length, 2); + expect(matches[1].encodedParams['uid'], '123'); + expect(matches[1].encodedParams['fid'], '456'); + }); + + test('GoRouteInformationParser can do top level redirect', () async { + final List routes = [ + GoRoute( + path: '/', + builder: (_, __) => const Placeholder(), + routes: [ + GoRoute( + path: ':uid/family/:fid', + builder: (_, __) => const Placeholder(), + ), + ], + ), + ]; + final GoRouteInformationParser parser = GoRouteInformationParser( + routes: routes, + redirectLimit: 100, + topRedirect: (GoRouterState state) { + if (state.location != '/123/family/345') { + return '/123/family/345'; + } + return null; + }, + ); + + final List matches = await parser + .parseRouteInformation(const RouteInformation(location: '/random/uri')); + expect(matches.length, 2); + expect(matches[0].fullUriString, '/'); + expect(matches[0].subloc, '/'); + + expect(matches[1].fullUriString, '/123/family/345'); + expect(matches[1].subloc, '/123/family/345'); + }); + + test('GoRouteInformationParser can do route level redirect', () async { + final List routes = [ + GoRoute( + path: '/', + builder: (_, __) => const Placeholder(), + routes: [ + GoRoute( + path: ':uid/family/:fid', + builder: (_, __) => const Placeholder(), + ), + GoRoute( + path: 'redirect', + redirect: (_) => '/123/family/345', + builder: (_, __) => throw UnimplementedError(), + ), + ], + ), + ]; + final GoRouteInformationParser parser = GoRouteInformationParser( + routes: routes, + redirectLimit: 100, + topRedirect: (_) => null, + ); + + final List matches = await parser + .parseRouteInformation(const RouteInformation(location: '/redirect')); + expect(matches.length, 2); + expect(matches[0].fullUriString, '/'); + expect(matches[0].subloc, '/'); + + expect(matches[1].fullUriString, '/123/family/345'); + expect(matches[1].subloc, '/123/family/345'); + }); +} diff --git a/packages/go_router/test/go_router_delegate_test.dart b/packages/go_router/test/go_router_delegate_test.dart index a7153327bf..9ad9745196 100644 --- a/packages/go_router/test/go_router_delegate_test.dart +++ b/packages/go_router/test/go_router_delegate_test.dart @@ -6,12 +6,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:go_router/src/go_route_match.dart'; -import 'package:go_router/src/go_router_delegate.dart'; import 'package:go_router/src/go_router_error_page.dart'; -GoRouterDelegate createGoRouterDelegate({ +Future createGoRouter( + WidgetTester tester, { Listenable? refreshListenable, -}) { +}) async { final GoRouter router = GoRouter( initialLocation: '/', routes: [ @@ -23,26 +23,32 @@ GoRouterDelegate createGoRouterDelegate({ ], refreshListenable: refreshListenable, ); - return router.routerDelegate; + await tester.pumpWidget(MaterialApp.router( + routeInformationProvider: router.routeInformationProvider, + routeInformationParser: router.routeInformationParser, + routerDelegate: router.routerDelegate)); + return router; } void main() { group('pop', () { - test('removes the last element', () { - final GoRouterDelegate delegate = createGoRouterDelegate() - ..push('/error') - ..addListener(expectAsync0(() {})); - final GoRouteMatch last = delegate.matches.last; - delegate.pop(); - expect(delegate.matches.length, 1); - expect(delegate.matches.contains(last), false); + testWidgets('removes the last element', (WidgetTester tester) async { + final GoRouter goRouter = await createGoRouter(tester) + ..push('/error'); + + goRouter.routerDelegate.addListener(expectAsync0(() {})); + final GoRouteMatch last = goRouter.routerDelegate.matches.last; + goRouter.routerDelegate.pop(); + expect(goRouter.routerDelegate.matches.length, 1); + expect(goRouter.routerDelegate.matches.contains(last), false); }); - test('throws when it pops more than matches count', () { - final GoRouterDelegate delegate = createGoRouterDelegate() + testWidgets('throws when it pops more than matches count', + (WidgetTester tester) async { + final GoRouter goRouter = await createGoRouter(tester) ..push('/error'); expect( - () => delegate + () => goRouter.routerDelegate ..pop() ..pop(), throwsA(isAssertionError), @@ -50,9 +56,13 @@ void main() { }); }); - test('dispose unsubscribes from refreshListenable', () { + testWidgets('dispose unsubscribes from refreshListenable', + (WidgetTester tester) async { final FakeRefreshListenable refreshListenable = FakeRefreshListenable(); - createGoRouterDelegate(refreshListenable: refreshListenable).dispose(); + final GoRouter goRouter = + await createGoRouter(tester, refreshListenable: refreshListenable); + await tester.pumpWidget(Container()); + goRouter.dispose(); expect(refreshListenable.unsubscribed, true); }); } diff --git a/packages/go_router/test/go_router_test.dart b/packages/go_router/test/go_router_test.dart index 8316597458..ca6461b963 100644 --- a/packages/go_router/test/go_router_test.dart +++ b/packages/go_router/test/go_router_test.dart @@ -24,7 +24,7 @@ void main() { Logger.root.onRecord.listen((LogRecord e) => debugPrint('$e')); group('path routes', () { - test('match home route', () { + testWidgets('match home route', (WidgetTester tester) async { final List routes = [ GoRoute( path: '/', @@ -32,20 +32,21 @@ void main() { const HomeScreen()), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); final List matches = router.routerDelegate.matches; expect(matches, hasLength(1)); expect(matches.first.fullpath, '/'); expect(router.screenFor(matches.first).runtimeType, HomeScreen); }); - test('If there is more than one route to match, use the first match', () { + testWidgets('If there is more than one route to match, use the first match', + (WidgetTester tester) async { final List routes = [ GoRoute(path: '/', builder: _dummy), GoRoute(path: '/', builder: _dummy), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); router.go('/'); final List matches = router.routerDelegate.matches; expect(matches, hasLength(1)); @@ -89,28 +90,30 @@ void main() { }, throwsA(isAssertionError)); }); - test('lack of leading / on top-level route', () { - expect(() { + testWidgets('lack of leading / on top-level route', + (WidgetTester tester) async { + await expectLater(() async { final List routes = [ GoRoute(path: 'foo', builder: _dummy), ]; - _router(routes); + await _router(routes, tester); }, throwsA(isAssertionError)); }); - test('match no routes', () { + testWidgets('match no routes', (WidgetTester tester) async { final List routes = [ GoRoute(path: '/', builder: _dummy), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); router.go('/foo'); + await tester.pumpAndSettle(); final List matches = router.routerDelegate.matches; expect(matches, hasLength(1)); expect(router.screenFor(matches.first).runtimeType, ErrorScreen); }); - test('match 2nd top level route', () { + testWidgets('match 2nd top level route', (WidgetTester tester) async { final List routes = [ GoRoute( path: '/', @@ -122,7 +125,7 @@ void main() { const LoginScreen()), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); router.go('/login'); final List matches = router.routerDelegate.matches; expect(matches, hasLength(1)); @@ -130,7 +133,8 @@ void main() { expect(router.screenFor(matches.first).runtimeType, LoginScreen); }); - test('match top level route when location has trailing /', () { + testWidgets('match top level route when location has trailing /', + (WidgetTester tester) async { final List routes = [ GoRoute( path: '/', @@ -144,7 +148,7 @@ void main() { ), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); router.go('/login/'); final List matches = router.routerDelegate.matches; expect(matches, hasLength(1)); @@ -152,13 +156,14 @@ void main() { expect(router.screenFor(matches.first).runtimeType, LoginScreen); }); - test('match top level route when location has trailing / (2)', () { + testWidgets('match top level route when location has trailing / (2)', + (WidgetTester tester) async { final List routes = [ GoRoute(path: '/profile', redirect: (_) => '/profile/foo'), GoRoute(path: '/profile/:kind', builder: _dummy), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); router.go('/profile/'); final List matches = router.routerDelegate.matches; expect(matches, hasLength(1)); @@ -166,13 +171,14 @@ void main() { expect(router.screenFor(matches.first).runtimeType, DummyScreen); }); - test('match top level route when location has trailing / (3)', () { + testWidgets('match top level route when location has trailing / (3)', + (WidgetTester tester) async { final List routes = [ GoRoute(path: '/profile', redirect: (_) => '/profile/foo'), GoRoute(path: '/profile/:kind', builder: _dummy), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); router.go('/profile/?bar=baz'); final List matches = router.routerDelegate.matches; expect(matches, hasLength(1)); @@ -180,7 +186,7 @@ void main() { expect(router.screenFor(matches.first).runtimeType, DummyScreen); }); - test('match sub-route', () { + testWidgets('match sub-route', (WidgetTester tester) async { final List routes = [ GoRoute( path: '/', @@ -196,7 +202,7 @@ void main() { ), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); router.go('/login'); final List matches = router.routerDelegate.matches; expect(matches.length, 2); @@ -206,7 +212,7 @@ void main() { expect(router.screenFor(matches[1]).runtimeType, LoginScreen); }); - test('match sub-routes', () { + testWidgets('match sub-routes', (WidgetTester tester) async { final List routes = [ GoRoute( path: '/', @@ -234,7 +240,7 @@ void main() { ), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); { final List matches = router.routerDelegate.matches; expect(matches, hasLength(1)); @@ -275,7 +281,8 @@ void main() { } }); - test('return first matching route if too many subroutes', () { + testWidgets('return first matching route if too many subroutes', + (WidgetTester tester) async { final List routes = [ GoRoute( path: '/', @@ -308,7 +315,7 @@ void main() { ), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); router.go('/bar'); List matches = router.routerDelegate.matches; expect(matches, hasLength(2)); @@ -325,28 +332,12 @@ void main() { expect(router.screenFor(matches[1]).runtimeType, Page2Screen); }); - test('router state', () { + testWidgets('router state', (WidgetTester tester) async { final List routes = [ GoRoute( name: 'home', path: '/', builder: (BuildContext context, GoRouterState state) { - expect( - state.location, - anyOf([ - '/', - '/login', - '/family/f2', - '/family/f2/person/p1' - ]), - ); - expect(state.subloc, '/'); - expect(state.name, 'home'); - expect(state.path, '/'); - expect(state.fullpath, '/'); - expect(state.params, {}); - expect(state.error, null); - expect(state.extra! as int, 1); return const HomeScreen(); }, routes: [ @@ -408,14 +399,18 @@ void main() { ), ]; - final GoRouter router = _router(routes); - router.go('/', extra: 1); - router.go('/login', extra: 2); - router.go('/family/f2', extra: 3); - router.go('/family/f2/person/p1', extra: 4); + final GoRouter router = await _router(routes, tester); + + await tester.pump(); + router.push('/login', extra: 2); + await tester.pump(); + router.push('/family/f2', extra: 3); + await tester.pump(); + router.push('/family/f2/person/p1', extra: 4); + await tester.pump(); }); - test('match path case insensitively', () { + testWidgets('match path case insensitively', (WidgetTester tester) async { final List routes = [ GoRoute( path: '/', @@ -429,7 +424,7 @@ void main() { ), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); const String loc = '/FaMiLy/f2'; router.go(loc); final List matches = router.routerDelegate.matches; @@ -443,7 +438,9 @@ void main() { expect(router.screenFor(matches.first).runtimeType, FamilyScreen); }); - test('If there is more than one route to match, use the first match.', () { + testWidgets( + 'If there is more than one route to match, use the first match.', + (WidgetTester tester) async { final List routes = [ GoRoute(path: '/', builder: _dummy), GoRoute(path: '/page1', builder: _dummy), @@ -451,7 +448,7 @@ void main() { GoRoute(path: '/:ok', builder: _dummy), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); router.go('/user'); final List matches = router.routerDelegate.matches; expect(matches, hasLength(1)); @@ -460,7 +457,7 @@ void main() { }); group('named routes', () { - test('match home route', () { + testWidgets('match home route', (WidgetTester tester) async { final List routes = [ GoRoute( name: 'home', @@ -469,18 +466,18 @@ void main() { const HomeScreen()), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); router.goNamed('home'); }); - test('match too many routes', () { + testWidgets('match too many routes', (WidgetTester tester) async { final List routes = [ GoRoute(name: 'home', path: '/', builder: _dummy), GoRoute(name: 'home', path: '/', builder: _dummy), ]; - expect(() { - _router(routes); + await expectLater(() async { + await _router(routes, tester); }, throwsA(isAssertionError)); }); @@ -490,17 +487,17 @@ void main() { }, throwsA(isAssertionError)); }); - test('match no routes', () { - expect(() { + testWidgets('match no routes', (WidgetTester tester) async { + await expectLater(() async { final List routes = [ GoRoute(name: 'home', path: '/', builder: _dummy), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); router.goNamed('work'); }, throwsA(isAssertionError)); }); - test('match 2nd top level route', () { + testWidgets('match 2nd top level route', (WidgetTester tester) async { final List routes = [ GoRoute( name: 'home', @@ -516,11 +513,11 @@ void main() { ), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); router.goNamed('login'); }); - test('match sub-route', () { + testWidgets('match sub-route', (WidgetTester tester) async { final List routes = [ GoRoute( name: 'home', @@ -538,40 +535,11 @@ void main() { ), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); router.goNamed('login'); }); - test('match sub-route case insensitive', () { - final List routes = [ - GoRoute( - name: 'home', - path: '/', - builder: (BuildContext context, GoRouterState state) => - const HomeScreen(), - routes: [ - GoRoute( - name: 'page1', - path: 'page1', - builder: (BuildContext context, GoRouterState state) => - const Page1Screen(), - ), - GoRoute( - name: 'page2', - path: 'Page2', - builder: (BuildContext context, GoRouterState state) => - const Page2Screen(), - ), - ], - ), - ]; - - final GoRouter router = _router(routes); - router.goNamed('Page1'); - router.goNamed('page2'); - }); - - test('match w/ params', () { + testWidgets('match w/ params', (WidgetTester tester) async { final List routes = [ GoRoute( name: 'home', @@ -600,12 +568,12 @@ void main() { ), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); router.goNamed('person', params: {'fid': 'f2', 'pid': 'p1'}); }); - test('too few params', () { + testWidgets('too few params', (WidgetTester tester) async { final List routes = [ GoRoute( name: 'home', @@ -630,13 +598,15 @@ void main() { ], ), ]; - expect(() { - final GoRouter router = _router(routes); + await expectLater(() async { + final GoRouter router = await _router(routes, tester); router.goNamed('person', params: {'fid': 'f2'}); + await tester.pump(); }, throwsA(isAssertionError)); }); - test('match case insensitive w/ params', () { + testWidgets('match case insensitive w/ params', + (WidgetTester tester) async { final List routes = [ GoRoute( name: 'home', @@ -665,12 +635,12 @@ void main() { ), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); router.goNamed('person', params: {'fid': 'f2', 'pid': 'p1'}); }); - test('too few params', () { + testWidgets('too few params', (WidgetTester tester) async { final List routes = [ GoRoute( name: 'family', @@ -679,13 +649,13 @@ void main() { const FamilyScreen('dummy'), ), ]; - expect(() { - final GoRouter router = _router(routes); + await expectLater(() async { + final GoRouter router = await _router(routes, tester); router.goNamed('family'); }, throwsA(isAssertionError)); }); - test('too many params', () { + testWidgets('too many params', (WidgetTester tester) async { final List routes = [ GoRoute( name: 'family', @@ -694,14 +664,14 @@ void main() { const FamilyScreen('dummy'), ), ]; - expect(() { - final GoRouter router = _router(routes); + await expectLater(() async { + final GoRouter router = await _router(routes, tester); router.goNamed('family', params: {'fid': 'f2', 'pid': 'p1'}); }, throwsA(isAssertionError)); }); - test('sparsely named routes', () { + testWidgets('sparsely named routes', (WidgetTester tester) async { final List routes = [ GoRoute( path: '/', @@ -726,7 +696,7 @@ void main() { ), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); router.goNamed('person', params: {'fid': 'f2', 'pid': 'p1'}); @@ -734,7 +704,8 @@ void main() { expect(router.screenFor(matches.last).runtimeType, PersonScreen); }); - test('preserve path param spaces and slashes', () { + testWidgets('preserve path param spaces and slashes', + (WidgetTester tester) async { const String param1 = 'param w/ spaces and slashes'; final List routes = [ GoRoute( @@ -747,7 +718,7 @@ void main() { ), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); final String loc = router .namedLocation('page1', params: {'param1': param1}); log.info('loc= $loc'); @@ -759,7 +730,8 @@ void main() { expect(matches.first.decodedParams['param1'], param1); }); - test('preserve query param spaces and slashes', () { + testWidgets('preserve query param spaces and slashes', + (WidgetTester tester) async { const String param1 = 'param w/ spaces and slashes'; final List routes = [ GoRoute( @@ -772,21 +744,19 @@ void main() { ), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); final String loc = router.namedLocation('page1', queryParams: {'param1': param1}); - log.info('loc= $loc'); router.go(loc); - + await tester.pump(); final List matches = router.routerDelegate.matches; - log.info('param1= ${matches.first.queryParams['param1']}'); expect(router.screenFor(matches.first).runtimeType, DummyScreen); expect(matches.first.queryParams['param1'], param1); }); }); group('redirects', () { - test('top-level redirect', () { + testWidgets('top-level redirect', (WidgetTester tester) async { final List routes = [ GoRoute( path: '/', @@ -805,16 +775,15 @@ void main() { ), ]; - final GoRouter router = GoRouter( - routes: routes, - errorBuilder: _dummy, - redirect: (GoRouterState state) => - state.subloc == '/login' ? null : '/login', - ); + final GoRouter router = await _router(routes, tester, + redirect: (GoRouterState state) => + state.subloc == '/login' ? null : '/login'); + expect(router.location, '/login'); }); - test('top-level redirect w/ named routes', () { + testWidgets('top-level redirect w/ named routes', + (WidgetTester tester) async { final List routes = [ GoRoute( name: 'home', @@ -838,17 +807,16 @@ void main() { ), ]; - final GoRouter router = GoRouter( - debugLogDiagnostics: true, - routes: routes, - errorBuilder: _dummy, + final GoRouter router = await _router( + routes, + tester, redirect: (GoRouterState state) => state.subloc == '/login' ? null : state.namedLocation('login'), ); expect(router.location, '/login'); }); - test('route-level redirect', () { + testWidgets('route-level redirect', (WidgetTester tester) async { final List routes = [ GoRoute( path: '/', @@ -870,15 +838,14 @@ void main() { ), ]; - final GoRouter router = GoRouter( - routes: routes, - errorBuilder: _dummy, - ); + final GoRouter router = await _router(routes, tester); router.go('/dummy'); + await tester.pump(); expect(router.location, '/login'); }); - test('route-level redirect w/ named routes', () { + testWidgets('route-level redirect w/ named routes', + (WidgetTester tester) async { final List routes = [ GoRoute( name: 'home', @@ -903,15 +870,13 @@ void main() { ), ]; - final GoRouter router = GoRouter( - routes: routes, - errorBuilder: _dummy, - ); + final GoRouter router = await _router(routes, tester); router.go('/dummy'); + await tester.pump(); expect(router.location, '/login'); }); - test('multiple mixed redirect', () { + testWidgets('multiple mixed redirect', (WidgetTester tester) async { final List routes = [ GoRoute( path: '/', @@ -933,27 +898,21 @@ void main() { ), ]; - final GoRouter router = GoRouter( - routes: routes, - errorBuilder: _dummy, - redirect: (GoRouterState state) => - state.subloc == '/dummy1' ? '/dummy2' : null, - ); + final GoRouter router = await _router(routes, tester, + redirect: (GoRouterState state) => + state.subloc == '/dummy1' ? '/dummy2' : null); router.go('/dummy1'); + await tester.pump(); expect(router.location, '/'); }); - test('top-level redirect loop', () { - final GoRouter router = GoRouter( - routes: [], - errorBuilder: (BuildContext context, GoRouterState state) => - ErrorScreen(state.error!), - redirect: (GoRouterState state) => state.subloc == '/' - ? '/login' - : state.subloc == '/login' - ? '/' - : null, - ); + testWidgets('top-level redirect loop', (WidgetTester tester) async { + final GoRouter router = await _router([], tester, + redirect: (GoRouterState state) => state.subloc == '/' + ? '/login' + : state.subloc == '/login' + ? '/' + : null); final List matches = router.routerDelegate.matches; expect(matches, hasLength(1)); @@ -962,9 +921,9 @@ void main() { log.info((router.screenFor(matches.first) as ErrorScreen).ex); }); - test('route-level redirect loop', () { - final GoRouter router = GoRouter( - routes: [ + testWidgets('route-level redirect loop', (WidgetTester tester) async { + final GoRouter router = await _router( + [ GoRoute( path: '/', redirect: (GoRouterState state) => '/login', @@ -974,8 +933,7 @@ void main() { redirect: (GoRouterState state) => '/', ), ], - errorBuilder: (BuildContext context, GoRouterState state) => - ErrorScreen(state.error!), + tester, ); final List matches = router.routerDelegate.matches; @@ -985,16 +943,15 @@ void main() { log.info((router.screenFor(matches.first) as ErrorScreen).ex); }); - test('mixed redirect loop', () { - final GoRouter router = GoRouter( - routes: [ + testWidgets('mixed redirect loop', (WidgetTester tester) async { + final GoRouter router = await _router( + [ GoRoute( path: '/login', redirect: (GoRouterState state) => '/', ), ], - errorBuilder: (BuildContext context, GoRouterState state) => - ErrorScreen(state.error!), + tester, redirect: (GoRouterState state) => state.subloc == '/' ? '/login' : null, ); @@ -1006,11 +963,11 @@ void main() { log.info((router.screenFor(matches.first) as ErrorScreen).ex); }); - test('top-level redirect loop w/ query params', () { - final GoRouter router = GoRouter( - routes: [], - errorBuilder: (BuildContext context, GoRouterState state) => - ErrorScreen(state.error!), + testWidgets('top-level redirect loop w/ query params', + (WidgetTester tester) async { + final GoRouter router = await _router( + [], + tester, redirect: (GoRouterState state) => state.subloc == '/' ? '/login?from=${state.location}' : state.subloc == '/login' @@ -1025,7 +982,8 @@ void main() { log.info((router.screenFor(matches.first) as ErrorScreen).ex); }); - test('expect null path/fullpath on top-level redirect', () { + testWidgets('expect null path/fullpath on top-level redirect', + (WidgetTester tester) async { final List routes = [ GoRoute( path: '/', @@ -1038,15 +996,15 @@ void main() { ), ]; - final GoRouter router = GoRouter( - routes: routes, - errorBuilder: _dummy, + final GoRouter router = await _router( + routes, + tester, initialLocation: '/dummy', ); expect(router.location, '/'); }); - test('top-level redirect state', () { + testWidgets('top-level redirect state', (WidgetTester tester) async { final List routes = [ GoRoute( path: '/', @@ -1060,11 +1018,10 @@ void main() { ), ]; - final GoRouter router = GoRouter( - routes: routes, - errorBuilder: _dummy, + final GoRouter router = await _router( + routes, + tester, initialLocation: '/login?from=/', - debugLogDiagnostics: true, redirect: (GoRouterState state) { expect(Uri.parse(state.location).queryParameters, isNotEmpty); expect(Uri.parse(state.subloc).queryParameters, isEmpty); @@ -1082,7 +1039,7 @@ void main() { expect(router.screenFor(matches.first).runtimeType, LoginScreen); }); - test('route-level redirect state', () { + testWidgets('route-level redirect state', (WidgetTester tester) async { const String loc = '/book/0'; final List routes = [ GoRoute( @@ -1100,11 +1057,10 @@ void main() { ), ]; - final GoRouter router = GoRouter( - routes: routes, - errorBuilder: _dummy, + final GoRouter router = await _router( + routes, + tester, initialLocation: loc, - debugLogDiagnostics: true, ); final List matches = router.routerDelegate.matches; @@ -1112,7 +1068,8 @@ void main() { expect(router.screenFor(matches.first).runtimeType, HomeScreen); }); - test('sub-sub-route-level redirect params', () { + testWidgets('sub-sub-route-level redirect params', + (WidgetTester tester) async { final List routes = [ GoRoute( path: '/', @@ -1141,11 +1098,10 @@ void main() { ), ]; - final GoRouter router = GoRouter( - routes: routes, - errorBuilder: _dummy, + final GoRouter router = await _router( + routes, + tester, initialLocation: '/family/f2/person/p1', - debugLogDiagnostics: true, ); final List matches = router.routerDelegate.matches; @@ -1157,12 +1113,10 @@ void main() { expect(page.pid, 'p1'); }); - test('redirect limit', () { - final GoRouter router = GoRouter( - routes: [], - errorBuilder: (BuildContext context, GoRouterState state) => - ErrorScreen(state.error!), - debugLogDiagnostics: true, + testWidgets('redirect limit', (WidgetTester tester) async { + final GoRouter router = await _router( + [], + tester, redirect: (GoRouterState state) => '${state.location}+', redirectLimit: 10, ); @@ -1176,7 +1130,7 @@ void main() { }); group('initial location', () { - test('initial location', () { + testWidgets('initial location', (WidgetTester tester) async { final List routes = [ GoRoute( path: '/', @@ -1192,15 +1146,15 @@ void main() { ), ]; - final GoRouter router = GoRouter( - routes: routes, - errorBuilder: _dummy, + final GoRouter router = await _router( + routes, + tester, initialLocation: '/dummy', ); expect(router.location, '/dummy'); }); - test('initial location w/ redirection', () { + testWidgets('initial location w/ redirection', (WidgetTester tester) async { final List routes = [ GoRoute( path: '/', @@ -1213,9 +1167,9 @@ void main() { ), ]; - final GoRouter router = GoRouter( - routes: routes, - errorBuilder: _dummy, + final GoRouter router = await _router( + routes, + tester, initialLocation: '/dummy', ); expect(router.location, '/'); @@ -1223,7 +1177,7 @@ void main() { }); group('params', () { - test('preserve path param case', () { + testWidgets('preserve path param case', (WidgetTester tester) async { final List routes = [ GoRoute( path: '/', @@ -1237,7 +1191,7 @@ void main() { ), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); for (final String fid in ['f2', 'F2']) { final String loc = '/family/$fid'; router.go(loc); @@ -1250,7 +1204,7 @@ void main() { } }); - test('preserve query param case', () { + testWidgets('preserve query param case', (WidgetTester tester) async { final List routes = [ GoRoute( path: '/', @@ -1265,7 +1219,7 @@ void main() { ), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); for (final String fid in ['f2', 'F2']) { final String loc = '/family?fid=$fid'; router.go(loc); @@ -1278,7 +1232,8 @@ void main() { } }); - test('preserve path param spaces and slashes', () { + testWidgets('preserve path param spaces and slashes', + (WidgetTester tester) async { const String param1 = 'param w/ spaces and slashes'; final List routes = [ GoRoute( @@ -1290,7 +1245,7 @@ void main() { ), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); final String loc = '/page1/${Uri.encodeComponent(param1)}'; router.go(loc); @@ -1300,7 +1255,8 @@ void main() { expect(matches.first.decodedParams['param1'], param1); }); - test('preserve query param spaces and slashes', () { + testWidgets('preserve query param spaces and slashes', + (WidgetTester tester) async { const String param1 = 'param w/ spaces and slashes'; final List routes = [ GoRoute( @@ -1312,7 +1268,7 @@ void main() { ), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); router.go('/page1?param1=$param1'); final List matches = router.routerDelegate.matches; @@ -1346,9 +1302,9 @@ void main() { } }); - test('duplicate query param', () { - final GoRouter router = GoRouter( - routes: [ + testWidgets('duplicate query param', (WidgetTester tester) async { + final GoRouter router = await _router( + [ GoRoute( path: '/', builder: (BuildContext context, GoRouterState state) { @@ -1360,20 +1316,18 @@ void main() { }, ), ], - errorBuilder: (BuildContext context, GoRouterState state) => - ErrorScreen(state.error!), + tester, + initialLocation: '/?id=0&id=1', ); - - router.go('/?id=0&id=1'); final List matches = router.routerDelegate.matches; expect(matches, hasLength(1)); expect(matches.first.fullpath, '/'); expect(router.screenFor(matches.first).runtimeType, HomeScreen); }); - test('duplicate path + query param', () { - final GoRouter router = GoRouter( - routes: [ + testWidgets('duplicate path + query param', (WidgetTester tester) async { + final GoRouter router = await _router( + [ GoRoute( path: '/:id', builder: (BuildContext context, GoRouterState state) { @@ -1383,19 +1337,20 @@ void main() { }, ), ], - errorBuilder: _dummy, + tester, ); router.go('/0?id=1'); + await tester.pump(); final List matches = router.routerDelegate.matches; expect(matches, hasLength(1)); expect(matches.first.fullpath, '/:id'); expect(router.screenFor(matches.first).runtimeType, HomeScreen); }); - test('push + query param', () { - final GoRouter router = GoRouter( - routes: [ + testWidgets('push + query param', (WidgetTester tester) async { + final GoRouter router = await _router( + [ GoRoute(path: '/', builder: _dummy), GoRoute( path: '/family', @@ -1413,11 +1368,13 @@ void main() { ), ), ], - errorBuilder: _dummy, + tester, ); router.go('/family?fid=f2'); + await tester.pump(); router.push('/person?fid=f2&pid=p1'); + await tester.pump(); final FamilyScreen page1 = router.screenFor(router.routerDelegate.matches.first) as FamilyScreen; expect(page1.fid, 'f2'); @@ -1428,9 +1385,9 @@ void main() { expect(page2.pid, 'p1'); }); - test('push + extra param', () { - final GoRouter router = GoRouter( - routes: [ + testWidgets('push + extra param', (WidgetTester tester) async { + final GoRouter router = await _router( + [ GoRoute(path: '/', builder: _dummy), GoRoute( path: '/family', @@ -1448,11 +1405,13 @@ void main() { ), ), ], - errorBuilder: _dummy, + tester, ); router.go('/family', extra: {'fid': 'f2'}); + await tester.pump(); router.push('/person', extra: {'fid': 'f2', 'pid': 'p1'}); + await tester.pump(); final FamilyScreen page1 = router.screenFor(router.routerDelegate.matches.first) as FamilyScreen; expect(page1.fid, 'f2'); @@ -1463,7 +1422,7 @@ void main() { expect(page2.pid, 'p1'); }); - test('keep param in nested route', () { + testWidgets('keep param in nested route', (WidgetTester tester) async { final List routes = [ GoRoute( path: '/', @@ -1488,12 +1447,13 @@ void main() { ), ]; - final GoRouter router = _router(routes); + final GoRouter router = await _router(routes, tester); const String fid = 'f1'; const String pid = 'p2'; const String loc = '/family/$fid/person/$pid'; router.push(loc); + await tester.pump(); final List matches = router.routerDelegate.matches; expect(router.location, loc); @@ -1586,6 +1546,7 @@ void main() { GoRouterNamedLocationSpy(routes: routes); await tester.pumpWidget( MaterialApp.router( + routeInformationProvider: router.routeInformationProvider, routeInformationParser: router.routeInformationParser, routerDelegate: router.routerDelegate, title: 'GoRouter Example', @@ -1605,6 +1566,7 @@ void main() { final GoRouterGoSpy router = GoRouterGoSpy(routes: routes); await tester.pumpWidget( MaterialApp.router( + routeInformationProvider: router.routeInformationProvider, routeInformationParser: router.routeInformationParser, routerDelegate: router.routerDelegate, title: 'GoRouter Example', @@ -1623,6 +1585,7 @@ void main() { final GoRouterGoNamedSpy router = GoRouterGoNamedSpy(routes: routes); await tester.pumpWidget( MaterialApp.router( + routeInformationProvider: router.routeInformationProvider, routeInformationParser: router.routeInformationParser, routerDelegate: router.routerDelegate, title: 'GoRouter Example', @@ -1645,6 +1608,7 @@ void main() { final GoRouterPushSpy router = GoRouterPushSpy(routes: routes); await tester.pumpWidget( MaterialApp.router( + routeInformationProvider: router.routeInformationProvider, routeInformationParser: router.routeInformationParser, routerDelegate: router.routerDelegate, title: 'GoRouter Example', @@ -1663,6 +1627,7 @@ void main() { final GoRouterPushNamedSpy router = GoRouterPushNamedSpy(routes: routes); await tester.pumpWidget( MaterialApp.router( + routeInformationProvider: router.routeInformationProvider, routeInformationParser: router.routeInformationParser, routerDelegate: router.routerDelegate, title: 'GoRouter Example', @@ -1681,9 +1646,11 @@ void main() { }); testWidgets('calls [pop] on closest GoRouter', (WidgetTester tester) async { + print('run 2.2'); final GoRouterPopSpy router = GoRouterPopSpy(routes: routes); await tester.pumpWidget( MaterialApp.router( + routeInformationProvider: router.routeInformationProvider, routeInformationParser: router.routeInformationParser, routerDelegate: router.routerDelegate, title: 'GoRouter Example', @@ -1694,20 +1661,17 @@ void main() { }); }); - test('pop triggers pop on routerDelegate', () { - final GoRouter router = createGoRouter()..push('/error'); + testWidgets('pop triggers pop on routerDelegate', + (WidgetTester tester) async { + final GoRouter router = await createGoRouter(tester) + ..push('/error'); router.routerDelegate.addListener(expectAsync0(() {})); router.pop(); + await tester.pump(); }); - test('refresh triggers refresh on routerDelegate', () { - final GoRouter router = createGoRouter(); - router.routerDelegate.addListener(expectAsync0(() {})); - router.refresh(); - }); - - test('didPush notifies listeners', () { - createGoRouter() + testWidgets('didPush notifies listeners', (WidgetTester tester) async { + await createGoRouter(tester) ..addListener(expectAsync0(() {})) ..didPush( MaterialPageRoute(builder: (_) => const Text('Current route')), @@ -1715,8 +1679,8 @@ void main() { ); }); - test('didPop notifies listeners', () { - createGoRouter() + testWidgets('didPop notifies listeners', (WidgetTester tester) async { + await createGoRouter(tester) ..addListener(expectAsync0(() {})) ..didPop( MaterialPageRoute(builder: (_) => const Text('Current route')), @@ -1724,8 +1688,8 @@ void main() { ); }); - test('didRemove notifies listeners', () { - createGoRouter() + testWidgets('didRemove notifies listeners', (WidgetTester tester) async { + await createGoRouter(tester) ..addListener(expectAsync0(() {})) ..didRemove( MaterialPageRoute(builder: (_) => const Text('Current route')), @@ -1733,8 +1697,8 @@ void main() { ); }); - test('didReplace notifies listeners', () { - createGoRouter() + testWidgets('didReplace notifies listeners', (WidgetTester tester) async { + await createGoRouter(tester) ..addListener(expectAsync0(() {})) ..didReplace( newRoute: MaterialPageRoute( @@ -1746,23 +1710,11 @@ void main() { ); }); - test('uses navigatorBuilder when provided', () { - final Func3 navigationBuilder = + testWidgets('uses navigatorBuilder when provided', + (WidgetTester tester) async { + final Func3 navigatorBuilder = expectAsync3(fakeNavigationBuilder); - final GoRouter router = createGoRouter(navigatorBuilder: navigationBuilder); - final GoRouterDelegate delegate = router.routerDelegate; - delegate.builderWithNav( - DummyBuildContext(), - GoRouterState(delegate, location: '/foo', subloc: '/bar', name: 'baz'), - const Navigator(), - ); - }); -} - -GoRouter createGoRouter({ - GoRouterNavigatorBuilder? navigatorBuilder, -}) => - GoRouter( + final GoRouter router = GoRouter( initialLocation: '/', routes: [ GoRoute(path: '/', builder: (_, __) => const DummyStatefulWidget()), @@ -1774,6 +1726,38 @@ GoRouter createGoRouter({ navigatorBuilder: navigatorBuilder, ); + final GoRouterDelegate delegate = router.routerDelegate; + delegate.builderWithNav( + DummyBuildContext(), + GoRouterState(router.routeInformationParser, + location: '/foo', subloc: '/bar', name: 'baz'), + const Navigator(), + ); + }); +} + +Future createGoRouter( + WidgetTester tester, { + GoRouterNavigatorBuilder? navigatorBuilder, +}) async { + final GoRouter goRouter = GoRouter( + initialLocation: '/', + routes: [ + GoRoute(path: '/', builder: (_, __) => const DummyStatefulWidget()), + GoRoute( + path: '/error', + builder: (_, __) => const GoRouterErrorScreen(null), + ), + ], + navigatorBuilder: navigatorBuilder, + ); + await tester.pumpWidget(MaterialApp.router( + routeInformationProvider: goRouter.routeInformationProvider, + routeInformationParser: goRouter.routeInformationParser, + routerDelegate: goRouter.routerDelegate)); + return goRouter; +} + Widget fakeNavigationBuilder( BuildContext context, GoRouterState state, @@ -1898,12 +1882,31 @@ class GoRouterRefreshStreamSpy extends GoRouterRefreshStream { } } -GoRouter _router(List routes) => GoRouter( - routes: routes, - errorBuilder: (BuildContext context, GoRouterState state) => - ErrorScreen(state.error!), - debugLogDiagnostics: true, - ); +Future _router( + List routes, + WidgetTester tester, { + GoRouterRedirect? redirect, + String initialLocation = '/', + int redirectLimit = 5, +}) async { + final GoRouter goRouter = GoRouter( + routes: routes, + redirect: redirect, + initialLocation: initialLocation, + redirectLimit: redirectLimit, + errorBuilder: (BuildContext context, GoRouterState state) => + ErrorScreen(state.error!), + debugLogDiagnostics: true, + ); + await tester.pumpWidget( + MaterialApp.router( + routeInformationProvider: goRouter.routeInformationProvider, + routeInformationParser: goRouter.routeInformationParser, + routerDelegate: goRouter.routerDelegate, + ), + ); + return goRouter; +} class ErrorScreen extends DummyScreen { const ErrorScreen(this.ex, {Key? key}) : super(key: key); @@ -1946,7 +1949,7 @@ class DummyScreen extends StatelessWidget { const DummyScreen({Key? key}) : super(key: key); @override - Widget build(BuildContext context) => throw UnimplementedError(); + Widget build(BuildContext context) => const Placeholder(); } Widget _dummy(BuildContext context, GoRouterState state) => const DummyScreen(); @@ -1961,7 +1964,7 @@ extension on GoRouter { } Widget screenFor(GoRouteMatch match) => - (_pageFor(match) as NoTransitionPage).child; + (_pageFor(match) as MaterialPage).child; } class DummyBuildContext implements BuildContext {