From 010ba5012831a4e02349e31965a0b080d7b44280 Mon Sep 17 00:00:00 2001 From: chunhtai <47866232+chunhtai@users.noreply.github.com> Date: Wed, 7 Jun 2023 16:19:52 -0700 Subject: [PATCH] [go_router] Refactors imperative APIs and browser history (#4134) Several thing. 1. I move all the imperative logic from RouterDelegate to RouteInformationParser, so that the imperative API can go through Router parsing pipeline. The Parser will handle modifying mutating RouteMatchList and produce the final RouteMatchList. The RouterDelegate would only focus on building the widget base on the final RouteMatchList 2. combine RouteMatcher and Redirector with RouteConfiguration. I feel that instead of passing three class instances around, we should probably just have one class for all the route parsing related utility. 3. serialize routeMatchList and store into browser history. This way we can let backward and forward button to reflect imperative operation as well. 4. Some minor clean ups --- packages/go_router/CHANGELOG.md | 9 +- packages/go_router/README.md | 3 +- packages/go_router/lib/src/builder.dart | 163 +++--- packages/go_router/lib/src/configuration.dart | 382 ++++++++++++-- packages/go_router/lib/src/delegate.dart | 107 +--- .../lib/src/information_provider.dart | 207 ++++++-- packages/go_router/lib/src/match.dart | 364 +++++++++++-- packages/go_router/lib/src/matching.dart | 479 ------------------ packages/go_router/lib/src/misc/errors.dart | 23 + packages/go_router/lib/src/parser.dart | 158 +++--- packages/go_router/lib/src/redirection.dart | 237 --------- packages/go_router/lib/src/route.dart | 35 +- packages/go_router/lib/src/router.dart | 283 +++++++---- packages/go_router/lib/src/state.dart | 3 - packages/go_router/pubspec.yaml | 2 +- packages/go_router/test/builder_test.dart | 11 - .../go_router/test/configuration_test.dart | 11 +- packages/go_router/test/delegate_test.dart | 195 ++++--- packages/go_router/test/go_router_test.dart | 314 +++++++++--- .../test/information_provider_test.dart | 24 +- packages/go_router/test/match_test.dart | 26 - packages/go_router/test/matching_test.dart | 24 +- packages/go_router/test/parser_test.dart | 36 +- packages/go_router/test/test_helpers.dart | 2 - 24 files changed, 1622 insertions(+), 1476 deletions(-) delete mode 100644 packages/go_router/lib/src/matching.dart delete mode 100644 packages/go_router/lib/src/redirection.dart diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index 4a0d29b6c6..0bba2a21f2 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,6 +1,13 @@ +## 8.0.0 + +- **BREAKING CHANGE**: + - Imperatively pushed GoRoute no longer change URL. + - Browser backward and forward button respects imperative route operations. +- Refactors the route parsing pipeline. + ## 7.1.1 -* Removes obsolete null checks on non-nullable values. +- Removes obsolete null checks on non-nullable values. ## 7.1.0 diff --git a/packages/go_router/README.md b/packages/go_router/README.md index eae5297abd..9beccbd72a 100644 --- a/packages/go_router/README.md +++ b/packages/go_router/README.md @@ -37,7 +37,8 @@ See the API documentation for details on the following topics: - [Error handling](https://pub.dev/documentation/go_router/latest/topics/Error%20handling-topic.html) ## Migration guides -- [Migrating to 7.0.0](https://docs.google.com/document/d/10Xbpifbs4E-zh6YE5akIO8raJq_m3FIXs6nUGdOspOg). +- [Migrating to 8.0.0](https://flutter.dev/go/go-router-v8-breaking-changes). +- [Migrating to 7.0.0](https://flutter.dev/go/go-router-v7-breaking-changes). - [Migrating to 6.0.0](https://flutter.dev/go/go-router-v6-breaking-changes) - [Migrating to 5.1.2](https://flutter.dev/go/go-router-v5-1-2-breaking-changes) - [Migrating to 5.0](https://flutter.dev/go/go-router-v5-breaking-changes) diff --git a/packages/go_router/lib/src/builder.dart b/packages/go_router/lib/src/builder.dart index f151251707..d68958cbaa 100644 --- a/packages/go_router/lib/src/builder.dart +++ b/packages/go_router/lib/src/builder.dart @@ -9,8 +9,8 @@ import '../go_router.dart'; import 'configuration.dart'; import 'logging.dart'; import 'match.dart'; -import 'matching.dart'; import 'misc/error_screen.dart'; +import 'misc/errors.dart'; import 'pages/cupertino.dart'; import 'pages/material.dart'; import 'route_data.dart'; @@ -83,7 +83,7 @@ class RouteBuilder { RouteMatchList matchList, bool routerNeglect, ) { - if (matchList.isEmpty) { + if (matchList.isEmpty && !matchList.isError) { // The build method can be called before async redirect finishes. Build a // empty box until then. return const SizedBox.shrink(); @@ -92,18 +92,12 @@ class RouteBuilder { context, Builder( builder: (BuildContext context) { - try { - final Map, GoRouterState> newRegistry = - , GoRouterState>{}; - final Widget result = tryBuild(context, matchList, routerNeglect, - configuration.navigatorKey, newRegistry); - _registry.updateRegistry(newRegistry); - return GoRouterStateRegistryScope( - registry: _registry, child: result); - } on _RouteBuilderError catch (e) { - return _buildErrorNavigator(context, e, matchList.uri, - onPopPageWithRouteMatch, configuration.navigatorKey); - } + final Map, GoRouterState> newRegistry = + , GoRouterState>{}; + final Widget result = tryBuild(context, matchList, routerNeglect, + configuration.navigatorKey, newRegistry); + _registry.updateRegistry(newRegistry); + return GoRouterStateRegistryScope(registry: _registry, child: result); }, ), ); @@ -147,28 +141,31 @@ class RouteBuilder { bool routerNeglect, GlobalKey navigatorKey, Map, GoRouterState> registry) { - final Map, List>> keyToPage = - , List>>{}; - try { + final Map, List>> keyToPage; + if (matchList.isError) { + keyToPage = , List>>{ + navigatorKey: >[ + _buildErrorPage( + context, _buildErrorState(matchList.error!, matchList.uri)), + ] + }; + } else { + keyToPage = , List>>{}; _buildRecursive(context, matchList, 0, pagePopContext, routerNeglect, keyToPage, navigatorKey, registry); // Every Page should have a corresponding RouteMatch. assert(keyToPage.values.flattened.every((Page page) => pagePopContext.getRouteMatchForPage(page) != null)); - return keyToPage[navigatorKey]!; - } on _RouteBuilderError catch (e) { - return >[ - _buildErrorPage(context, e, matchList.uri), - ]; - } finally { - /// Clean up previous cache to prevent memory leak, making sure any nested - /// stateful shell routes for the current match list are kept. - final Set activeKeys = keyToPage.keys.toSet() - ..addAll(_nestedStatefulNavigatorKeys(matchList)); - _goHeroCache.removeWhere( - (GlobalKey key, _) => !activeKeys.contains(key)); } + + /// Clean up previous cache to prevent memory leak, making sure any nested + /// stateful shell routes for the current match list are kept. + final Set activeKeys = keyToPage.keys.toSet() + ..addAll(_nestedStatefulNavigatorKeys(matchList)); + _goHeroCache.removeWhere( + (GlobalKey key, _) => !activeKeys.contains(key)); + return keyToPage[navigatorKey]!; } static Set> _nestedStatefulNavigatorKeys( @@ -200,15 +197,15 @@ class RouteBuilder { } final RouteMatch match = matchList.matches[startIndex]; - if (match.error != null) { - throw _RouteBuilderError('Match error found during build phase', - exception: match.error); - } - final RouteBase route = match.route; final GoRouterState state = buildState(matchList, match); Page? page; - if (route is GoRoute) { + if (state.error != null) { + page = _buildErrorPage(context, state); + keyToPages.putIfAbsent(navigatorKey, () => >[]).add(page); + _buildRecursive(context, matchList, startIndex + 1, pagePopContext, + routerNeglect, keyToPages, navigatorKey, registry); + } else if (route is GoRoute) { page = _buildPageForGoRoute(context, state, match, route, pagePopContext); // If this GoRoute is for a different Navigator, add it to the // list of out of scope pages @@ -284,7 +281,7 @@ class RouteBuilder { registry[page] = state; pagePopContext._setRouteMatchForPage(page, match); } else { - throw _RouteBuilderException('Unsupported route type $route'); + throw GoError('Unsupported route type $route'); } } @@ -324,8 +321,17 @@ class RouteBuilder { name = route.name; path = route.path; } - final RouteMatchList effectiveMatchList = - match is ImperativeRouteMatch ? match.matches : matchList; + final RouteMatchList effectiveMatchList; + if (match is ImperativeRouteMatch) { + effectiveMatchList = match.matches; + if (effectiveMatchList.isError) { + return _buildErrorState( + effectiveMatchList.error!, effectiveMatchList.uri); + } + } else { + effectiveMatchList = matchList; + assert(!effectiveMatchList.isError); + } return GoRouterState( configuration, location: effectiveMatchList.uri.toString(), @@ -335,10 +341,10 @@ class RouteBuilder { fullPath: effectiveMatchList.fullPath, pathParameters: Map.from(effectiveMatchList.pathParameters), - error: match.error, + error: effectiveMatchList.error, queryParameters: effectiveMatchList.uri.queryParameters, queryParametersAll: effectiveMatchList.uri.queryParametersAll, - extra: match.extra, + extra: effectiveMatchList.extra, pageKey: match.pageKey, ); } @@ -370,7 +376,7 @@ class RouteBuilder { final GoRouterWidgetBuilder? builder = route.builder; if (builder == null) { - throw _RouteBuilderError('No routeBuilder provided to GoRoute: $route'); + throw GoError('No routeBuilder provided to GoRoute: $route'); } return builder(context, state); @@ -405,7 +411,7 @@ class RouteBuilder { final Widget? widget = route.buildWidget(context, state, shellRouteContext!); if (widget == null) { - throw _RouteBuilderError('No builder provided to ShellRoute: $route'); + throw GoError('No builder provided to ShellRoute: $route'); } return widget; @@ -485,38 +491,26 @@ class RouteBuilder { child: child, ); - /// Builds a Navigator containing an error page. - Widget _buildErrorNavigator( - BuildContext context, - _RouteBuilderError e, - Uri uri, - PopPageWithRouteMatchCallback onPopPage, - GlobalKey navigatorKey) { - return _buildNavigator( - (Route route, dynamic result) => onPopPage(route, result, null), - >[ - _buildErrorPage(context, e, uri), - ], - navigatorKey, - ); - } - - /// Builds a an error page. - Page _buildErrorPage( - BuildContext context, - _RouteBuilderError error, + GoRouterState _buildErrorState( + Exception error, Uri uri, ) { - final GoRouterState state = GoRouterState( + final String location = uri.toString(); + return GoRouterState( configuration, - location: uri.toString(), + location: location, matchedLocation: uri.path, name: null, queryParameters: uri.queryParameters, queryParametersAll: uri.queryParametersAll, - error: Exception(error), - pageKey: const ValueKey('error'), + error: error, + pageKey: ValueKey('$location(error)'), ); + } + + /// Builds a an error page. + Page _buildErrorPage(BuildContext context, GoRouterState state) { + assert(state.error != null); // If the error page builder is provided, use that, otherwise, if the error // builder is provided, wrap that in an app-specific page (for example, @@ -556,43 +550,6 @@ typedef _PageBuilderForAppType = Page Function({ required Widget child, }); -/// An error that occurred while building the app's UI based on the route -/// matches. -class _RouteBuilderError extends Error { - /// Constructs a [_RouteBuilderError]. - _RouteBuilderError(this.message, {this.exception}); - - /// The error message. - final String message; - - /// The exception that occurred. - final Exception? exception; - - @override - String toString() { - return '$message ${exception ?? ""}'; - } -} - -/// An error that occurred while building the app's UI based on the route -/// matches. -class _RouteBuilderException implements Exception { - /// Constructs a [_RouteBuilderException]. - //ignore: unused_element - _RouteBuilderException(this.message, {this.exception}); - - /// The error message. - final String message; - - /// The exception that occurred. - final Exception? exception; - - @override - String toString() { - return '$message ${exception ?? ""}'; - } -} - /// Context used to provide a route to page association when popping routes. class _PagePopContext { _PagePopContext._(this.onPopPageWithRouteMatch); diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart index 07d02d469c..f2e9d84617 100644 --- a/packages/go_router/lib/src/configuration.dart +++ b/packages/go_router/lib/src/configuration.dart @@ -2,11 +2,13 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:flutter/widgets.dart'; import 'configuration.dart'; import 'logging.dart'; -import 'matching.dart'; +import 'match.dart'; import 'misc/errors.dart'; import 'path_utils.dart'; import 'typedefs.dart'; @@ -26,8 +28,7 @@ class RouteConfiguration { _debugVerifyNoDuplicatePathParameter(routes, {})), assert(_debugCheckParentNavigatorKeys( routes, >[navigatorKey])) { - assert(_debugCheckStatefulShellBranchDefaultLocations( - routes, RouteMatcher(this))); + assert(_debugCheckStatefulShellBranchDefaultLocations(routes)); _cacheNameToPath('', routes); log.info(debugKnownRoutes()); } @@ -37,11 +38,13 @@ class RouteConfiguration { late bool subRouteIsTopLevel; if (route is GoRoute) { if (isTopLevel) { - assert(route.path.startsWith('/'), - 'top-level path must start with "/": $route'); + if (!route.path.startsWith('/')) { + throw GoError('top-level path must start with "/": $route'); + } } else { - assert(!route.path.startsWith('/') && !route.path.endsWith('/'), - 'sub-route path may not start or end with /: $route'); + if (route.path.startsWith('/') || route.path.endsWith('/')) { + throw GoError('sub-route path may not start or end with /: $route'); + } } subRouteIsTopLevel = false; } else if (route is ShellRouteBase) { @@ -62,11 +65,11 @@ class RouteConfiguration { if (parentKey != null) { // Verify that the root navigator or a ShellRoute ancestor has a // matching navigator key. - assert( - allowedKeys.contains(parentKey), - 'parentNavigatorKey $parentKey must refer to' - " an ancestor ShellRoute's navigatorKey or GoRouter's" - ' navigatorKey'); + if (!allowedKeys.contains(parentKey)) { + throw GoError('parentNavigatorKey $parentKey must refer to' + " an ancestor ShellRoute's navigatorKey or GoRouter's" + ' navigatorKey'); + } _debugCheckParentNavigatorKeys( route.routes, @@ -91,10 +94,11 @@ class RouteConfiguration { ); } else if (route is StatefulShellRoute) { for (final StatefulShellBranch branch in route.branches) { - assert( - !allowedKeys.contains(branch.navigatorKey), - 'StatefulShellBranch must not reuse an ancestor navigatorKey ' - '(${branch.navigatorKey})'); + if (allowedKeys.contains(branch.navigatorKey)) { + throw GoError( + 'StatefulShellBranch must not reuse an ancestor navigatorKey ' + '(${branch.navigatorKey})'); + } _debugCheckParentNavigatorKeys( branch.routes, @@ -131,66 +135,77 @@ class RouteConfiguration { // Check to see that the configured initialLocation of StatefulShellBranches // points to a descendant route of the route branch. - bool _debugCheckStatefulShellBranchDefaultLocations( - List routes, RouteMatcher matcher) { - try { - for (final RouteBase route in routes) { - if (route is StatefulShellRoute) { - for (final StatefulShellBranch branch in route.branches) { - if (branch.initialLocation == null) { - // Recursively search for the first GoRoute descendant. Will - // throw assertion error if not found. - final GoRoute? route = branch.defaultRoute; - final String? initialLocation = - route != null ? locationForRoute(route) : null; - assert( - initialLocation != null, + bool _debugCheckStatefulShellBranchDefaultLocations(List routes) { + for (final RouteBase route in routes) { + if (route is StatefulShellRoute) { + for (final StatefulShellBranch branch in route.branches) { + if (branch.initialLocation == null) { + // Recursively search for the first GoRoute descendant. Will + // throw assertion error if not found. + final GoRoute? route = branch.defaultRoute; + final String? initialLocation = + route != null ? locationForRoute(route) : null; + if (initialLocation == null) { + throw GoError( 'The default location of a StatefulShellBranch must be ' 'derivable from GoRoute descendant'); - assert( - route!.pathParameters.isEmpty, + } + if (route!.pathParameters.isNotEmpty) { + throw GoError( 'The default location of a StatefulShellBranch cannot be ' 'a parameterized route'); - } else { - final List matchRoutes = - matcher.findMatch(branch.initialLocation!).routes; - final int shellIndex = matchRoutes.indexOf(route); - bool matchFound = false; - if (shellIndex >= 0 && (shellIndex + 1) < matchRoutes.length) { - final RouteBase branchRoot = matchRoutes[shellIndex + 1]; - matchFound = branch.routes.contains(branchRoot); - } - assert( - matchFound, + } + } else { + final RouteMatchList matchList = findMatch(branch.initialLocation!); + if (matchList.isError) { + throw GoError( + 'initialLocation (${matchList.uri}) of StatefulShellBranch must ' + 'be a valid location'); + } + final List matchRoutes = matchList.routes; + final int shellIndex = matchRoutes.indexOf(route); + bool matchFound = false; + if (shellIndex >= 0 && (shellIndex + 1) < matchRoutes.length) { + final RouteBase branchRoot = matchRoutes[shellIndex + 1]; + matchFound = branch.routes.contains(branchRoot); + } + if (!matchFound) { + throw GoError( 'The initialLocation (${branch.initialLocation}) of ' 'StatefulShellBranch must match a descendant route of the ' 'branch'); } } } - _debugCheckStatefulShellBranchDefaultLocations(route.routes, matcher); } - } on MatcherError catch (e) { - assert( - false, - 'initialLocation (${e.location}) of StatefulShellBranch must ' - 'be a valid location'); + _debugCheckStatefulShellBranchDefaultLocations(route.routes); } return true; } + /// The match used when there is an error during parsing. + static RouteMatchList _errorRouteMatchList(Uri uri, String errorMessage) { + final Exception error = Exception(errorMessage); + return RouteMatchList( + matches: const [], + error: error, + uri: uri, + pathParameters: const {}, + ); + } + /// The list of top level routes used by [GoRouterDelegate]. final List routes; /// The limit for the number of consecutive redirects. final int redirectLimit; + /// The global key for top level navigator. + final GlobalKey navigatorKey; + /// Top level page redirect. final GoRouterRedirect topRedirect; - /// The key to use when building the root [Navigator]. - final GlobalKey navigatorKey; - final Map _nameToPath = {}; /// Looks up the url location by a [GoRoute]'s name. @@ -235,6 +250,269 @@ class RouteConfiguration { .toString(); } + /// Finds the routes that matched the given URL. + RouteMatchList findMatch(String location, {Object? extra}) { + final Uri uri = Uri.parse(canonicalUri(location)); + + final Map pathParameters = {}; + final List? matches = _getLocRouteMatches(uri, pathParameters); + + if (matches == null) { + return _errorRouteMatchList(uri, 'no routes for location: $uri'); + } + return RouteMatchList( + matches: matches, + uri: uri, + pathParameters: pathParameters, + extra: extra); + } + + List? _getLocRouteMatches( + Uri uri, Map pathParameters) { + final List? result = _getLocRouteRecursively( + location: uri.path, + remainingLocation: uri.path, + matchedLocation: '', + pathParameters: pathParameters, + routes: routes, + ); + return result; + } + + List? _getLocRouteRecursively({ + required String location, + required String remainingLocation, + required String matchedLocation, + required Map pathParameters, + required List routes, + }) { + List? result; + late Map subPathParameters; + // find the set of matches at this level of the tree + for (final RouteBase route in routes) { + subPathParameters = {}; + + final RouteMatch? match = RouteMatch.match( + route: route, + remainingLocation: remainingLocation, + matchedLocation: matchedLocation, + pathParameters: subPathParameters, + ); + + if (match == null) { + continue; + } + + if (match.route is GoRoute && + match.matchedLocation.toLowerCase() == location.toLowerCase()) { + // If it is a complete match, then return the matched route + // NOTE: need a lower case match because matchedLocation is canonicalized to match + // the path case whereas the location can be of any case and still match + result = [match]; + } else if (route.routes.isEmpty) { + // If it is partial match but no sub-routes, bail. + continue; + } else { + // Otherwise, recurse + final String childRestLoc; + final String newParentSubLoc; + if (match.route is ShellRouteBase) { + childRestLoc = remainingLocation; + newParentSubLoc = matchedLocation; + } else { + assert(location.startsWith(match.matchedLocation)); + assert(remainingLocation.isNotEmpty); + + childRestLoc = location.substring(match.matchedLocation.length + + (match.matchedLocation == '/' ? 0 : 1)); + newParentSubLoc = match.matchedLocation; + } + + final List? subRouteMatch = _getLocRouteRecursively( + location: location, + remainingLocation: childRestLoc, + matchedLocation: newParentSubLoc, + pathParameters: subPathParameters, + routes: route.routes, + ); + + // If there's no sub-route matches, there is no match for this location + if (subRouteMatch == null) { + continue; + } + result = [match, ...subRouteMatch]; + } + // Should only reach here if there is a match. + break; + } + if (result != null) { + pathParameters.addAll(subPathParameters); + } + return result; + } + + /// Processes redirects by returning a new [RouteMatchList] representing the new + /// location. + FutureOr redirect( + BuildContext context, FutureOr prevMatchListFuture, + {required List redirectHistory}) { + FutureOr processRedirect(RouteMatchList prevMatchList) { + final String prevLocation = prevMatchList.uri.toString(); + FutureOr processTopLevelRedirect( + String? topRedirectLocation) { + if (topRedirectLocation != null && + topRedirectLocation != prevLocation) { + final RouteMatchList newMatch = _getNewMatches( + topRedirectLocation, + prevMatchList.uri, + redirectHistory, + ); + if (newMatch.isError) { + return newMatch; + } + return redirect( + context, + newMatch, + redirectHistory: redirectHistory, + ); + } + + FutureOr processRouteLevelRedirect( + String? routeRedirectLocation) { + if (routeRedirectLocation != null && + routeRedirectLocation != prevLocation) { + final RouteMatchList newMatch = _getNewMatches( + routeRedirectLocation, + prevMatchList.uri, + redirectHistory, + ); + + if (newMatch.isError) { + return newMatch; + } + return redirect( + context, + newMatch, + redirectHistory: redirectHistory, + ); + } + return prevMatchList; + } + + final FutureOr routeLevelRedirectResult = + _getRouteLevelRedirect(context, prevMatchList, 0); + if (routeLevelRedirectResult is String?) { + return processRouteLevelRedirect(routeLevelRedirectResult); + } + return routeLevelRedirectResult + .then(processRouteLevelRedirect); + } + + redirectHistory.add(prevMatchList); + // Check for top-level redirect + final FutureOr topRedirectResult = topRedirect( + context, + GoRouterState( + this, + location: prevLocation, + name: null, + // No name available at the top level trim the query params off the + // sub-location to match route.redirect + matchedLocation: prevMatchList.uri.path, + queryParameters: prevMatchList.uri.queryParameters, + queryParametersAll: prevMatchList.uri.queryParametersAll, + extra: prevMatchList.extra, + pageKey: const ValueKey('topLevel'), + ), + ); + + if (topRedirectResult is String?) { + return processTopLevelRedirect(topRedirectResult); + } + return topRedirectResult.then(processTopLevelRedirect); + } + + if (prevMatchListFuture is RouteMatchList) { + return processRedirect(prevMatchListFuture); + } + return prevMatchListFuture.then(processRedirect); + } + + FutureOr _getRouteLevelRedirect( + BuildContext context, + RouteMatchList matchList, + int currentCheckIndex, + ) { + if (currentCheckIndex >= matchList.matches.length) { + return null; + } + final RouteMatch match = matchList.matches[currentCheckIndex]; + FutureOr processRouteRedirect(String? newLocation) => + newLocation ?? + _getRouteLevelRedirect(context, matchList, currentCheckIndex + 1); + final RouteBase route = match.route; + FutureOr routeRedirectResult; + if (route is GoRoute && route.redirect != null) { + routeRedirectResult = route.redirect!( + context, + GoRouterState( + this, + location: matchList.uri.toString(), + matchedLocation: match.matchedLocation, + name: route.name, + path: route.path, + fullPath: matchList.fullPath, + extra: matchList.extra, + pathParameters: matchList.pathParameters, + queryParameters: matchList.uri.queryParameters, + queryParametersAll: matchList.uri.queryParametersAll, + pageKey: match.pageKey, + ), + ); + } + if (routeRedirectResult is String?) { + return processRouteRedirect(routeRedirectResult); + } + return routeRedirectResult.then(processRouteRedirect); + } + + RouteMatchList _getNewMatches( + String newLocation, + Uri previousLocation, + List redirectHistory, + ) { + try { + final RouteMatchList newMatch = findMatch(newLocation); + _addRedirect(redirectHistory, newMatch, previousLocation); + return newMatch; + } on RedirectionError catch (e) { + log.info('Redirection error: ${e.message}'); + return _errorRouteMatchList(e.location, e.message); + } + } + + /// Adds the redirect to [redirects] if it is valid. + /// + /// Throws if a loop is detected or the redirection limit is reached. + void _addRedirect( + List redirects, + RouteMatchList newMatch, + Uri prevLocation, + ) { + if (redirects.contains(newMatch)) { + throw RedirectionError('redirect loop detected', + [...redirects, newMatch], prevLocation); + } + if (redirects.length > redirectLimit) { + throw RedirectionError('too many redirects', + [...redirects, newMatch], prevLocation); + } + + redirects.add(newMatch); + + log.info('redirecting to $newMatch'); + } + /// Get the location for the provided route. /// /// Builds the absolute path for the route, by concatenating the paths of the diff --git a/packages/go_router/lib/src/delegate.dart b/packages/go_router/lib/src/delegate.dart index 985dce389d..2ba3729ef9 100644 --- a/packages/go_router/lib/src/delegate.dart +++ b/packages/go_router/lib/src/delegate.dart @@ -10,7 +10,6 @@ import 'package:flutter/widgets.dart'; import 'builder.dart'; import 'configuration.dart'; import 'match.dart'; -import 'matching.dart'; import 'misc/errors.dart'; import 'typedefs.dart'; @@ -46,25 +45,10 @@ class GoRouterDelegate extends RouterDelegate /// Set to true to disable creating history entries on the web. final bool routerNeglect; - RouteMatchList _matchList = RouteMatchList.empty; - final RouteConfiguration _configuration; - /// Stores the number of times each route route has been pushed. - /// - /// This is used to generate a unique key for each route. - /// - /// For example, it could be equal to: - /// ```dart - /// { - /// 'family': 1, - /// 'family/:fid': 2, - /// } - /// ``` - final Map _pushCounts = {}; - _NavigatorStateIterator _createNavigatorStateIterator() => - _NavigatorStateIterator(_matchList, navigatorKey.currentState!); + _NavigatorStateIterator(currentConfiguration, navigatorKey.currentState!); @override Future popRoute() async { @@ -78,45 +62,6 @@ class GoRouterDelegate extends RouterDelegate return false; } - ValueKey _getNewKeyForPath(String path) { - // Remap the pageKey to allow any number of the same page on the stack - final int count = (_pushCounts[path] ?? -1) + 1; - _pushCounts[path] = count; - return ValueKey('$path-p$count'); - } - - Future _push( - RouteMatchList matches, ValueKey pageKey) async { - final ImperativeRouteMatch newPageKeyMatch = ImperativeRouteMatch( - pageKey: pageKey, - matches: matches, - ); - - _matchList = _matchList.push(newPageKeyMatch); - return newPageKeyMatch.future; - } - - void _remove(RouteMatch match) { - _matchList = _matchList.remove(match); - } - - /// Pushes the given location onto the page stack. - /// - /// See also: - /// * [pushReplacement] which replaces the top-most page of the page stack and - /// always use a new page key. - /// * [replace] which replaces the top-most page of the page stack but treats - /// it as the same page. The page key will be reused. This will preserve the - /// state and not run any page animation. - Future push(RouteMatchList matches) async { - assert(matches.last.route is! ShellRoute); - - final ValueKey pageKey = _getNewKeyForPath(matches.fullPath); - final Future future = _push(matches, pageKey); - notifyListeners(); - return future; - } - /// Returns `true` if the active Navigator can pop. bool canPop() { final _NavigatorStateIterator iterator = _createNavigatorStateIterator(); @@ -142,7 +87,7 @@ class GoRouterDelegate extends RouterDelegate void _debugAssertMatchListNotEmpty() { assert( - _matchList.isNotEmpty, + currentConfiguration.isNotEmpty, 'You have popped the last page off of the stack,' ' there are no pages left to show', ); @@ -157,7 +102,7 @@ class GoRouterDelegate extends RouterDelegate if (match is ImperativeRouteMatch) { match.complete(result); } - _remove(match!); + currentConfiguration = currentConfiguration.remove(match!); notifyListeners(); assert(() { _debugAssertMatchListNotEmpty(); @@ -166,57 +111,19 @@ class GoRouterDelegate extends RouterDelegate return true; } - /// Replaces the top-most page of the page stack with the given one. - /// - /// The page key of the new page will always be different from the old one. - /// - /// See also: - /// * [push] which pushes the given location onto the page stack. - /// * [replace] which replaces the top-most page of the page stack but treats - /// it as the same page. The page key will be reused. This will preserve the - /// state and not run any page animation. - void pushReplacement(RouteMatchList matches) { - assert(matches.last.route is! ShellRoute); - _remove(_matchList.last); - push(matches); // [push] will notify the listeners. - } - - /// Replaces the top-most page of the page stack with the given one but treats - /// it as the same page. - /// - /// The page key will be reused. This will preserve the state and not run any - /// page animation. - /// - /// See also: - /// * [push] which pushes the given location onto the page stack. - /// * [pushReplacement] which replaces the top-most page of the page stack but - /// always uses a new page key. - void replace(RouteMatchList matches) { - assert(matches.last.route is! ShellRoute); - final RouteMatch routeMatch = _matchList.last; - final ValueKey pageKey = routeMatch.pageKey; - _remove(routeMatch); - _push(matches, pageKey); - notifyListeners(); - } - - /// For internal use; visible for testing only. - @visibleForTesting - RouteMatchList get matches => _matchList; - /// For use by the Router architecture as part of the RouterDelegate. GlobalKey get navigatorKey => _configuration.navigatorKey; /// For use by the Router architecture as part of the RouterDelegate. @override - RouteMatchList get currentConfiguration => _matchList; + RouteMatchList currentConfiguration = RouteMatchList.empty; /// For use by the Router architecture as part of the RouterDelegate. @override Widget build(BuildContext context) { return builder.build( context, - _matchList, + currentConfiguration, routerNeglect, ); } @@ -224,8 +131,8 @@ class GoRouterDelegate extends RouterDelegate /// For use by the Router architecture as part of the RouterDelegate. @override Future setNewRoutePath(RouteMatchList configuration) { - _matchList = configuration; - assert(_matchList.isNotEmpty); + currentConfiguration = configuration; + assert(currentConfiguration.isNotEmpty || currentConfiguration.isError); notifyListeners(); // Use [SynchronousFuture] so that the initial url is processed // synchronously and remove unwanted initial animations on deep-linking diff --git a/packages/go_router/lib/src/information_provider.dart b/packages/go_router/lib/src/information_provider.dart index d78e5bc83d..0f61e742bc 100644 --- a/packages/go_router/lib/src/information_provider.dart +++ b/packages/go_router/lib/src/information_provider.dart @@ -2,79 +2,229 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; +import 'match.dart'; + +// TODO(chunhtai): remove this ignore and migrate the code +// https://github.com/flutter/flutter/issues/124045. +// ignore_for_file: deprecated_member_use + +/// The type of the navigation. +/// +/// This enum is used by [RouteInformationState] to denote the navigation +/// operations. +enum NavigatingType { + /// Push new location on top of the [RouteInformationState.baseRouteMatchList]. + push, + + /// Push new location and remove top-most [RouteMatch] of the + /// [RouteInformationState.baseRouteMatchList]. + pushReplacement, + + /// Push new location and replace top-most [RouteMatch] of the + /// [RouteInformationState.baseRouteMatchList]. + replace, + + /// Replace the entire [RouteMatchList] with the new location. + go, +} + +/// The data class to be stored in [RouteInformation.state] to be used by +/// [GoRouteInformationPrarser]. +/// +/// This state class is used internally in go_router and will not be send to +/// the engine. +class RouteInformationState { + /// Creates an InternalRouteInformationState. + @visibleForTesting + RouteInformationState({ + this.extra, + this.completer, + this.baseRouteMatchList, + required this.type, + }) : assert((type != NavigatingType.go) == + (completer != null && baseRouteMatchList != null)); + + /// The extra object used when navigating with [GoRouter]. + final Object? extra; + + /// The completer that needs to be complete when the newly added route is + /// popped off the screen.. + /// + /// This is only null if [type] is [NavigatingType.go]. + final Completer? completer; + + /// The base route match list to push on top to. + /// + /// This is only null if [type] is [NavigatingType.go]. + final RouteMatchList? baseRouteMatchList; + + /// The type of navigation. + final NavigatingType type; +} + /// The [RouteInformationProvider] created by go_router. class GoRouteInformationProvider extends RouteInformationProvider with WidgetsBindingObserver, ChangeNotifier { /// Creates a [GoRouteInformationProvider]. GoRouteInformationProvider({ - required RouteInformation initialRouteInformation, + required String initialLocation, + required Object? initialExtra, Listenable? refreshListenable, }) : _refreshListenable = refreshListenable, - _value = initialRouteInformation { + _value = RouteInformation( + location: initialLocation, + state: RouteInformationState( + extra: initialExtra, type: NavigatingType.go), + ), + _valueInEngine = _kEmptyRouteInformation { _refreshListenable?.addListener(notifyListeners); } final Listenable? _refreshListenable; static WidgetsBinding get _binding => WidgetsBinding.instance; + static const RouteInformation _kEmptyRouteInformation = + RouteInformation(location: ''); @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 && - // TODO(chunhtai): remove this ignore and migrate the code - // https://github.com/flutter/flutter/issues/124045. - // ignore: deprecated_member_use - _valueInEngine.location == routeInformation.location); + // GoRouteInformationParser should always report encoded route match list + // in the state. + assert(routeInformation.state != null); + final bool replace; + switch (type) { + case RouteInformationReportingType.none: + if (_valueInEngine.location == routeInformation.location && + const DeepCollectionEquality() + .equals(_valueInEngine.state, routeInformation.state)) { + return; + } + replace = _valueInEngine == _kEmptyRouteInformation; + break; + case RouteInformationReportingType.neglect: + replace = true; + break; + case RouteInformationReportingType.navigate: + replace = false; + break; + } SystemNavigator.selectMultiEntryHistory(); - // TODO(chunhtai): report extra to browser through state if possible - // See https://github.com/flutter/flutter/issues/108142 SystemNavigator.routeInformationUpdated( // TODO(chunhtai): remove this ignore and migrate the code // https://github.com/flutter/flutter/issues/124045. - // ignore: deprecated_member_use, unnecessary_null_checks, unnecessary_non_null_assertion + // ignore: unnecessary_null_checks, unnecessary_non_null_assertion location: routeInformation.location!, + state: routeInformation.state, replace: replace, ); - _value = routeInformation; - _valueInEngine = routeInformation; + _value = _valueInEngine = routeInformation; } @override RouteInformation get value => _value; RouteInformation _value; - set value(RouteInformation other) { + void _setValue(String location, Object state) { final bool shouldNotify = - // TODO(chunhtai): remove this ignore and migrate the code - // https://github.com/flutter/flutter/issues/124045. - // ignore: deprecated_member_use - _value.location != other.location || _value.state != other.state; - _value = other; + _value.location != location || _value.state != state; + _value = RouteInformation(location: location, state: state); if (shouldNotify) { notifyListeners(); } } - RouteInformation _valueInEngine = - // TODO(chunhtai): remove this ignore and migrate the code - // https://github.com/flutter/flutter/issues/124045. - // ignore: deprecated_member_use - RouteInformation(location: _binding.platformDispatcher.defaultRouteName); + /// Pushes the `location` as a new route on top of `base`. + Future push(String location, + {required RouteMatchList base, Object? extra}) { + final Completer completer = Completer(); + _setValue( + location, + RouteInformationState( + extra: extra, + baseRouteMatchList: base, + completer: completer, + type: NavigatingType.push, + ), + ); + return completer.future; + } + + /// Replace the current route matches with the `location`. + void go(String location, {Object? extra}) { + _setValue( + location, + RouteInformationState( + extra: extra, + type: NavigatingType.go, + ), + ); + } + + /// Restores the current route matches with the `encodedMatchList`. + void restore(String location, {required Object encodedMatchList}) { + _setValue( + location, + encodedMatchList, + ); + } + + /// Removes the top-most route match from `base` and pushes the `location` as a + /// new route on top. + Future pushReplacement(String location, + {required RouteMatchList base, Object? extra}) { + final Completer completer = Completer(); + _setValue( + location, + RouteInformationState( + extra: extra, + baseRouteMatchList: base, + completer: completer, + type: NavigatingType.pushReplacement, + ), + ); + return completer.future; + } + + /// Replaces the top-most route match from `base` with the `location`. + Future replace(String location, + {required RouteMatchList base, Object? extra}) { + final Completer completer = Completer(); + _setValue( + location, + RouteInformationState( + extra: extra, + baseRouteMatchList: base, + completer: completer, + type: NavigatingType.replace, + ), + ); + return completer.future; + } + + RouteInformation _valueInEngine; void _platformReportsNewRouteInformation(RouteInformation routeInformation) { if (_value == routeInformation) { return; } - _value = routeInformation; - _valueInEngine = routeInformation; + if (routeInformation.state != null) { + _value = _valueInEngine = routeInformation; + } else { + _value = RouteInformation( + location: routeInformation.location, + state: RouteInformationState(type: NavigatingType.go), + ); + _valueInEngine = _kEmptyRouteInformation; + } notifyListeners(); } @@ -113,9 +263,6 @@ class GoRouteInformationProvider extends RouteInformationProvider @override Future didPushRoute(String route) { assert(hasListeners); - // TODO(chunhtai): remove this ignore and migrate the code - // https://github.com/flutter/flutter/issues/124045. - // ignore: deprecated_member_use _platformReportsNewRouteInformation(RouteInformation(location: route)); return SynchronousFuture(true); } diff --git a/packages/go_router/lib/src/match.dart b/packages/go_router/lib/src/match.dart index 24aa0587bc..e04833132e 100644 --- a/packages/go_router/lib/src/match.dart +++ b/packages/go_router/lib/src/match.dart @@ -3,12 +3,14 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:convert'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -import 'matching.dart'; +import 'configuration.dart'; import 'path_utils.dart'; -import 'route.dart'; /// An matched result by matching a [RouteBase] against a location. /// @@ -19,8 +21,6 @@ class RouteMatch { const RouteMatch({ required this.route, required this.matchedLocation, - required this.extra, - required this.error, required this.pageKey, }); @@ -34,14 +34,11 @@ class RouteMatch { required String remainingLocation, // e.g. person/p1 required String matchedLocation, // e.g. /family/f2 required Map pathParameters, - required Object? extra, }) { if (route is ShellRouteBase) { return RouteMatch( route: route, matchedLocation: remainingLocation, - extra: extra, - error: null, pageKey: ValueKey(route.hashCode.toString()), ); } else if (route is GoRoute) { @@ -62,12 +59,11 @@ class RouteMatch { return RouteMatch( route: route, matchedLocation: newMatchedLocation, - extra: extra, - error: null, pageKey: ValueKey(route.hashCode.toString()), ); } - throw MatcherError('Unexpected route type: $route', remainingLocation); + assert(false, 'Unexpected route type: $route'); + return null; } /// The matched route. @@ -83,12 +79,6 @@ class RouteMatch { /// matchedLocation = '/family/f2' final String matchedLocation; - /// An extra object to pass along with the navigation. - final Object? extra; - - /// An exception if there was an error during matching. - final Exception? error; - /// Value key of type string, to hold a unique reference to a page. final ValueKey pageKey; @@ -100,44 +90,49 @@ class RouteMatch { return other is RouteMatch && route == other.route && matchedLocation == other.matchedLocation && - extra == other.extra && pageKey == other.pageKey; } @override - int get hashCode => Object.hash(route, matchedLocation, extra, pageKey); + int get hashCode => Object.hash(route, matchedLocation, pageKey); } /// The route match that represent route pushed through [GoRouter.push]. -class ImperativeRouteMatch extends RouteMatch { +class ImperativeRouteMatch extends RouteMatch { /// Constructor for [ImperativeRouteMatch]. - ImperativeRouteMatch({ - required super.pageKey, - required this.matches, - }) : _completer = Completer(), - super( - route: matches.last.route, - matchedLocation: matches.last.matchedLocation, - extra: matches.last.extra, - error: matches.last.error, + ImperativeRouteMatch( + {required super.pageKey, required this.matches, required this.completer}) + : super( + route: _getsLastRouteFromMatches(matches), + matchedLocation: _getsMatchedLocationFromMatches(matches), ); + static RouteBase _getsLastRouteFromMatches(RouteMatchList matchList) { + if (matchList.isError) { + return GoRoute( + path: 'error', builder: (_, __) => throw UnimplementedError()); + } + return matchList.last.route; + } + + static String _getsMatchedLocationFromMatches(RouteMatchList matchList) { + if (matchList.isError) { + return matchList.uri.toString(); + } + return matchList.last.matchedLocation; + } /// The matches that produces this route match. final RouteMatchList matches; /// The completer for the future returned by [GoRouter.push]. - final Completer _completer; + final Completer completer; /// Called when the corresponding [Route] associated with this route match is /// completed. void complete([dynamic value]) { - _completer.complete(value as T?); + completer.complete(value); } - /// The future of the [RouteMatch] completer. - /// When the future completes, this will return the value passed to [complete]. - Future get future => _completer.future; - // An ImperativeRouteMatch has its own life cycle due the the _completer. // comparing _completer between instances would be the same thing as // comparing object reference. @@ -149,3 +144,304 @@ class ImperativeRouteMatch extends RouteMatch { @override int get hashCode => identityHashCode(this); } + +/// The list of [RouteMatch] objects. +/// +/// This corresponds to the GoRouter's history. +@immutable +class RouteMatchList { + /// RouteMatchList constructor. + RouteMatchList({ + required this.matches, + required this.uri, + this.extra, + this.error, + required this.pathParameters, + }) : fullPath = _generateFullPath(matches); + + /// Constructs an empty matches object. + static RouteMatchList empty = RouteMatchList( + matches: const [], + uri: Uri(), + pathParameters: const {}); + + /// The route matches. + final List matches; + + /// Parameters for the matched route, URI-encoded. + /// + /// The parameters only reflects [RouteMatch]s that are not + /// [ImperativeRouteMatch]. + final Map pathParameters; + + /// The uri of the current match. + /// + /// This uri only reflects [RouteMatch]s that are not [ImperativeRouteMatch]. + final Uri uri; + + /// An extra object to pass along with the navigation. + final Object? extra; + + /// An exception if there was an error during matching. + final Exception? error; + + /// the full path pattern that matches the uri. + /// + /// For example: + /// + /// ```dart + /// '/family/:fid/person/:pid' + /// ``` + final String fullPath; + + /// Generates the full path (ex: `'/family/:fid/person/:pid'`) of a list of + /// [RouteMatch]. + /// + /// This method ignores [ImperativeRouteMatch]s in the `matches`, as they + /// don't contribute to the path. + /// + /// This methods considers that [matches]'s elements verify the go route + /// structure given to `GoRouter`. For example, if the routes structure is + /// + /// ```dart + /// GoRoute( + /// path: '/a', + /// routes: [ + /// GoRoute( + /// path: 'b', + /// routes: [ + /// GoRoute( + /// path: 'c', + /// ), + /// ], + /// ), + /// ], + /// ), + /// ``` + /// + /// The [matches] must be the in same order of how GoRoutes are matched. + /// + /// ```dart + /// [RouteMatchA(), RouteMatchB(), RouteMatchC()] + /// ``` + static String _generateFullPath(Iterable matches) { + final StringBuffer buffer = StringBuffer(); + bool addsSlash = false; + for (final RouteMatch match in matches + .where((RouteMatch match) => match is! ImperativeRouteMatch)) { + final RouteBase route = match.route; + if (route is GoRoute) { + if (addsSlash) { + buffer.write('/'); + } + buffer.write(route.path); + addsSlash = addsSlash || route.path != '/'; + } + } + return buffer.toString(); + } + + /// Returns true if there are no matches. + bool get isEmpty => matches.isEmpty; + + /// Returns true if there are matches. + bool get isNotEmpty => matches.isNotEmpty; + + /// Returns a new instance of RouteMatchList with the input `match` pushed + /// onto the current instance. + RouteMatchList push(ImperativeRouteMatch match) { + // Imperative route match doesn't change the uri and path parameters. + return _copyWith(matches: [...matches, match]); + } + + /// Returns a new instance of RouteMatchList with the input `match` removed + /// from the current instance. + RouteMatchList remove(RouteMatch match) { + final List newMatches = matches.toList(); + final int index = newMatches.indexOf(match); + assert(index != -1); + newMatches.removeRange(index, newMatches.length); + + // Also pop ShellRoutes when there are no subsequent route matches + while (newMatches.isNotEmpty && newMatches.last.route is ShellRouteBase) { + newMatches.removeLast(); + } + // Removing ImperativeRouteMatch should not change uri and pathParameters. + if (match is ImperativeRouteMatch) { + return _copyWith(matches: newMatches); + } + + final String fullPath = _generateFullPath( + newMatches.where((RouteMatch match) => match is! ImperativeRouteMatch)); + // Need to remove path parameters that are no longer in the fullPath. + final List newParameters = []; + patternToRegExp(fullPath, newParameters); + final Set validParameters = newParameters.toSet(); + final Map newPathParameters = + Map.fromEntries( + pathParameters.entries.where((MapEntry value) => + validParameters.contains(value.key)), + ); + final Uri newUri = + uri.replace(path: patternToPath(fullPath, newPathParameters)); + return _copyWith( + matches: newMatches, + uri: newUri, + pathParameters: newPathParameters, + ); + } + + /// The last matching route. + RouteMatch get last => matches.last; + + /// Returns true if the current match intends to display an error screen. + bool get isError => error != null; + + /// The routes for each of the matches. + List get routes => matches.map((RouteMatch e) => e.route).toList(); + + RouteMatchList _copyWith({ + List? matches, + Uri? uri, + Map? pathParameters, + }) { + return RouteMatchList( + matches: matches ?? this.matches, + uri: uri ?? this.uri, + extra: extra, + error: error, + pathParameters: pathParameters ?? this.pathParameters); + } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is RouteMatchList && + uri == other.uri && + extra == other.extra && + error == other.error && + const ListEquality().equals(matches, other.matches) && + const MapEquality() + .equals(pathParameters, other.pathParameters); + } + + @override + int get hashCode { + return Object.hash( + Object.hashAll(matches), + uri, + extra, + error, + Object.hashAllUnordered( + pathParameters.entries.map((MapEntry entry) => + Object.hash(entry.key, entry.value)), + ), + ); + } + + @override + String toString() { + return '${objectRuntimeType(this, 'RouteMatchList')}($fullPath)'; + } +} + +/// Handles encoding and decoding of [RouteMatchList] objects to a format +/// suitable for using with [StandardMessageCodec]. +/// +/// The primary use of this class is for state restoration. +class RouteMatchListCodec extends Codec> { + /// Creates a new [RouteMatchListCodec] object. + RouteMatchListCodec(RouteConfiguration configuration) + : decoder = _RouteMatchListDecoder(configuration); + + static const String _locationKey = 'location'; + static const String _extraKey = 'state'; + static const String _imperativeMatchesKey = 'imperativeMatches'; + static const String _pageKey = 'pageKey'; + + @override + final Converter> encoder = + const _RouteMatchListEncoder(); + + @override + final Converter, RouteMatchList> decoder; +} + +class _RouteMatchListEncoder + extends Converter> { + const _RouteMatchListEncoder(); + @override + Map convert(RouteMatchList input) { + final List> imperativeMatches = input.matches + .whereType() + .map((ImperativeRouteMatch e) => _toPrimitives( + e.matches.uri.toString(), e.matches.extra, + pageKey: e.pageKey.value)) + .toList(); + + return _toPrimitives(input.uri.toString(), input.extra, + imperativeMatches: imperativeMatches); + } + + static Map _toPrimitives(String location, Object? extra, + {List>? imperativeMatches, String? pageKey}) { + String? encodedExtra; + try { + encodedExtra = json.encoder.convert(extra); + } on JsonUnsupportedObjectError {/* give up if not serializable */} + return { + RouteMatchListCodec._locationKey: location, + if (encodedExtra != null) RouteMatchListCodec._extraKey: encodedExtra, + if (imperativeMatches != null) + RouteMatchListCodec._imperativeMatchesKey: imperativeMatches, + if (pageKey != null) RouteMatchListCodec._pageKey: pageKey, + }; + } +} + +class _RouteMatchListDecoder + extends Converter, RouteMatchList> { + _RouteMatchListDecoder(this.configuration); + + final RouteConfiguration configuration; + + @override + RouteMatchList convert(Map input) { + final String rootLocation = + input[RouteMatchListCodec._locationKey]! as String; + final String? encodedExtra = + input[RouteMatchListCodec._extraKey] as String?; + final Object? extra; + if (encodedExtra != null) { + extra = json.decoder.convert(encodedExtra); + } else { + extra = null; + } + RouteMatchList matchList = + configuration.findMatch(rootLocation, extra: extra); + + final List? imperativeMatches = + input[RouteMatchListCodec._imperativeMatchesKey] as List?; + if (imperativeMatches != null) { + for (final Map encodedImperativeMatch + in imperativeMatches.whereType>()) { + final RouteMatchList imperativeMatchList = + convert(encodedImperativeMatch); + final ValueKey pageKey = ValueKey( + encodedImperativeMatch[RouteMatchListCodec._pageKey]! as String); + final ImperativeRouteMatch imperativeMatch = ImperativeRouteMatch( + pageKey: pageKey, + // TODO(chunhtai): Figure out a way to preserve future. + // https://github.com/flutter/flutter/issues/128122. + completer: Completer(), + matches: imperativeMatchList, + ); + matchList = matchList.push(imperativeMatch); + } + } + + return matchList; + } +} diff --git a/packages/go_router/lib/src/matching.dart b/packages/go_router/lib/src/matching.dart deleted file mode 100644 index b809ccae5c..0000000000 --- a/packages/go_router/lib/src/matching.dart +++ /dev/null @@ -1,479 +0,0 @@ -// 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:collection/collection.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; - -import 'configuration.dart'; -import 'match.dart'; -import 'path_utils.dart'; - -/// Converts a location into a list of [RouteMatch] objects. -class RouteMatcher { - /// [RouteMatcher] constructor. - RouteMatcher(this.configuration); - - /// The route configuration. - final RouteConfiguration configuration; - - /// Finds the routes that matched the given URL. - RouteMatchList findMatch(String location, {Object? extra}) { - final Uri uri = Uri.parse(canonicalUri(location)); - - final Map pathParameters = {}; - final List matches = - _getLocRouteMatches(uri, extra, pathParameters); - return RouteMatchList( - matches: matches, uri: uri, pathParameters: pathParameters); - } - - List _getLocRouteMatches( - Uri uri, Object? extra, Map pathParameters) { - final List? result = _getLocRouteRecursively( - location: uri.path, - remainingLocation: uri.path, - routes: configuration.routes, - matchedLocation: '', - pathParameters: pathParameters, - extra: extra, - ); - - if (result == null) { - throw MatcherError('no routes for location', uri.toString()); - } - - return result; - } -} - -/// The list of [RouteMatch] objects. -/// -/// This corresponds to the GoRouter's history. -@immutable -class RouteMatchList { - /// RouteMatchList constructor. - RouteMatchList({ - required this.matches, - required this.uri, - required this.pathParameters, - }) : fullPath = _generateFullPath(matches); - - /// Constructs an empty matches object. - static RouteMatchList empty = RouteMatchList( - matches: const [], - uri: Uri(), - pathParameters: const {}); - - /// The route matches. - final List matches; - - /// Parameters for the matched route, URI-encoded. - /// - /// The parameters only reflects [RouteMatch]s that are not - /// [ImperativeRouteMatch]. - final Map pathParameters; - - /// The uri of the current match. - /// - /// This uri only reflects [RouteMatch]s that are not [ImperativeRouteMatch]. - final Uri uri; - - /// the full path pattern that matches the uri. - /// - /// For example: - /// - /// ```dart - /// '/family/:fid/person/:pid' - /// ``` - final String fullPath; - - /// Generates the full path (ex: `'/family/:fid/person/:pid'`) of a list of - /// [RouteMatch]. - /// - /// This method ignores [ImperativeRouteMatch]s in the `matches`, as they - /// don't contribute to the path. - /// - /// This methods considers that [matches]'s elements verify the go route - /// structure given to `GoRouter`. For example, if the routes structure is - /// - /// ```dart - /// GoRoute( - /// path: '/a', - /// routes: [ - /// GoRoute( - /// path: 'b', - /// routes: [ - /// GoRoute( - /// path: 'c', - /// ), - /// ], - /// ), - /// ], - /// ), - /// ``` - /// - /// The [matches] must be the in same order of how GoRoutes are matched. - /// - /// ```dart - /// [RouteMatchA(), RouteMatchB(), RouteMatchC()] - /// ``` - static String _generateFullPath(Iterable matches) { - final StringBuffer buffer = StringBuffer(); - bool addsSlash = false; - for (final RouteMatch match in matches - .where((RouteMatch match) => match is! ImperativeRouteMatch)) { - final RouteBase route = match.route; - if (route is GoRoute) { - if (addsSlash) { - buffer.write('/'); - } - buffer.write(route.path); - addsSlash = addsSlash || route.path != '/'; - } - } - return buffer.toString(); - } - - /// Returns true if there are no matches. - bool get isEmpty => matches.isEmpty; - - /// Returns true if there are matches. - bool get isNotEmpty => matches.isNotEmpty; - - /// Returns a new instance of RouteMatchList with the input `match` pushed - /// onto the current instance. - RouteMatchList push(ImperativeRouteMatch match) { - // Imperative route match doesn't change the uri and path parameters. - return _copyWith(matches: [...matches, match]); - } - - /// Returns a new instance of RouteMatchList with the input `match` removed - /// from the current instance. - RouteMatchList remove(RouteMatch match) { - final List newMatches = matches.toList(); - final int index = newMatches.indexOf(match); - assert(index != -1); - newMatches.removeRange(index, newMatches.length); - - // Also pop ShellRoutes when there are no subsequent route matches - while (newMatches.isNotEmpty && newMatches.last.route is ShellRouteBase) { - newMatches.removeLast(); - } - // Removing ImperativeRouteMatch should not change uri and pathParameters. - if (match is ImperativeRouteMatch) { - return _copyWith(matches: newMatches); - } - - final String fullPath = _generateFullPath( - newMatches.where((RouteMatch match) => match is! ImperativeRouteMatch)); - // Need to remove path parameters that are no longer in the fullPath. - final List newParameters = []; - patternToRegExp(fullPath, newParameters); - final Set validParameters = newParameters.toSet(); - final Map newPathParameters = - Map.fromEntries( - pathParameters.entries.where((MapEntry value) => - validParameters.contains(value.key)), - ); - final Uri newUri = - uri.replace(path: patternToPath(fullPath, newPathParameters)); - return _copyWith( - matches: newMatches, - uri: newUri, - pathParameters: newPathParameters, - ); - } - - /// An optional object provided by the app during navigation. - Object? get extra => matches.isEmpty ? null : matches.last.extra; - - /// The last matching route. - RouteMatch get last => matches.last; - - /// Returns true if the current match intends to display an error screen. - bool get isError => error != null; - - /// Returns the error that this match intends to display. - Exception? get error => matches.firstOrNull?.error; - - /// The routes for each of the matches. - List get routes => matches.map((RouteMatch e) => e.route).toList(); - - RouteMatchList _copyWith({ - List? matches, - Uri? uri, - Map? pathParameters, - }) { - return RouteMatchList( - matches: matches ?? this.matches, - uri: uri ?? this.uri, - pathParameters: pathParameters ?? this.pathParameters); - } - - @override - bool operator ==(Object other) { - if (other.runtimeType != runtimeType) { - return false; - } - return other is RouteMatchList && - const ListEquality().equals(matches, other.matches) && - uri == other.uri && - const MapEquality() - .equals(pathParameters, other.pathParameters); - } - - @override - int get hashCode { - return Object.hash( - Object.hashAll(matches), - uri, - Object.hashAllUnordered( - pathParameters.entries.map((MapEntry entry) => - Object.hash(entry.key, entry.value)), - ), - ); - } - - @override - String toString() { - return '${objectRuntimeType(this, 'RouteMatchList')}($fullPath)'; - } - - /// Returns a pre-parsed [RouteInformation], containing a reference to this - /// match list. - RouteInformation toPreParsedRouteInformation() { - return RouteInformation( - // TODO(tolo): remove this ignore and migrate the code - // https://github.com/flutter/flutter/issues/124045. - // ignore: deprecated_member_use - location: uri.toString(), - state: this, - ); - } - - /// Attempts to extract a pre-parsed match list from the provided - /// [RouteInformation]. - static RouteMatchList? fromPreParsedRouteInformation( - RouteInformation routeInformation) { - if (routeInformation.state is RouteMatchList) { - return routeInformation.state! as RouteMatchList; - } - return null; - } -} - -/// Handles encoding and decoding of [RouteMatchList] objects to a format -/// suitable for using with [StandardMessageCodec]. -/// -/// The primary use of this class is for state restoration. -class RouteMatchListCodec { - /// Creates a new [RouteMatchListCodec] object. - RouteMatchListCodec(this._matcher); - - static const String _encodedDataKey = 'go_router/encoded_route_match_list'; - static const String _locationKey = 'location'; - static const String _stateKey = 'state'; - static const String _imperativeMatchesKey = 'imperativeMatches'; - static const String _pageKey = 'pageKey'; - - final RouteMatcher _matcher; - - /// Encodes the provided [RouteMatchList]. - Object? encodeMatchList(RouteMatchList matchlist) { - if (matchlist.isEmpty) { - return null; - } - final List> imperativeMatches = matchlist.matches - .whereType>() - .map((ImperativeRouteMatch e) => _toPrimitives( - e.matches.uri.toString(), e.extra, - pageKey: e.pageKey.value)) - .toList(); - - return { - _encodedDataKey: _toPrimitives( - matchlist.uri.toString(), matchlist.matches.first.extra, - imperativeMatches: imperativeMatches), - }; - } - - static Map _toPrimitives(String location, Object? state, - {List? imperativeMatches, String? pageKey}) { - return { - _locationKey: location, - _stateKey: state, - if (imperativeMatches != null) _imperativeMatchesKey: imperativeMatches, - if (pageKey != null) _pageKey: pageKey, - }; - } - - /// Attempts to decode the provided object into a [RouteMatchList]. - RouteMatchList? decodeMatchList(Object? object) { - if (object is Map && object[_encodedDataKey] is Map) { - final Map data = - object[_encodedDataKey] as Map; - final Object? rootLocation = data[_locationKey]; - if (rootLocation is! String) { - return null; - } - final RouteMatchList matchList = - _matcher.findMatch(rootLocation, extra: data[_stateKey]); - - final List? imperativeMatches = - data[_imperativeMatchesKey] as List?; - if (imperativeMatches != null) { - for (int i = 0; i < imperativeMatches.length; i++) { - final Object? match = imperativeMatches[i]; - if (match is! Map || - match[_locationKey] is! String || - match[_pageKey] is! String) { - continue; - } - final ValueKey pageKey = - ValueKey(match[_pageKey] as String); - final RouteMatchList imperativeMatchList = _matcher.findMatch( - match[_locationKey] as String, - extra: match[_stateKey]); - final ImperativeRouteMatch imperativeMatch = - ImperativeRouteMatch( - pageKey: pageKey, - matches: imperativeMatchList, - ); - matchList.push(imperativeMatch); - } - } - - return matchList; - } - return null; - } -} - -/// An error that occurred during matching. -class MatcherError extends Error { - /// Constructs a [MatcherError]. - MatcherError(String message, this.location) : message = '$message: $location'; - - /// The error message. - final String message; - - /// The location that failed to match. - final String location; - - @override - String toString() { - return message; - } -} - -/// Returns the list of `RouteMatch` corresponding to the given `loc`. -/// -/// For example, for a given `loc` `/a/b/c/d`, this function will return the -/// list of [RouteBase] `[GoRouteA(), GoRouterB(), GoRouteC(), GoRouterD()]`. -/// -/// - [location] is the complete URL to match (without the query parameters). For -/// example, for the URL `/a/b?c=0`, [location] will be `/a/b`. -/// - [remainingLocation] is the remaining part of the URL to match while [matchedLocation] -/// is the part of the URL that has already been matched. For examples, for -/// the URL `/a/b/c/d`, at some point, [remainingLocation] would be `/c/d` and -/// [matchedLocation] will be `/a/b`. -/// - [routes] are the possible [RouteBase] to match to [remainingLocation]. -List? _getLocRouteRecursively({ - required String location, - required String remainingLocation, - required String matchedLocation, - required List routes, - required Map pathParameters, - required Object? extra, -}) { - List? result; - late Map subPathParameters; - // find the set of matches at this level of the tree - for (final RouteBase route in routes) { - subPathParameters = {}; - - final RouteMatch? match = RouteMatch.match( - route: route, - remainingLocation: remainingLocation, - matchedLocation: matchedLocation, - pathParameters: subPathParameters, - extra: extra, - ); - - if (match == null) { - continue; - } - - if (match.route is GoRoute && - match.matchedLocation.toLowerCase() == location.toLowerCase()) { - // If it is a complete match, then return the matched route - // NOTE: need a lower case match because matchedLocation is canonicalized to match - // the path case whereas the location can be of any case and still match - result = [match]; - } else if (route.routes.isEmpty) { - // If it is partial match but no sub-routes, bail. - continue; - } else { - // Otherwise, recurse - final String childRestLoc; - final String newParentSubLoc; - if (match.route is ShellRouteBase) { - childRestLoc = remainingLocation; - newParentSubLoc = matchedLocation; - } else { - assert(location.startsWith(match.matchedLocation)); - assert(remainingLocation.isNotEmpty); - - childRestLoc = location.substring(match.matchedLocation.length + - (match.matchedLocation == '/' ? 0 : 1)); - newParentSubLoc = match.matchedLocation; - } - - final List? subRouteMatch = _getLocRouteRecursively( - location: location, - remainingLocation: childRestLoc, - matchedLocation: newParentSubLoc, - routes: route.routes, - pathParameters: subPathParameters, - extra: extra, - ); - - // If there's no sub-route matches, there is no match for this location - if (subRouteMatch == null) { - continue; - } - result = [match, ...subRouteMatch]; - } - // Should only reach here if there is a match. - break; - } - if (result != null) { - pathParameters.addAll(subPathParameters); - } - return result; -} - -/// The match used when there is an error during parsing. -RouteMatchList errorScreen(Uri uri, String errorMessage) { - final Exception error = Exception(errorMessage); - return RouteMatchList( - matches: [ - RouteMatch( - matchedLocation: uri.path, - extra: null, - error: error, - route: GoRoute( - path: uri.toString(), - pageBuilder: (BuildContext context, GoRouterState state) { - throw UnimplementedError(); - }, - ), - pageKey: const ValueKey('error'), - ), - ], - uri: uri, - pathParameters: const {}, - ); -} diff --git a/packages/go_router/lib/src/misc/errors.dart b/packages/go_router/lib/src/misc/errors.dart index 7aca0d8995..045e2ea04d 100644 --- a/packages/go_router/lib/src/misc/errors.dart +++ b/packages/go_router/lib/src/misc/errors.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import '../match.dart'; + /// Thrown when [GoRouter] is used incorrectly. class GoError extends Error { /// Constructs a [GoError] @@ -13,3 +15,24 @@ class GoError extends Error { @override String toString() => 'GoError: $message'; } + +/// A configuration error detected while processing redirects. +class RedirectionError extends Error implements UnsupportedError { + /// RedirectionError constructor. + RedirectionError(this.message, this.matches, this.location); + + /// The matches that were found while processing redirects. + final List matches; + + @override + final String message; + + /// The location that was originally navigated to, before redirection began. + final Uri location; + + @override + String toString() => '${super.toString()} ${[ + ...matches + .map((RouteMatchList routeMatches) => routeMatches.uri.toString()), + ].join(' => ')}'; +} diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index 78a7ee0624..17902413cf 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -3,17 +3,16 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import '../go_router.dart'; import 'configuration.dart'; import 'information_provider.dart'; import 'logging.dart'; import 'match.dart'; -import 'matching.dart'; -import 'path_utils.dart'; -import 'redirection.dart'; /// Converts between incoming URLs and a [RouteMatchList] using [RouteMatcher]. /// Also performs redirection using [RouteRedirector]. @@ -21,27 +20,14 @@ class GoRouteInformationParser extends RouteInformationParser { /// Creates a [GoRouteInformationParser]. GoRouteInformationParser({ required this.configuration, - this.debugRequireGoRouteInformationProvider = false, - }) : matcher = RouteMatcher(configuration), - redirector = redirect; + }) : _routeMatchListCodec = RouteMatchListCodec(configuration); - /// The route configuration for the app. + /// The route configuration used for parsing [RouteInformation]s. final RouteConfiguration configuration; - /// The route matcher. - final RouteMatcher matcher; + final RouteMatchListCodec _routeMatchListCodec; - /// The route redirector. - final RouteRedirector redirector; - - /// 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 not in use. - /// - /// Defaults to false. - final bool debugRequireGoRouteInformationProvider; + final Random _random = Random(); /// The future of current route parsing. /// @@ -55,64 +41,42 @@ class GoRouteInformationParser extends RouteInformationParser { RouteInformation routeInformation, BuildContext context, ) { + assert(routeInformation.state != null); + final Object state = routeInformation.state!; + + if (state is! RouteInformationState) { + // This is a result of browser backward/forward button or state + // restoration. In this case, the route match list is already stored in + // the state. + final RouteMatchList matchList = + _routeMatchListCodec.decode(state as Map); + return debugParserFuture = _redirect(context, matchList); + } + late final RouteMatchList initialMatches; - try { - final RouteMatchList? preParsedMatchList = - RouteMatchList.fromPreParsedRouteInformation(routeInformation); - if (preParsedMatchList != null) { - initialMatches = preParsedMatchList; - } else { + initialMatches = // TODO(chunhtai): remove this ignore and migrate the code // https://github.com/flutter/flutter/issues/124045. // ignore: deprecated_member_use, unnecessary_non_null_assertion - initialMatches = matcher.findMatch(routeInformation.location!, - extra: routeInformation.state); - } - } on MatcherError { + configuration.findMatch(routeInformation.location!, extra: state.extra); + if (initialMatches.isError) { // TODO(chunhtai): remove this ignore and migrate the code // https://github.com/flutter/flutter/issues/124045. // ignore: deprecated_member_use log.info('No initial matches: ${routeInformation.location}'); - - // If there is a matching error for the initial location, we should - // still try to process the top-level redirects. - initialMatches = RouteMatchList( - matches: const [], - // TODO(chunhtai): remove this ignore and migrate the code - // https://github.com/flutter/flutter/issues/124045. - // ignore: deprecated_member_use, unnecessary_non_null_assertion - uri: Uri.parse(canonicalUri(routeInformation.location!)), - pathParameters: const {}, - ); - } - Future processRedirectorResult(RouteMatchList matches) { - if (matches.isEmpty) { - return SynchronousFuture(errorScreen( - // TODO(chunhtai): remove this ignore and migrate the code - // https://github.com/flutter/flutter/issues/124045. - // ignore: deprecated_member_use, unnecessary_non_null_assertion - Uri.parse(routeInformation.location!), - // TODO(chunhtai): remove this ignore and migrate the code - // https://github.com/flutter/flutter/issues/124045. - // ignore: deprecated_member_use, unnecessary_non_null_assertion - MatcherError('no routes for location', routeInformation.location!) - .toString())); - } - return SynchronousFuture(matches); } - final FutureOr redirectorResult = redirector( + return debugParserFuture = _redirect( context, - SynchronousFuture(initialMatches), - configuration, - matcher, - extra: routeInformation.state, - ); - if (redirectorResult is RouteMatchList) { - return processRedirectorResult(redirectorResult); - } - - return debugParserFuture = redirectorResult.then(processRedirectorResult); + initialMatches, + ).then((RouteMatchList matchList) { + return _updateRouteMatchList( + matchList, + baseRouteMatchList: state.baseRouteMatchList, + completer: state.completer, + type: state.type, + ); + }); } @override @@ -128,16 +92,70 @@ class GoRouteInformationParser extends RouteInformationParser { if (configuration.isEmpty) { return null; } - if (configuration.matches.last is ImperativeRouteMatch) { + if (GoRouter.optionURLReflectsImperativeAPIs && + configuration.matches.last is ImperativeRouteMatch) { configuration = - (configuration.matches.last as ImperativeRouteMatch).matches; + (configuration.matches.last as ImperativeRouteMatch).matches; } return RouteInformation( // TODO(chunhtai): remove this ignore and migrate the code // https://github.com/flutter/flutter/issues/124045. // ignore: deprecated_member_use location: configuration.uri.toString(), - state: configuration.extra, + state: _routeMatchListCodec.encode(configuration), ); } + + Future _redirect( + BuildContext context, RouteMatchList routeMatch) { + final FutureOr redirectedFuture = configuration + .redirect(context, routeMatch, redirectHistory: []); + if (redirectedFuture is RouteMatchList) { + return SynchronousFuture(redirectedFuture); + } + return redirectedFuture; + } + + RouteMatchList _updateRouteMatchList( + RouteMatchList newMatchList, { + required RouteMatchList? baseRouteMatchList, + required Completer? completer, + required NavigatingType type, + }) { + switch (type) { + case NavigatingType.push: + return baseRouteMatchList!.push( + ImperativeRouteMatch( + pageKey: _getUniqueValueKey(), + completer: completer!, + matches: newMatchList, + ), + ); + case NavigatingType.pushReplacement: + final RouteMatch routeMatch = baseRouteMatchList!.last; + return baseRouteMatchList.remove(routeMatch).push( + ImperativeRouteMatch( + pageKey: _getUniqueValueKey(), + completer: completer!, + matches: newMatchList, + ), + ); + case NavigatingType.replace: + final RouteMatch routeMatch = baseRouteMatchList!.last; + return baseRouteMatchList.remove(routeMatch).push( + ImperativeRouteMatch( + pageKey: routeMatch.pageKey, + completer: completer!, + matches: newMatchList, + ), + ); + case NavigatingType.go: + return newMatchList; + } + } + + ValueKey _getUniqueValueKey() { + return ValueKey(String.fromCharCodes( + List.generate(32, (_) => _random.nextInt(33) + 89))); + } } diff --git a/packages/go_router/lib/src/redirection.dart b/packages/go_router/lib/src/redirection.dart deleted file mode 100644 index 3ac4a5b9e6..0000000000 --- a/packages/go_router/lib/src/redirection.dart +++ /dev/null @@ -1,237 +0,0 @@ -// 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 'dart:async'; - -import 'package:flutter/cupertino.dart'; - -import 'configuration.dart'; -import 'logging.dart'; -import 'match.dart'; -import 'matching.dart'; - -/// A GoRouter redirector function. -typedef RouteRedirector = FutureOr Function( - BuildContext, FutureOr, RouteConfiguration, RouteMatcher, - {List? redirectHistory, Object? extra}); - -/// Processes redirects by returning a new [RouteMatchList] representing the new -/// location. -FutureOr redirect( - BuildContext context, - FutureOr prevMatchListFuture, - RouteConfiguration configuration, - RouteMatcher matcher, - {List? redirectHistory, - Object? extra}) { - FutureOr processRedirect(RouteMatchList prevMatchList) { - final String prevLocation = prevMatchList.uri.toString(); - FutureOr processTopLevelRedirect( - String? topRedirectLocation) { - if (topRedirectLocation != null && topRedirectLocation != prevLocation) { - final RouteMatchList newMatch = _getNewMatches( - topRedirectLocation, - prevMatchList.uri, - configuration, - matcher, - redirectHistory!, - ); - if (newMatch.isError) { - return newMatch; - } - return redirect( - context, - newMatch, - configuration, - matcher, - redirectHistory: redirectHistory, - extra: extra, - ); - } - - FutureOr processRouteLevelRedirect( - String? routeRedirectLocation) { - if (routeRedirectLocation != null && - routeRedirectLocation != prevLocation) { - final RouteMatchList newMatch = _getNewMatches( - routeRedirectLocation, - prevMatchList.uri, - configuration, - matcher, - redirectHistory!, - ); - - if (newMatch.isError) { - return newMatch; - } - return redirect( - context, - newMatch, - configuration, - matcher, - redirectHistory: redirectHistory, - extra: extra, - ); - } - return prevMatchList; - } - - final FutureOr routeLevelRedirectResult = - _getRouteLevelRedirect(context, configuration, prevMatchList, 0); - if (routeLevelRedirectResult is String?) { - return processRouteLevelRedirect(routeLevelRedirectResult); - } - return routeLevelRedirectResult - .then(processRouteLevelRedirect); - } - - redirectHistory ??= [prevMatchList]; - // Check for top-level redirect - final FutureOr topRedirectResult = configuration.topRedirect( - context, - GoRouterState( - configuration, - location: prevLocation, - name: null, - // No name available at the top level trim the query params off the - // sub-location to match route.redirect - matchedLocation: prevMatchList.uri.path, - queryParameters: prevMatchList.uri.queryParameters, - queryParametersAll: prevMatchList.uri.queryParametersAll, - extra: extra, - pageKey: const ValueKey('topLevel'), - ), - ); - - if (topRedirectResult is String?) { - return processTopLevelRedirect(topRedirectResult); - } - return topRedirectResult.then(processTopLevelRedirect); - } - - if (prevMatchListFuture is RouteMatchList) { - return processRedirect(prevMatchListFuture); - } - return prevMatchListFuture.then(processRedirect); -} - -FutureOr _getRouteLevelRedirect( - BuildContext context, - RouteConfiguration configuration, - RouteMatchList matchList, - int currentCheckIndex, -) { - if (currentCheckIndex >= matchList.matches.length) { - return null; - } - final RouteMatch match = matchList.matches[currentCheckIndex]; - FutureOr processRouteRedirect(String? newLocation) => - newLocation ?? - _getRouteLevelRedirect( - context, configuration, matchList, currentCheckIndex + 1); - final RouteBase route = match.route; - FutureOr routeRedirectResult; - if (route is GoRoute && route.redirect != null) { - routeRedirectResult = route.redirect!( - context, - GoRouterState( - configuration, - location: matchList.uri.toString(), - matchedLocation: match.matchedLocation, - name: route.name, - path: route.path, - fullPath: matchList.fullPath, - extra: match.extra, - pathParameters: matchList.pathParameters, - queryParameters: matchList.uri.queryParameters, - queryParametersAll: matchList.uri.queryParametersAll, - pageKey: match.pageKey, - ), - ); - } - if (routeRedirectResult is String?) { - return processRouteRedirect(routeRedirectResult); - } - return routeRedirectResult.then(processRouteRedirect); -} - -RouteMatchList _getNewMatches( - String newLocation, - Uri previousLocation, - RouteConfiguration configuration, - RouteMatcher matcher, - List redirectHistory, -) { - try { - final RouteMatchList newMatch = matcher.findMatch(newLocation); - _addRedirect(redirectHistory, newMatch, previousLocation, - configuration.redirectLimit); - return newMatch; - } on RedirectionError catch (e) { - return _handleRedirectionError(e); - } on MatcherError catch (e) { - return _handleMatcherError(e); - } -} - -RouteMatchList _handleMatcherError(MatcherError error) { - // The RouteRedirector uses the matcher to find the match, so a match - // exception can happen during redirection. For example, the redirector - // redirects from `/a` to `/b`, it needs to get the matches for `/b`. - log.info('Match error: ${error.message}'); - final Uri uri = Uri.parse(error.location); - return errorScreen(uri, error.message); -} - -RouteMatchList _handleRedirectionError(RedirectionError error) { - log.info('Redirection error: ${error.message}'); - final Uri uri = error.location; - return errorScreen(uri, error.message); -} - -/// A configuration error detected while processing redirects. -class RedirectionError extends Error implements UnsupportedError { - /// RedirectionError constructor. - RedirectionError(this.message, this.matches, this.location); - - /// The matches that were found while processing redirects. - final List matches; - - @override - final String message; - - /// The location that was originally navigated to, before redirection began. - final Uri location; - - @override - String toString() => '${super.toString()} ${[ - ...matches - .map((RouteMatchList routeMatches) => routeMatches.uri.toString()), - ].join(' => ')}'; -} - -/// Adds the redirect to [redirects] if it is valid. -void _addRedirect(List redirects, RouteMatchList newMatch, - Uri prevLocation, int redirectLimit) { - // Verify that the redirect can be parsed and is not already - // in the list of redirects - assert(() { - if (redirects.contains(newMatch)) { - throw RedirectionError('redirect loop detected', - [...redirects, newMatch], prevLocation); - } - if (redirects.length > redirectLimit) { - throw RedirectionError('too many redirects', - [...redirects, newMatch], prevLocation); - } - return true; - }()); - - redirects.add(newMatch); - - assert(() { - log.info('redirecting to $newMatch'); - return true; - }()); -} diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart index 80a885ed8a..8983a7894e 100644 --- a/packages/go_router/lib/src/route.dart +++ b/packages/go_router/lib/src/route.dart @@ -7,8 +7,8 @@ import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; import '../go_router.dart'; +import 'configuration.dart'; import 'match.dart'; -import 'matching.dart'; import 'path_utils.dart'; import 'typedefs.dart'; @@ -928,6 +928,8 @@ class StatefulNavigationShell extends StatefulWidget { /// current [StatefulShellBranch]. final ShellRouteContext shellRouteContext; + final GoRouter _router; + /// The builder for a custom container for shell route Navigators. final ShellNavigationContainerBuilder containerBuilder; @@ -936,8 +938,6 @@ class StatefulNavigationShell extends StatefulWidget { /// Corresponds to the index in the branches field of [StatefulShellRoute]. final int currentIndex; - final GoRouter _router; - /// The associated [StatefulShellRoute]. StatefulShellRoute get route => shellRouteContext.route as StatefulShellRoute; @@ -948,6 +948,8 @@ class StatefulNavigationShell extends StatefulWidget { /// [StatefulShellRoute]. If the branch has not been visited before, or if /// initialLocation is true, this method will navigate to initial location of /// the branch (see [StatefulShellBranch.initialLocation]). + // TODO(chunhtai): figure out a way to avoid putting navigation API in widget + // class. void goBranch(int index, {bool initialLocation = false}) { final StatefulShellRoute route = shellRouteContext.route as StatefulShellRoute; @@ -977,7 +979,7 @@ class StatefulNavigationShell extends StatefulWidget { /// Recursively traverses the routes of the provided StackedShellBranch to /// find the first GoRoute, from which a full path will be derived. final GoRoute route = branch.defaultRoute!; - return _router.locationForRoute(route)!; + return _router.configuration.locationForRoute(route)!; } } @@ -1019,7 +1021,6 @@ class StatefulNavigationShellState extends State StatefulShellRoute get route => widget.route; GoRouter get _router => widget._router; - RouteMatcher get _matcher => _router.routeInformationParser.matcher; final Map _branchLocations = {}; @@ -1040,7 +1041,7 @@ class StatefulNavigationShellState extends State [bool register = true]) { return _branchLocations.putIfAbsent(branch, () { final _RestorableRouteMatchList branchLocation = - _RestorableRouteMatchList(_matcher); + _RestorableRouteMatchList(_router.configuration); if (register) { registerForRestoration( branchLocation, _branchLocationRestorationScopeId(branch)); @@ -1070,6 +1071,7 @@ class StatefulNavigationShellState extends State if (index > 0) { final List matches = matchList.matches.sublist(0, index); return RouteMatchList( + extra: matchList.extra, matches: matches, uri: Uri.parse(matches.last.matchedLocation), pathParameters: matchList.pathParameters, @@ -1114,15 +1116,10 @@ class StatefulNavigationShellState extends State /// the branch (see [StatefulShellBranch.initialLocation]). void goBranch(int index, {bool initialLocation = false}) { assert(index >= 0 && index < route.branches.length); - final RouteMatchList? matchlist = + final RouteMatchList? matchList = initialLocation ? null : _matchListForBranch(index); - if (matchlist != null && matchlist.isNotEmpty) { - final RouteInformation preParsed = - matchlist.toPreParsedRouteInformation(); - // TODO(tolo): remove this ignore and migrate the code - // https://github.com/flutter/flutter/issues/124045. - // ignore: deprecated_member_use, unnecessary_non_null_assertion - _router.go(preParsed.location!, extra: preParsed.state); + if (matchList != null && matchList.isNotEmpty) { + _router.restore(matchList); } else { _router.go(widget._effectiveInitialBranchLocation(index)); } @@ -1169,8 +1166,8 @@ class StatefulNavigationShellState extends State /// [RestorableProperty] for enabling state restoration of [RouteMatchList]s. class _RestorableRouteMatchList extends RestorableProperty { - _RestorableRouteMatchList(RouteMatcher matcher) - : _matchListCodec = RouteMatchListCodec(matcher); + _RestorableRouteMatchList(RouteConfiguration configuration) + : _matchListCodec = RouteMatchListCodec(configuration); final RouteMatchListCodec _matchListCodec; @@ -1193,13 +1190,15 @@ class _RestorableRouteMatchList extends RestorableProperty { @override RouteMatchList fromPrimitives(Object? data) { - return _matchListCodec.decodeMatchList(data) ?? RouteMatchList.empty; + return data == null + ? RouteMatchList.empty + : _matchListCodec.decode(data as Map); } @override Object? toPrimitives() { if (value.isNotEmpty) { - return _matchListCodec.encodeMatchList(value); + return _matchListCodec.encode(value); } return null; } diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index 0e2d59b647..eac57c2255 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -9,7 +9,7 @@ import 'delegate.dart'; import 'information_provider.dart'; import 'logging.dart'; import 'match.dart'; -import 'matching.dart'; +import 'misc/errors.dart'; import 'misc/inherited_router.dart'; import 'parser.dart'; import 'typedefs.dart'; @@ -69,37 +69,39 @@ class GoRouter extends ChangeNotifier implements RouterConfig { assert( initialExtra == null || initialLocation != null, 'initialLocation must be set in order to use initialExtra', - ) { + ), + assert(_debugCheckPath(routes, true)), + assert( + _debugVerifyNoDuplicatePathParameter(routes, {})), + assert(_debugCheckParentNavigatorKeys( + routes, + navigatorKey == null + ? >[] + : >[navigatorKey])) { setLogging(enabled: debugLogDiagnostics); WidgetsFlutterBinding.ensureInitialized(); navigatorKey ??= GlobalKey(); - _routeConfiguration = RouteConfiguration( + configuration = RouteConfiguration( routes: routes, topRedirect: redirect ?? (_, __) => null, redirectLimit: redirectLimit, navigatorKey: navigatorKey, ); - _routeInformationParser = GoRouteInformationParser( - configuration: _routeConfiguration, - debugRequireGoRouteInformationProvider: true, + routeInformationParser = GoRouteInformationParser( + configuration: configuration, ); - _routeInformationProvider = GoRouteInformationProvider( - initialRouteInformation: RouteInformation( - // TODO(chunhtai): remove this ignore and migrate the code - // https://github.com/flutter/flutter/issues/124045. - // ignore: deprecated_member_use - location: _effectiveInitialLocation(initialLocation), - state: initialExtra, - ), + routeInformationProvider = GoRouteInformationProvider( + initialLocation: _effectiveInitialLocation(initialLocation), + initialExtra: initialExtra, refreshListenable: refreshListenable, ); - _routerDelegate = GoRouterDelegate( - configuration: _routeConfiguration, + routerDelegate = GoRouterDelegate( + configuration: configuration, errorPageBuilder: errorPageBuilder, errorBuilder: errorBuilder, routerNeglect: routerNeglect, @@ -112,7 +114,7 @@ class GoRouter extends ChangeNotifier implements RouterConfig { builderWithNav: (BuildContext context, Widget child) => InheritedGoRouter(goRouter: this, child: child), ); - _routerDelegate.addListener(_handleStateMayChange); + routerDelegate.addListener(_handleStateMayChange); assert(() { log.info('setting initial location $initialLocation'); @@ -120,10 +122,125 @@ class GoRouter extends ChangeNotifier implements RouterConfig { }()); } - late final RouteConfiguration _routeConfiguration; - late final GoRouteInformationParser _routeInformationParser; - late final GoRouterDelegate _routerDelegate; - late final GoRouteInformationProvider _routeInformationProvider; + static bool _debugCheckPath(List routes, bool isTopLevel) { + for (final RouteBase route in routes) { + late bool subRouteIsTopLevel; + if (route is GoRoute) { + if (isTopLevel) { + assert(route.path.startsWith('/'), + 'top-level path must start with "/": $route'); + } else { + assert(!route.path.startsWith('/') && !route.path.endsWith('/'), + 'sub-route path may not start or end with /: $route'); + } + subRouteIsTopLevel = false; + } else if (route is ShellRouteBase) { + subRouteIsTopLevel = isTopLevel; + } + _debugCheckPath(route.routes, subRouteIsTopLevel); + } + return true; + } + + // Check that each parentNavigatorKey refers to either a ShellRoute's + // navigatorKey or the root navigator key. + static bool _debugCheckParentNavigatorKeys( + List routes, List> allowedKeys) { + for (final RouteBase route in routes) { + if (route is GoRoute) { + final GlobalKey? parentKey = route.parentNavigatorKey; + if (parentKey != null) { + // Verify that the root navigator or a ShellRoute ancestor has a + // matching navigator key. + assert( + allowedKeys.contains(parentKey), + 'parentNavigatorKey $parentKey must refer to' + " an ancestor ShellRoute's navigatorKey or GoRouter's" + ' navigatorKey'); + + _debugCheckParentNavigatorKeys( + route.routes, + >[ + // Once a parentNavigatorKey is used, only that navigator key + // or keys above it can be used. + ...allowedKeys.sublist(0, allowedKeys.indexOf(parentKey) + 1), + ], + ); + } else { + _debugCheckParentNavigatorKeys( + route.routes, + >[ + ...allowedKeys, + ], + ); + } + } else if (route is ShellRoute) { + _debugCheckParentNavigatorKeys( + route.routes, + >[...allowedKeys..add(route.navigatorKey)], + ); + } else if (route is StatefulShellRoute) { + for (final StatefulShellBranch branch in route.branches) { + assert( + !allowedKeys.contains(branch.navigatorKey), + 'StatefulShellBranch must not reuse an ancestor navigatorKey ' + '(${branch.navigatorKey})'); + + _debugCheckParentNavigatorKeys( + branch.routes, + >[ + ...allowedKeys, + branch.navigatorKey, + ], + ); + } + } + } + return true; + } + + static bool _debugVerifyNoDuplicatePathParameter( + List routes, Map usedPathParams) { + for (final RouteBase route in routes) { + if (route is! GoRoute) { + continue; + } + for (final String pathParam in route.pathParameters) { + if (usedPathParams.containsKey(pathParam)) { + final bool sameRoute = usedPathParams[pathParam] == route; + throw GoError( + "duplicate path parameter, '$pathParam' found in ${sameRoute ? '$route' : '${usedPathParams[pathParam]}, and $route'}"); + } + usedPathParams[pathParam] = route; + } + _debugVerifyNoDuplicatePathParameter(route.routes, usedPathParams); + route.pathParameters.forEach(usedPathParams.remove); + } + return true; + } + + /// Whether the imperative API affects browser URL bar. + /// + /// The Imperative APIs refer to [push], [pushReplacement], or [Replace]. + /// + /// If this option is set to true. The URL bar reflects the top-most [GoRoute] + /// regardless the [RouteBase]s underneath. + /// + /// If this option is set to false. The URL bar reflects the [RouteBase]s + /// in the current state but ignores any [RouteBase]s that are results of + /// imperative API calls. + /// + /// Defaults to false. + /// + /// This option is for backward compatibility. It is strongly suggested + /// against setting this value to true, as the URL of the top-most [GoRoute] + /// is not always deeplink-able. + /// + /// This option only affects web platform. + static bool optionURLReflectsImperativeAPIs = false; + + /// The route configuration used in go_router. + late final RouteConfiguration configuration; @override final BackButtonDispatcher backButtonDispatcher; @@ -131,17 +248,15 @@ class GoRouter extends ChangeNotifier implements RouterConfig { /// The router delegate. Provide this to the MaterialApp or CupertinoApp's /// `.router()` constructor @override - GoRouterDelegate get routerDelegate => _routerDelegate; + late final GoRouterDelegate routerDelegate; /// The route information provider used by [GoRouter]. @override - GoRouteInformationProvider get routeInformationProvider => - _routeInformationProvider; + late final GoRouteInformationProvider routeInformationProvider; /// The route information parser used by [GoRouter]. @override - GoRouteInformationParser get routeInformationParser => - _routeInformationParser; + late final GoRouteInformationParser routeInformationParser; /// Gets the current location. // TODO(chunhtai): deprecates this once go_router_builder is migrated to @@ -150,7 +265,7 @@ class GoRouter extends ChangeNotifier implements RouterConfig { String _location = '/'; /// Returns `true` if there is at least two or more route can be pop. - bool canPop() => _routerDelegate.canPop(); + bool canPop() => routerDelegate.canPop(); void _handleStateMayChange() { final String newLocation; @@ -158,12 +273,12 @@ class GoRouter extends ChangeNotifier implements RouterConfig { routerDelegate.currentConfiguration.matches.last is ImperativeRouteMatch) { newLocation = (routerDelegate.currentConfiguration.matches.last - as ImperativeRouteMatch) + as ImperativeRouteMatch) .matches .uri .toString(); } else { - newLocation = _routerDelegate.currentConfiguration.uri.toString(); + newLocation = routerDelegate.currentConfiguration.uri.toString(); } if (_location != newLocation) { _location = newLocation; @@ -178,31 +293,26 @@ class GoRouter extends ChangeNotifier implements RouterConfig { Map pathParameters = const {}, Map queryParameters = const {}, }) => - _routeInformationParser.configuration.namedLocation( + configuration.namedLocation( name, pathParameters: pathParameters, queryParameters: queryParameters, ); - /// Get the location for the provided route. - /// - /// Builds the absolute path for the route, by concatenating the paths of the - /// route and all its ancestors. - String? locationForRoute(RouteBase route) => - _routeInformationParser.configuration.locationForRoute(route); - /// Navigate to a URI location w/ optional query parameters, e.g. /// `/family/f2/person/p1?color=blue` void go(String location, {Object? extra}) { - assert(() { - log.info('going to $location'); - return true; - }()); - _routeInformationProvider.value = - // TODO(chunhtai): remove this ignore and migrate the code - // https://github.com/flutter/flutter/issues/124045. - // ignore: deprecated_member_use - RouteInformation(location: location, state: extra); + log.info('going to $location'); + routeInformationProvider.go(location, extra: extra); + } + + /// Restore the RouteMatchList + void restore(RouteMatchList matchList) { + log.info('going to ${matchList.uri}'); + routeInformationProvider.restore( + matchList.uri.toString(), + encodedMatchList: RouteMatchListCodec(configuration).encode(matchList), + ); } /// Navigate to a named route w/ optional parameters, e.g. @@ -230,22 +340,12 @@ class GoRouter extends ChangeNotifier implements RouterConfig { /// it as the same page. The page key will be reused. This will preserve the /// state and not run any page animation. Future push(String location, {Object? extra}) async { - assert(() { - log.info('pushing $location'); - return true; - }()); - final RouteMatchList matches = - await _routeInformationParser.parseRouteInformationWithDependencies( - // TODO(chunhtai): remove this ignore and migrate the code - // https://github.com/flutter/flutter/issues/124045. - // ignore: deprecated_member_use - RouteInformation(location: location, state: extra), - // TODO(chunhtai): avoid accessing the context directly through global key. - // https://github.com/flutter/flutter/issues/99112 - _routerDelegate.navigatorKey.currentContext!, + log.info('pushing $location'); + return routeInformationProvider.push( + location, + base: routerDelegate.currentConfiguration, + extra: extra, ); - - return _routerDelegate.push(matches); } /// Push a named route onto the page stack w/ optional parameters, e.g. @@ -271,20 +371,14 @@ class GoRouter extends ChangeNotifier implements RouterConfig { /// * [replace] which replaces the top-most page of the page stack but treats /// it as the same page. The page key will be reused. This will preserve the /// state and not run any page animation. - void pushReplacement(String location, {Object? extra}) { - routeInformationParser - .parseRouteInformationWithDependencies( - // TODO(chunhtai): remove this ignore and migrate the code - // https://github.com/flutter/flutter/issues/124045. - // ignore: deprecated_member_use - RouteInformation(location: location, state: extra), - // TODO(chunhtai): avoid accessing the context directly through global key. - // https://github.com/flutter/flutter/issues/99112 - _routerDelegate.navigatorKey.currentContext!, - ) - .then((RouteMatchList matchList) { - routerDelegate.pushReplacement(matchList); - }); + Future pushReplacement(String location, + {Object? extra}) { + log.info('pushReplacement $location'); + return routeInformationProvider.pushReplacement( + location, + base: routerDelegate.currentConfiguration, + extra: extra, + ); } /// Replaces the top-most page of the page stack with the named route w/ @@ -294,13 +388,13 @@ class GoRouter extends ChangeNotifier implements RouterConfig { /// See also: /// * [goNamed] which navigates a named route. /// * [pushNamed] which pushes a named route onto the page stack. - void pushReplacementNamed( + Future pushReplacementNamed( String name, { Map pathParameters = const {}, Map queryParameters = const {}, Object? extra, }) { - pushReplacement( + return pushReplacement( namedLocation(name, pathParameters: pathParameters, queryParameters: queryParameters), extra: extra, @@ -317,20 +411,13 @@ class GoRouter extends ChangeNotifier implements RouterConfig { /// * [push] which pushes the given location onto the page stack. /// * [pushReplacement] which replaces the top-most page of the page stack but /// always uses a new page key. - void replace(String location, {Object? extra}) { - routeInformationParser - .parseRouteInformationWithDependencies( - // TODO(chunhtai): remove this ignore and migrate the code - // https://github.com/flutter/flutter/issues/124045. - // ignore: deprecated_member_use - RouteInformation(location: location, state: extra), - // TODO(chunhtai): avoid accessing the context directly through global key. - // https://github.com/flutter/flutter/issues/99112 - _routerDelegate.navigatorKey.currentContext!, - ) - .then((RouteMatchList matchList) { - routerDelegate.replace(matchList); - }); + Future replace(String location, {Object? extra}) { + log.info('replace $location'); + return routeInformationProvider.replace( + location, + base: routerDelegate.currentConfiguration, + extra: extra, + ); } /// Replaces the top-most page with the named route and optional parameters, @@ -344,13 +431,13 @@ class GoRouter extends ChangeNotifier implements RouterConfig { /// * [pushNamed] which pushes the given location onto the page stack. /// * [pushReplacementNamed] which replaces the top-most page of the page /// stack but always uses a new page key. - void replaceNamed( + Future replaceNamed( String name, { Map pathParameters = const {}, Map queryParameters = const {}, Object? extra, }) { - replace( + return replace( namedLocation(name, pathParameters: pathParameters, queryParameters: queryParameters), extra: extra, @@ -366,7 +453,7 @@ class GoRouter extends ChangeNotifier implements RouterConfig { log.info('popping $location'); return true; }()); - _routerDelegate.pop(result); + routerDelegate.pop(result); } /// Refresh the route. @@ -375,7 +462,7 @@ class GoRouter extends ChangeNotifier implements RouterConfig { log.info('refreshing $location'); return true; }()); - _routeInformationProvider.notifyListeners(); + routeInformationProvider.notifyListeners(); } /// Find the current GoRouter in the widget tree. @@ -395,9 +482,9 @@ class GoRouter extends ChangeNotifier implements RouterConfig { @override void dispose() { - _routeInformationProvider.dispose(); - _routerDelegate.removeListener(_handleStateMayChange); - _routerDelegate.dispose(); + routeInformationProvider.dispose(); + routerDelegate.removeListener(_handleStateMayChange); + routerDelegate.dispose(); super.dispose(); } diff --git a/packages/go_router/lib/src/state.dart b/packages/go_router/lib/src/state.dart index c360cef3d7..9c634eddbe 100644 --- a/packages/go_router/lib/src/state.dart +++ b/packages/go_router/lib/src/state.dart @@ -27,9 +27,6 @@ class GoRouterState { this.error, required this.pageKey, }); - - // TODO(johnpryan): remove once namedLocation is removed from go_router. - // See https://github.com/flutter/flutter/issues/107729 final RouteConfiguration _configuration; /// The full location of the route, e.g. /family/f2/person/p1 diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index 7aae5cb12e..b71ea71440 100644 --- a/packages/go_router/pubspec.yaml +++ b/packages/go_router/pubspec.yaml @@ -1,7 +1,7 @@ name: go_router description: A declarative router for Flutter based on Navigation 2 supporting deep linking, data-driven routes and more -version: 7.1.1 +version: 8.0.0 repository: https://github.com/flutter/packages/tree/main/packages/go_router issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+go_router%22 diff --git a/packages/go_router/test/builder_test.dart b/packages/go_router/test/builder_test.dart index ac97cab5dd..9d426992cb 100644 --- a/packages/go_router/test/builder_test.dart +++ b/packages/go_router/test/builder_test.dart @@ -7,7 +7,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/src/builder.dart'; import 'package:go_router/src/configuration.dart'; import 'package:go_router/src/match.dart'; -import 'package:go_router/src/matching.dart'; import 'package:go_router/src/router.dart'; import 'test_helpers.dart'; @@ -36,8 +35,6 @@ void main() { RouteMatch( route: config.routes.first as GoRoute, matchedLocation: '/', - extra: null, - error: null, pageKey: const ValueKey('/'), ), ], @@ -121,8 +118,6 @@ void main() { RouteMatch( route: config.routes.first as GoRoute, matchedLocation: '/', - extra: null, - error: null, pageKey: const ValueKey('/'), ), ], @@ -176,15 +171,11 @@ void main() { RouteMatch( route: config.routes.first, matchedLocation: '', - extra: null, - error: null, pageKey: const ValueKey(''), ), RouteMatch( route: config.routes.first.routes.first, matchedLocation: '/details', - extra: null, - error: null, pageKey: const ValueKey('/details'), ), ], @@ -251,8 +242,6 @@ void main() { RouteMatch( route: config.routes.first.routes.first as GoRoute, matchedLocation: '/a/details', - extra: null, - error: null, pageKey: const ValueKey('/a/details'), ), ], diff --git a/packages/go_router/test/configuration_test.dart b/packages/go_router/test/configuration_test.dart index 3124702218..ac6ff0e398 100644 --- a/packages/go_router/test/configuration_test.dart +++ b/packages/go_router/test/configuration_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/src/configuration.dart'; +import 'package:go_router/src/misc/errors.dart'; import 'test_helpers.dart'; @@ -120,7 +121,7 @@ void main() { }, ); }, - throwsAssertionError, + throwsA(isA()), ); }); @@ -318,7 +319,7 @@ void main() { }, ); }, - throwsAssertionError, + throwsA(isA()), ); }); @@ -373,7 +374,7 @@ void main() { }, ); }, - throwsAssertionError, + throwsA(isA()), ); }); @@ -749,7 +750,7 @@ void main() { return null; }, ), - throwsAssertionError, + throwsA(isA()), ); }, ); @@ -857,7 +858,7 @@ void main() { return null; }, ), - throwsAssertionError, + throwsA(isA()), ); }, ); diff --git a/packages/go_router/test/delegate_test.dart b/packages/go_router/test/delegate_test.dart index c25877bf14..859a4f2d70 100644 --- a/packages/go_router/test/delegate_test.dart +++ b/packages/go_router/test/delegate_test.dart @@ -79,10 +79,13 @@ void main() { ..push('/error'); await tester.pumpAndSettle(); - final RouteMatch last = goRouter.routerDelegate.matches.matches.last; + final RouteMatch last = + goRouter.routerDelegate.currentConfiguration.matches.last; await goRouter.routerDelegate.popRoute(); - expect(goRouter.routerDelegate.matches.matches.length, 1); - expect(goRouter.routerDelegate.matches.matches.contains(last), false); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 1); + expect( + goRouter.routerDelegate.currentConfiguration.matches.contains(last), + false); }); testWidgets('pops more than matches count should return false', @@ -100,24 +103,19 @@ void main() { 'It should return different pageKey when push is called', (WidgetTester tester) async { final GoRouter goRouter = await createGoRouter(tester); - expect(goRouter.routerDelegate.matches.matches.length, 1); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 1); goRouter.push('/a'); await tester.pumpAndSettle(); - expect(goRouter.routerDelegate.matches.matches.length, 2); - expect( - goRouter.routerDelegate.matches.matches[1].pageKey, - const ValueKey('/a-p0'), - ); - goRouter.push('/a'); await tester.pumpAndSettle(); - expect(goRouter.routerDelegate.matches.matches.length, 3); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 3); expect( - goRouter.routerDelegate.matches.matches[2].pageKey, - const ValueKey('/a-p1'), + goRouter.routerDelegate.currentConfiguration.matches[1].pageKey, + isNot(equals( + goRouter.routerDelegate.currentConfiguration.matches[2].pageKey)), ); }, ); @@ -134,10 +132,11 @@ void main() { goRouter.push('/a'); await tester.pumpAndSettle(); - expect(goRouter.routerDelegate.matches.matches.length, 3); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 3); expect( - goRouter.routerDelegate.matches.matches[2].pageKey, - const Key('/a-p0'), + goRouter.routerDelegate.currentConfiguration.matches[1].pageKey, + isNot(equals( + goRouter.routerDelegate.currentConfiguration.matches[2].pageKey)), ); }, ); @@ -154,10 +153,11 @@ void main() { goRouter.push('/c/c2'); await tester.pumpAndSettle(); - expect(goRouter.routerDelegate.matches.matches.length, 3); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 3); expect( - goRouter.routerDelegate.matches.matches[2].pageKey, - const Key('/c/c2-p0'), + goRouter.routerDelegate.currentConfiguration.matches[1].pageKey, + isNot(equals( + goRouter.routerDelegate.currentConfiguration.matches[2].pageKey)), ); }, ); @@ -174,10 +174,11 @@ void main() { goRouter.push('/c'); await tester.pumpAndSettle(); - expect(goRouter.routerDelegate.matches.matches.length, 3); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 3); expect( - goRouter.routerDelegate.matches.matches[2].pageKey, - const Key('/c-p1'), + goRouter.routerDelegate.currentConfiguration.matches[1].pageKey, + isNot(equals( + goRouter.routerDelegate.currentConfiguration.matches[2].pageKey)), ); }, ); @@ -190,7 +191,7 @@ void main() { final GoRouter goRouter = await createGoRouter(tester); await tester.pumpAndSettle(); - expect(goRouter.routerDelegate.matches.matches.length, 1); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 1); expect(goRouter.routerDelegate.canPop(), false); }, ); @@ -201,7 +202,7 @@ void main() { ..push('/a'); await tester.pumpAndSettle(); - expect(goRouter.routerDelegate.matches.matches.length, 2); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 2); expect(goRouter.routerDelegate.canPop(), true); }, ); @@ -227,22 +228,24 @@ void main() { goRouter.push('/page-0'); goRouter.routerDelegate.addListener(expectAsync0(() {})); - final RouteMatch first = goRouter.routerDelegate.matches.matches.first; - final RouteMatch last = goRouter.routerDelegate.matches.last; + final RouteMatch first = + goRouter.routerDelegate.currentConfiguration.matches.first; + final RouteMatch last = goRouter.routerDelegate.currentConfiguration.last; goRouter.pushReplacement('/page-1'); - expect(goRouter.routerDelegate.matches.matches.length, 2); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 2); expect( - goRouter.routerDelegate.matches.matches.first, + goRouter.routerDelegate.currentConfiguration.matches.first, first, reason: 'The first match should still be in the list of matches', ); expect( - goRouter.routerDelegate.matches.last, + goRouter.routerDelegate.currentConfiguration.last, isNot(last), reason: 'The last match should have been removed', ); expect( - (goRouter.routerDelegate.matches.last as ImperativeRouteMatch) + (goRouter.routerDelegate.currentConfiguration.last + as ImperativeRouteMatch) .matches .uri .toString(), @@ -255,28 +258,26 @@ void main() { 'It should return different pageKey when pushReplacement is called', (WidgetTester tester) async { final GoRouter goRouter = await createGoRouter(tester); - expect(goRouter.routerDelegate.matches.matches.length, 1); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 1); expect( - goRouter.routerDelegate.matches.matches[0].pageKey, + goRouter.routerDelegate.currentConfiguration.matches[0].pageKey, isNotNull, ); goRouter.push('/a'); await tester.pumpAndSettle(); - expect(goRouter.routerDelegate.matches.matches.length, 2); - expect( - goRouter.routerDelegate.matches.matches.last.pageKey, - const ValueKey('/a-p0'), - ); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 2); + final ValueKey prev = + goRouter.routerDelegate.currentConfiguration.matches.last.pageKey; goRouter.pushReplacement('/a'); await tester.pumpAndSettle(); - expect(goRouter.routerDelegate.matches.matches.length, 2); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 2); expect( - goRouter.routerDelegate.matches.matches.last.pageKey, - const ValueKey('/a-p1'), + goRouter.routerDelegate.currentConfiguration.matches.last.pageKey, + isNot(equals(prev)), ); }, ); @@ -309,22 +310,24 @@ void main() { goRouter.pushNamed('page0'); goRouter.routerDelegate.addListener(expectAsync0(() {})); - final RouteMatch first = goRouter.routerDelegate.matches.matches.first; - final RouteMatch last = goRouter.routerDelegate.matches.last; + final RouteMatch first = + goRouter.routerDelegate.currentConfiguration.matches.first; + final RouteMatch last = + goRouter.routerDelegate.currentConfiguration.last; goRouter.pushReplacementNamed('page1'); - expect(goRouter.routerDelegate.matches.matches.length, 2); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 2); expect( - goRouter.routerDelegate.matches.matches.first, + goRouter.routerDelegate.currentConfiguration.matches.first, first, reason: 'The first match should still be in the list of matches', ); expect( - goRouter.routerDelegate.matches.last, + goRouter.routerDelegate.currentConfiguration.last, isNot(last), reason: 'The last match should have been removed', ); expect( - goRouter.routerDelegate.matches.last, + goRouter.routerDelegate.currentConfiguration.last, isA().having( (RouteMatch match) => (match.route as GoRoute).name, 'match.route.name', @@ -356,22 +359,24 @@ void main() { goRouter.push('/page-0'); goRouter.routerDelegate.addListener(expectAsync0(() {})); - final RouteMatch first = goRouter.routerDelegate.matches.matches.first; - final RouteMatch last = goRouter.routerDelegate.matches.last; + final RouteMatch first = + goRouter.routerDelegate.currentConfiguration.matches.first; + final RouteMatch last = goRouter.routerDelegate.currentConfiguration.last; goRouter.replace('/page-1'); - expect(goRouter.routerDelegate.matches.matches.length, 2); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 2); expect( - goRouter.routerDelegate.matches.matches.first, + goRouter.routerDelegate.currentConfiguration.matches.first, first, reason: 'The first match should still be in the list of matches', ); expect( - goRouter.routerDelegate.matches.last, + goRouter.routerDelegate.currentConfiguration.last, isNot(last), reason: 'The last match should have been removed', ); expect( - (goRouter.routerDelegate.matches.last as ImperativeRouteMatch) + (goRouter.routerDelegate.currentConfiguration.last + as ImperativeRouteMatch) .matches .uri .toString(), @@ -384,28 +389,26 @@ void main() { 'It should use the same pageKey when replace is called (with the same path)', (WidgetTester tester) async { final GoRouter goRouter = await createGoRouter(tester); - expect(goRouter.routerDelegate.matches.matches.length, 1); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 1); expect( - goRouter.routerDelegate.matches.matches[0].pageKey, + goRouter.routerDelegate.currentConfiguration.matches[0].pageKey, isNotNull, ); goRouter.push('/a'); await tester.pumpAndSettle(); - expect(goRouter.routerDelegate.matches.matches.length, 2); - expect( - goRouter.routerDelegate.matches.matches.last.pageKey, - const ValueKey('/a-p0'), - ); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 2); + final ValueKey prev = + goRouter.routerDelegate.currentConfiguration.matches.last.pageKey; goRouter.replace('/a'); await tester.pumpAndSettle(); - expect(goRouter.routerDelegate.matches.matches.length, 2); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 2); expect( - goRouter.routerDelegate.matches.matches.last.pageKey, - const ValueKey('/a-p0'), + goRouter.routerDelegate.currentConfiguration.matches.last.pageKey, + prev, ); }, ); @@ -414,28 +417,26 @@ void main() { 'It should use the same pageKey when replace is called (with a different path)', (WidgetTester tester) async { final GoRouter goRouter = await createGoRouter(tester); - expect(goRouter.routerDelegate.matches.matches.length, 1); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 1); expect( - goRouter.routerDelegate.matches.matches[0].pageKey, + goRouter.routerDelegate.currentConfiguration.matches[0].pageKey, isNotNull, ); goRouter.push('/a'); await tester.pumpAndSettle(); - expect(goRouter.routerDelegate.matches.matches.length, 2); - expect( - goRouter.routerDelegate.matches.matches.last.pageKey, - const ValueKey('/a-p0'), - ); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 2); + final ValueKey prev = + goRouter.routerDelegate.currentConfiguration.matches.last.pageKey; goRouter.replace('/'); await tester.pumpAndSettle(); - expect(goRouter.routerDelegate.matches.matches.length, 2); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 2); expect( - goRouter.routerDelegate.matches.matches.last.pageKey, - const ValueKey('/a-p0'), + goRouter.routerDelegate.currentConfiguration.matches.last.pageKey, + prev, ); }, ); @@ -479,22 +480,24 @@ void main() { goRouter.pushNamed('page0'); goRouter.routerDelegate.addListener(expectAsync0(() {})); - final RouteMatch first = goRouter.routerDelegate.matches.matches.first; - final RouteMatch last = goRouter.routerDelegate.matches.last; + final RouteMatch first = + goRouter.routerDelegate.currentConfiguration.matches.first; + final RouteMatch last = goRouter.routerDelegate.currentConfiguration.last; goRouter.replaceNamed('page1'); - expect(goRouter.routerDelegate.matches.matches.length, 2); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 2); expect( - goRouter.routerDelegate.matches.matches.first, + goRouter.routerDelegate.currentConfiguration.matches.first, first, reason: 'The first match should still be in the list of matches', ); expect( - goRouter.routerDelegate.matches.last, + goRouter.routerDelegate.currentConfiguration.last, isNot(last), reason: 'The last match should have been removed', ); expect( - (goRouter.routerDelegate.matches.last as ImperativeRouteMatch) + (goRouter.routerDelegate.currentConfiguration.last + as ImperativeRouteMatch) .matches .uri .toString(), @@ -507,28 +510,26 @@ void main() { 'It should use the same pageKey when replace is called with the same path', (WidgetTester tester) async { final GoRouter goRouter = await createGoRouter(tester); - expect(goRouter.routerDelegate.matches.matches.length, 1); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 1); expect( - goRouter.routerDelegate.matches.matches.first.pageKey, + goRouter.routerDelegate.currentConfiguration.matches.first.pageKey, isNotNull, ); goRouter.pushNamed('page0'); await tester.pumpAndSettle(); - expect(goRouter.routerDelegate.matches.matches.length, 2); - expect( - goRouter.routerDelegate.matches.matches.last.pageKey, - const ValueKey('/page-0-p0'), - ); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 2); + final ValueKey prev = + goRouter.routerDelegate.currentConfiguration.matches.last.pageKey; goRouter.replaceNamed('page0'); await tester.pumpAndSettle(); - expect(goRouter.routerDelegate.matches.matches.length, 2); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 2); expect( - goRouter.routerDelegate.matches.matches.last.pageKey, - const ValueKey('/page-0-p0'), + goRouter.routerDelegate.currentConfiguration.matches.last.pageKey, + prev, ); }, ); @@ -537,28 +538,26 @@ void main() { 'It should use a new pageKey when replace is called with a different path', (WidgetTester tester) async { final GoRouter goRouter = await createGoRouter(tester); - expect(goRouter.routerDelegate.matches.matches.length, 1); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 1); expect( - goRouter.routerDelegate.matches.matches.first.pageKey, + goRouter.routerDelegate.currentConfiguration.matches.first.pageKey, isNotNull, ); goRouter.pushNamed('page0'); await tester.pumpAndSettle(); - expect(goRouter.routerDelegate.matches.matches.length, 2); - expect( - goRouter.routerDelegate.matches.matches.last.pageKey, - const ValueKey('/page-0-p0'), - ); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 2); + final ValueKey prev = + goRouter.routerDelegate.currentConfiguration.matches.last.pageKey; goRouter.replaceNamed('home'); await tester.pumpAndSettle(); - expect(goRouter.routerDelegate.matches.matches.length, 2); + expect(goRouter.routerDelegate.currentConfiguration.matches.length, 2); expect( - goRouter.routerDelegate.matches.matches.last.pageKey, - const ValueKey('/page-0-p0'), + goRouter.routerDelegate.currentConfiguration.matches.last.pageKey, + prev, ); }, ); diff --git a/packages/go_router/test/go_router_test.dart b/packages/go_router/test/go_router_test.dart index cbdc543f46..b3b46660e6 100644 --- a/packages/go_router/test/go_router_test.dart +++ b/packages/go_router/test/go_router_test.dart @@ -6,13 +6,13 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:go_router/src/match.dart'; -import 'package:go_router/src/matching.dart'; import 'package:logging/logging.dart'; import 'test_helpers.dart'; @@ -46,7 +46,7 @@ void main() { ]; final GoRouter router = await createRouter(routes, tester); - final RouteMatchList matches = router.routerDelegate.matches; + final RouteMatchList matches = router.routerDelegate.currentConfiguration; expect(matches.matches, hasLength(1)); expect(matches.uri.toString(), '/'); expect(find.byType(HomeScreen), findsOneWidget); @@ -61,7 +61,8 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/'); - final List matches = router.routerDelegate.matches.matches; + final List matches = + router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); expect((matches.first.route as GoRoute).name, '1'); expect(find.byType(DummyScreen), findsOneWidget); @@ -129,8 +130,9 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/foo'); await tester.pumpAndSettle(); - final List matches = router.routerDelegate.matches.matches; - expect(matches, hasLength(1)); + final List matches = + router.routerDelegate.currentConfiguration.matches; + expect(matches, hasLength(0)); expect(find.byType(TestErrorScreen), findsOneWidget); }); @@ -149,7 +151,8 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/login'); await tester.pumpAndSettle(); - final List matches = router.routerDelegate.matches.matches; + final List matches = + router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); expect(matches.first.matchedLocation, '/login'); expect(find.byType(LoginScreen), findsOneWidget); @@ -178,7 +181,8 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/login'); await tester.pumpAndSettle(); - final List matches = router.routerDelegate.matches.matches; + final List matches = + router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); expect(matches.first.matchedLocation, '/login'); expect(find.byType(LoginScreen), findsOneWidget); @@ -202,7 +206,8 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/login/'); await tester.pumpAndSettle(); - final List matches = router.routerDelegate.matches.matches; + final List matches = + router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); expect(matches.first.matchedLocation, '/login'); expect(find.byType(LoginScreen), findsOneWidget); @@ -221,7 +226,8 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/profile/'); await tester.pumpAndSettle(); - final List matches = router.routerDelegate.matches.matches; + final List matches = + router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); expect(matches.first.matchedLocation, '/profile/foo'); expect(find.byType(DummyScreen), findsOneWidget); @@ -240,7 +246,8 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/profile/?bar=baz'); await tester.pumpAndSettle(); - final List matches = router.routerDelegate.matches.matches; + final List matches = + router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); expect(matches.first.matchedLocation, '/profile/foo'); expect(find.byType(DummyScreen), findsOneWidget); @@ -352,7 +359,8 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/login'); await tester.pumpAndSettle(); - final List matches = router.routerDelegate.matches.matches; + final List matches = + router.routerDelegate.currentConfiguration.matches; expect(matches.length, 2); expect(matches.first.matchedLocation, '/'); expect(find.byType(HomeScreen, skipOffstage: false), findsOneWidget); @@ -390,7 +398,8 @@ void main() { final GoRouter router = await createRouter(routes, tester); { - final RouteMatchList matches = router.routerDelegate.matches; + final RouteMatchList matches = + router.routerDelegate.currentConfiguration; expect(matches.matches, hasLength(1)); expect(matches.uri.toString(), '/'); expect(find.byType(HomeScreen), findsOneWidget); @@ -399,7 +408,8 @@ void main() { router.go('/login'); await tester.pumpAndSettle(); { - final RouteMatchList matches = router.routerDelegate.matches; + final RouteMatchList matches = + router.routerDelegate.currentConfiguration; expect(matches.matches.length, 2); expect(matches.matches.first.matchedLocation, '/'); expect(find.byType(HomeScreen, skipOffstage: false), findsOneWidget); @@ -410,7 +420,8 @@ void main() { router.go('/family/f2'); await tester.pumpAndSettle(); { - final RouteMatchList matches = router.routerDelegate.matches; + final RouteMatchList matches = + router.routerDelegate.currentConfiguration; expect(matches.matches.length, 2); expect(matches.matches.first.matchedLocation, '/'); expect(find.byType(HomeScreen, skipOffstage: false), findsOneWidget); @@ -421,7 +432,8 @@ void main() { router.go('/family/f2/person/p1'); await tester.pumpAndSettle(); { - final RouteMatchList matches = router.routerDelegate.matches; + final RouteMatchList matches = + router.routerDelegate.currentConfiguration; expect(matches.matches.length, 3); expect(matches.matches.first.matchedLocation, '/'); expect(find.byType(HomeScreen, skipOffstage: false), findsOneWidget); @@ -469,19 +481,20 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/bar'); await tester.pumpAndSettle(); - List matches = router.routerDelegate.matches.matches; + List matches = + router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(2)); expect(find.byType(Page1Screen), findsOneWidget); router.go('/foo/bar'); await tester.pumpAndSettle(); - matches = router.routerDelegate.matches.matches; + matches = router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(2)); expect(find.byType(FamilyScreen), findsOneWidget); router.go('/foo'); await tester.pumpAndSettle(); - matches = router.routerDelegate.matches.matches; + matches = router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(2)); expect(find.byType(Page2Screen), findsOneWidget); }); @@ -592,7 +605,8 @@ void main() { const String loc = '/FaMiLy/f2'; router.go(loc); await tester.pumpAndSettle(); - final List matches = router.routerDelegate.matches.matches; + final List matches = + router.routerDelegate.currentConfiguration.matches; // NOTE: match the lower case, since location is canonicalized to match the // path case whereas the location can be any case; so long as the path @@ -616,7 +630,8 @@ void main() { final GoRouter router = await createRouter(routes, tester); router.go('/user'); await tester.pumpAndSettle(); - final List matches = router.routerDelegate.matches.matches; + final List matches = + router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); expect(find.byType(DummyScreen), findsOneWidget); }); @@ -973,6 +988,7 @@ void main() { group('report correct url', () { final List log = []; setUp(() { + GoRouter.optionURLReflectsImperativeAPIs = false; _ambiguate(TestDefaultBinaryMessengerBinding.instance)! .defaultBinaryMessenger .setMockMethodCallHandler(SystemChannels.navigation, @@ -982,12 +998,43 @@ void main() { }); }); tearDown(() { + GoRouter.optionURLReflectsImperativeAPIs = false; _ambiguate(TestDefaultBinaryMessengerBinding.instance)! .defaultBinaryMessenger .setMockMethodCallHandler(SystemChannels.navigation, null); log.clear(); }); + testWidgets('on push with optionURLReflectImperativeAPIs = true', + (WidgetTester tester) async { + GoRouter.optionURLReflectsImperativeAPIs = true; + final List routes = [ + GoRoute( + path: '/', + builder: (_, __) => const DummyScreen(), + ), + GoRoute( + path: '/settings', + builder: (_, __) => const DummyScreen(), + ), + ]; + + final GoRouter router = await createRouter(routes, tester); + + log.clear(); + router.push('/settings'); + final RouteMatchListCodec codec = + RouteMatchListCodec(router.configuration); + await tester.pumpAndSettle(); + final ImperativeRouteMatch match = router + .routerDelegate.currentConfiguration.last as ImperativeRouteMatch; + expect(log, [ + isMethodCall('selectMultiEntryHistory', arguments: null), + IsRouteUpdateCall('/settings', false, codec.encode(match.matches)), + ]); + GoRouter.optionURLReflectsImperativeAPIs = false; + }); + testWidgets('on push', (WidgetTester tester) async { final List routes = [ GoRoute( @@ -1004,10 +1051,13 @@ void main() { log.clear(); router.push('/settings'); + final RouteMatchListCodec codec = + RouteMatchListCodec(router.configuration); await tester.pumpAndSettle(); expect(log, [ isMethodCall('selectMultiEntryHistory', arguments: null), - const IsRouteUpdateCall('/settings', false, null), + IsRouteUpdateCall('/', false, + codec.encode(router.routerDelegate.currentConfiguration)), ]); }); @@ -1026,13 +1076,15 @@ void main() { final GoRouter router = await createRouter(routes, tester, initialLocation: '/settings'); - + final RouteMatchListCodec codec = + RouteMatchListCodec(router.configuration); log.clear(); router.pop(); await tester.pumpAndSettle(); expect(log, [ isMethodCall('selectMultiEntryHistory', arguments: null), - const IsRouteUpdateCall('/', false, null), + IsRouteUpdateCall('/', false, + codec.encode(router.routerDelegate.currentConfiguration)), ]); }); @@ -1056,14 +1108,16 @@ void main() { final GoRouter router = await createRouter(routes, tester, initialLocation: '/settings/profile'); - + final RouteMatchListCodec codec = + RouteMatchListCodec(router.configuration); log.clear(); router.pop(); router.pop(); await tester.pumpAndSettle(); expect(log, [ isMethodCall('selectMultiEntryHistory', arguments: null), - const IsRouteUpdateCall('/', false, null), + IsRouteUpdateCall('/', false, + codec.encode(router.routerDelegate.currentConfiguration)), ]); }); @@ -1082,13 +1136,15 @@ void main() { final GoRouter router = await createRouter(routes, tester, initialLocation: '/settings/123'); - + final RouteMatchListCodec codec = + RouteMatchListCodec(router.configuration); log.clear(); router.pop(); await tester.pumpAndSettle(); expect(log, [ isMethodCall('selectMultiEntryHistory', arguments: null), - const IsRouteUpdateCall('/', false, null), + IsRouteUpdateCall('/', false, + codec.encode(router.routerDelegate.currentConfiguration)), ]); }); @@ -1108,13 +1164,15 @@ void main() { final GoRouter router = await createRouter(routes, tester, initialLocation: '/123/'); - + final RouteMatchListCodec codec = + RouteMatchListCodec(router.configuration); log.clear(); router.pop(); await tester.pumpAndSettle(); expect(log, [ isMethodCall('selectMultiEntryHistory', arguments: null), - const IsRouteUpdateCall('/', false, null), + IsRouteUpdateCall('/', false, + codec.encode(router.routerDelegate.currentConfiguration)), ]); }); @@ -1165,12 +1223,15 @@ void main() { ), ]; - await createRouter(routes, tester, + final GoRouter router = await createRouter(routes, tester, initialLocation: '/b/c', navigatorKey: rootNavigatorKey); + final RouteMatchListCodec codec = + RouteMatchListCodec(router.configuration); expect(find.text('Screen C'), findsOneWidget); expect(log, [ isMethodCall('selectMultiEntryHistory', arguments: null), - const IsRouteUpdateCall('/b/c', false, null), + IsRouteUpdateCall('/b/c', true, + codec.encode(router.routerDelegate.currentConfiguration)), ]); log.clear(); @@ -1180,10 +1241,70 @@ void main() { expect(find.text('Home'), findsOneWidget); expect(log, [ isMethodCall('selectMultiEntryHistory', arguments: null), - const IsRouteUpdateCall('/', false, null), + IsRouteUpdateCall('/', false, + codec.encode(router.routerDelegate.currentConfiguration)), ]); }); + testWidgets('can handle route information update from browser', + (WidgetTester tester) async { + final List routes = [ + GoRoute( + path: '/', + builder: (_, __) => const DummyScreen(key: ValueKey('home')), + routes: [ + GoRoute( + path: 'settings', + builder: (_, GoRouterState state) => + DummyScreen(key: ValueKey('settings-${state.extra}')), + ), + ], + ), + ]; + + final GoRouter router = await createRouter(routes, tester); + expect(find.byKey(const ValueKey('home')), findsOneWidget); + + router.push('/settings', extra: 0); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('settings-0')), findsOneWidget); + + log.clear(); + router.push('/settings', extra: 1); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('settings-1')), findsOneWidget); + + final Map arguments = + log.last.arguments as Map; + // Stores the state after the last push. This should contain the encoded + // RouteMatchList. + final Object? state = + (log.last.arguments as Map)['state']; + final String location = + (arguments['location'] ?? arguments['uri']!) as String; + + router.go('/'); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('home')), findsOneWidget); + + router.routeInformationProvider.didPushRouteInformation( + // TODO(chunhtai): remove this ignore and migrate the code + // https://github.com/flutter/flutter/issues/124045. + // ignore: deprecated_member_use + RouteInformation(location: location, state: state)); + await tester.pumpAndSettle(); + // Make sure it has all the imperative routes. + expect(find.byKey(const ValueKey('settings-1')), findsOneWidget); + + router.pop(); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('settings-0')), findsOneWidget); + + router.pop(); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('home')), findsOneWidget); + }); + testWidgets('works correctly with async redirect', (WidgetTester tester) async { final UniqueKey login = UniqueKey(); @@ -1198,10 +1319,13 @@ void main() { ), ]; final Completer completer = Completer(); - await createRouter(routes, tester, redirect: (_, __) async { + final GoRouter router = + await createRouter(routes, tester, redirect: (_, __) async { await completer.future; return '/login'; }); + final RouteMatchListCodec codec = + RouteMatchListCodec(router.configuration); await tester.pumpAndSettle(); expect(find.byKey(login), findsNothing); expect(tester.takeException(), isNull); @@ -1214,7 +1338,8 @@ void main() { expect(tester.takeException(), isNull); expect(log, [ isMethodCall('selectMultiEntryHistory', arguments: null), - const IsRouteUpdateCall('/login', false, null), + IsRouteUpdateCall('/login', true, + codec.encode(router.routerDelegate.currentConfiguration)), ]); }); }); @@ -1487,7 +1612,7 @@ void main() { router.go(loc); await tester.pumpAndSettle(); - final RouteMatchList matches = router.routerDelegate.matches; + final RouteMatchList matches = router.routerDelegate.currentConfiguration; expect(find.byType(DummyScreen), findsOneWidget); expect(matches.pathParameters['param1'], param1); }); @@ -1511,7 +1636,7 @@ void main() { queryParameters: {'param1': param1}); router.go(loc); await tester.pumpAndSettle(); - final RouteMatchList matches = router.routerDelegate.matches; + final RouteMatchList matches = router.routerDelegate.currentConfiguration; expect(find.byType(DummyScreen), findsOneWidget); expect(matches.uri.queryParameters['param1'], param1); }); @@ -1766,8 +1891,9 @@ void main() { ? '/' : null); - final List matches = router.routerDelegate.matches.matches; - expect(matches, hasLength(1)); + final List matches = + router.routerDelegate.currentConfiguration.matches; + expect(matches, hasLength(0)); expect(find.byType(TestErrorScreen), findsOneWidget); final TestErrorScreen screen = tester.widget(find.byType(TestErrorScreen)); @@ -1791,8 +1917,9 @@ void main() { tester, ); - final List matches = router.routerDelegate.matches.matches; - expect(matches, hasLength(1)); + final List matches = + router.routerDelegate.currentConfiguration.matches; + expect(matches, hasLength(0)); expect(find.byType(TestErrorScreen), findsOneWidget); final TestErrorScreen screen = tester.widget(find.byType(TestErrorScreen)); @@ -1813,8 +1940,9 @@ void main() { state.matchedLocation == '/' ? '/login' : null, ); - final List matches = router.routerDelegate.matches.matches; - expect(matches, hasLength(1)); + final List matches = + router.routerDelegate.currentConfiguration.matches; + expect(matches, hasLength(0)); expect(find.byType(TestErrorScreen), findsOneWidget); final TestErrorScreen screen = tester.widget(find.byType(TestErrorScreen)); @@ -1834,8 +1962,9 @@ void main() { : null, ); - final List matches = router.routerDelegate.matches.matches; - expect(matches, hasLength(1)); + final List matches = + router.routerDelegate.currentConfiguration.matches; + expect(matches, hasLength(0)); expect(find.byType(TestErrorScreen), findsOneWidget); final TestErrorScreen screen = tester.widget(find.byType(TestErrorScreen)); @@ -1895,7 +2024,8 @@ void main() { }, ); - final List matches = router.routerDelegate.matches.matches; + final List matches = + router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); expect(find.byType(LoginScreen), findsOneWidget); }); @@ -1924,7 +2054,8 @@ void main() { initialLocation: loc, ); - final List matches = router.routerDelegate.matches.matches; + final List matches = + router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); expect(find.byType(HomeScreen), findsOneWidget); }); @@ -1965,7 +2096,8 @@ void main() { initialLocation: '/family/f2/person/p1', ); - final List matches = router.routerDelegate.matches.matches; + final List matches = + router.routerDelegate.currentConfiguration.matches; expect(matches.length, 3); expect(find.byType(HomeScreen, skipOffstage: false), findsOneWidget); expect(find.byType(FamilyScreen, skipOffstage: false), findsOneWidget); @@ -1984,14 +2116,56 @@ void main() { redirectLimit: 10, ); - final List matches = router.routerDelegate.matches.matches; - expect(matches, hasLength(1)); + final List matches = + router.routerDelegate.currentConfiguration.matches; + expect(matches, hasLength(0)); expect(find.byType(TestErrorScreen), findsOneWidget); final TestErrorScreen screen = tester.widget(find.byType(TestErrorScreen)); expect(screen.ex, isNotNull); }); + testWidgets('can push error page', (WidgetTester tester) async { + final GoRouter router = await createRouter( + [ + GoRoute(path: '/', builder: (_, __) => const Text('/')), + ], + tester, + errorBuilder: (_, GoRouterState state) { + return Text(state.location); + }, + ); + + expect(find.text('/'), findsOneWidget); + + router.push('/error1'); + await tester.pumpAndSettle(); + + expect(find.text('/'), findsNothing); + expect(find.text('/error1'), findsOneWidget); + + router.push('/error2'); + await tester.pumpAndSettle(); + + expect(find.text('/'), findsNothing); + expect(find.text('/error1'), findsNothing); + expect(find.text('/error2'), findsOneWidget); + + router.pop(); + await tester.pumpAndSettle(); + + expect(find.text('/'), findsNothing); + expect(find.text('/error1'), findsOneWidget); + expect(find.text('/error2'), findsNothing); + + router.pop(); + await tester.pumpAndSettle(); + + expect(find.text('/'), findsOneWidget); + expect(find.text('/error1'), findsNothing); + expect(find.text('/error2'), findsNothing); + }); + testWidgets('extra not null in redirect', (WidgetTester tester) async { bool isCallTopRedirect = false; bool isCallRouteRedirect = false; @@ -2231,7 +2405,8 @@ void main() { final String loc = '/family/$fid'; router.go(loc); await tester.pumpAndSettle(); - final RouteMatchList matches = router.routerDelegate.matches; + final RouteMatchList matches = + router.routerDelegate.currentConfiguration; expect(router.location, loc); expect(matches.matches, hasLength(1)); @@ -2260,7 +2435,8 @@ void main() { final String loc = '/family?fid=$fid'; router.go(loc); await tester.pumpAndSettle(); - final RouteMatchList matches = router.routerDelegate.matches; + final RouteMatchList matches = + router.routerDelegate.currentConfiguration; expect(router.location, loc); expect(matches.matches, hasLength(1)); @@ -2287,7 +2463,7 @@ void main() { router.go(loc); await tester.pumpAndSettle(); - final RouteMatchList matches = router.routerDelegate.matches; + final RouteMatchList matches = router.routerDelegate.currentConfiguration; expect(find.byType(DummyScreen), findsOneWidget); expect(matches.pathParameters['param1'], param1); }); @@ -2309,7 +2485,7 @@ void main() { router.go('/page1?param1=$param1'); await tester.pumpAndSettle(); - final RouteMatchList matches = router.routerDelegate.matches; + final RouteMatchList matches = router.routerDelegate.currentConfiguration; expect(find.byType(DummyScreen), findsOneWidget); expect(matches.uri.queryParameters['param1'], param1); @@ -2317,7 +2493,8 @@ void main() { router.go(loc); await tester.pumpAndSettle(); - final RouteMatchList matches2 = router.routerDelegate.matches; + final RouteMatchList matches2 = + router.routerDelegate.currentConfiguration; expect(find.byType(DummyScreen), findsOneWidget); expect(matches2.uri.queryParameters['param1'], param1); }); @@ -2358,7 +2535,7 @@ void main() { tester, initialLocation: '/?id=0&id=1', ); - final RouteMatchList matches = router.routerDelegate.matches; + final RouteMatchList matches = router.routerDelegate.currentConfiguration; expect(matches.matches, hasLength(1)); expect(matches.fullPath, '/'); expect(find.byType(HomeScreen), findsOneWidget); @@ -2381,7 +2558,7 @@ void main() { router.go('/0?id=1'); await tester.pumpAndSettle(); - final RouteMatchList matches = router.routerDelegate.matches; + final RouteMatchList matches = router.routerDelegate.currentConfiguration; expect(matches.matches, hasLength(1)); expect(matches.fullPath, '/:id'); expect(find.byType(HomeScreen), findsOneWidget); @@ -2493,13 +2670,13 @@ void main() { router.push(loc); await tester.pumpAndSettle(); - final RouteMatchList matches = router.routerDelegate.matches; + final RouteMatchList matches = router.routerDelegate.currentConfiguration; expect(router.location, loc); expect(matches.matches, hasLength(2)); expect(find.byType(PersonScreen), findsOneWidget); - final ImperativeRouteMatch imperativeRouteMatch = - matches.matches.last as ImperativeRouteMatch; + final ImperativeRouteMatch imperativeRouteMatch = + matches.matches.last as ImperativeRouteMatch; expect(imperativeRouteMatch.matches.pathParameters['fid'], fid); expect(imperativeRouteMatch.matches.pathParameters['pid'], pid); }); @@ -2563,7 +2740,7 @@ void main() { router.go(loc); await tester.pumpAndSettle(); - RouteMatchList matches = router.routerDelegate.matches; + RouteMatchList matches = router.routerDelegate.currentConfiguration; expect(router.location, loc); expect(matches.matches, hasLength(4)); @@ -2580,7 +2757,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Screen A'), findsNothing); expect(find.byType(PersonScreen), findsOneWidget); - matches = router.routerDelegate.matches; + matches = router.routerDelegate.currentConfiguration; expect(matches.pathParameters['fid'], fid); expect(matches.pathParameters['pid'], pid); }); @@ -2623,7 +2800,8 @@ void main() { 'q2': ['v2', 'v3'], }); await tester.pumpAndSettle(); - final List matches = router.routerDelegate.matches.matches; + final List matches = + router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); expectLocationWithQueryParams(router.location); @@ -2673,7 +2851,8 @@ void main() { router.go('/page?q1=v1&q2=v2&q2=v3'); await tester.pumpAndSettle(); - final List matches = router.routerDelegate.matches.matches; + final List matches = + router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); expectLocationWithQueryParams(router.location); @@ -2722,7 +2901,8 @@ void main() { router.go('/page?q1=v1&q2=v2&q2=v3'); await tester.pumpAndSettle(); - final List matches = router.routerDelegate.matches.matches; + final List matches = + router.routerDelegate.currentConfiguration.matches; expect(matches, hasLength(1)); expectLocationWithQueryParams(router.location); @@ -4681,7 +4861,11 @@ class IsRouteUpdateCall extends Matcher { if (arguments['uri'] != uri && arguments['location'] != uri) { return false; } - return arguments['state'] == state && arguments['replace'] == replace; + + if (!const DeepCollectionEquality().equals(arguments['state'], state)) { + return false; + } + return arguments['replace'] == replace; } @override diff --git a/packages/go_router/test/information_provider_test.dart b/packages/go_router/test/information_provider_test.dart index d5e2a0e891..d708515183 100644 --- a/packages/go_router/test/information_provider_test.dart +++ b/packages/go_router/test/information_provider_test.dart @@ -6,31 +6,31 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/src/information_provider.dart'; -// TODO(chunhtai): remove this ignore and migrate the code -// https://github.com/flutter/flutter/issues/124045. -// ignore: deprecated_member_use -const RouteInformation initialRoute = RouteInformation(location: '/'); -// TODO(chunhtai): remove this ignore and migrate the code -// https://github.com/flutter/flutter/issues/124045. -// ignore: deprecated_member_use -const RouteInformation newRoute = RouteInformation(location: '/new'); +const String initialRoute = '/'; +const String newRoute = '/new'; void main() { group('GoRouteInformationProvider', () { testWidgets('notifies its listeners when set by the app', (WidgetTester tester) async { late final GoRouteInformationProvider provider = - GoRouteInformationProvider(initialRouteInformation: initialRoute); + GoRouteInformationProvider( + initialLocation: initialRoute, initialExtra: null); provider.addListener(expectAsync0(() {})); - provider.value = newRoute; + provider.go(newRoute); }); testWidgets('notifies its listeners when set by the platform', (WidgetTester tester) async { late final GoRouteInformationProvider provider = - GoRouteInformationProvider(initialRouteInformation: initialRoute); + GoRouteInformationProvider( + initialLocation: initialRoute, initialExtra: null); provider.addListener(expectAsync0(() {})); - provider.didPushRouteInformation(newRoute); + // TODO(chunhtai): remove this ignore and migrate the code + // https://github.com/flutter/flutter/issues/124045. + // ignore_for_file: deprecated_member_use + provider + .didPushRouteInformation(const RouteInformation(location: newRoute)); }); }); } diff --git a/packages/go_router/test/match_test.dart b/packages/go_router/test/match_test.dart index 712f379d4d..f9e73075dd 100644 --- a/packages/go_router/test/match_test.dart +++ b/packages/go_router/test/match_test.dart @@ -20,7 +20,6 @@ void main() { remainingLocation: '/users/123', matchedLocation: '', pathParameters: pathParameters, - extra: const _Extra('foo'), ); if (match == null) { fail('Null match'); @@ -28,8 +27,6 @@ void main() { expect(match.route, route); expect(match.matchedLocation, '/users/123'); expect(pathParameters['userId'], '123'); - expect(match.extra, const _Extra('foo')); - expect(match.error, isNull); expect(match.pageKey, isNotNull); }); @@ -44,7 +41,6 @@ void main() { remainingLocation: 'users/123', matchedLocation: '/home', pathParameters: pathParameters, - extra: const _Extra('foo'), ); if (match == null) { fail('Null match'); @@ -52,8 +48,6 @@ void main() { expect(match.route, route); expect(match.matchedLocation, '/home/users/123'); expect(pathParameters['userId'], '123'); - expect(match.extra, const _Extra('foo')); - expect(match.error, isNull); expect(match.pageKey, isNotNull); }); @@ -73,7 +67,6 @@ void main() { remainingLocation: 'users/123', matchedLocation: '/home', pathParameters: pathParameters, - extra: const _Extra('foo'), ); if (match == null) { fail('Null match'); @@ -97,7 +90,6 @@ void main() { remainingLocation: 'users/123', matchedLocation: '/home', pathParameters: pathParameters, - extra: const _Extra('foo'), ); final RouteMatch? match2 = RouteMatch.match( @@ -105,7 +97,6 @@ void main() { remainingLocation: 'users/1234', matchedLocation: '/home', pathParameters: pathParameters, - extra: const _Extra('foo1'), ); expect(match1!.pageKey, match2!.pageKey); @@ -122,7 +113,6 @@ void main() { remainingLocation: 'users/123', matchedLocation: '/home', pathParameters: pathParameters, - extra: const _Extra('foo'), ); final RouteMatch? match2 = RouteMatch.match( @@ -130,7 +120,6 @@ void main() { remainingLocation: 'users/1234', matchedLocation: '/home', pathParameters: pathParameters, - extra: const _Extra('foo1'), ); expect(match1!.pageKey, match2!.pageKey); @@ -138,21 +127,6 @@ void main() { }); } -@immutable -class _Extra { - const _Extra(this.value); - - final String value; - - @override - bool operator ==(Object other) { - return other is _Extra && other.value == value; - } - - @override - int get hashCode => value.hashCode; -} - Widget _builder(BuildContext context, GoRouterState state) => const Placeholder(); diff --git a/packages/go_router/test/matching_test.dart b/packages/go_router/test/matching_test.dart index 8a1d12ab53..7784b851d7 100644 --- a/packages/go_router/test/matching_test.dart +++ b/packages/go_router/test/matching_test.dart @@ -2,11 +2,12 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/src/configuration.dart'; import 'package:go_router/src/match.dart'; -import 'package:go_router/src/matching.dart'; import 'package:go_router/src/router.dart'; import 'test_helpers.dart'; @@ -25,7 +26,7 @@ void main() { router.go('/page-0'); await tester.pumpAndSettle(); - final RouteMatchList matches = router.routerDelegate.matches; + final RouteMatchList matches = router.routerDelegate.currentConfiguration; expect(matches.toString(), contains('/page-0')); }); @@ -41,7 +42,6 @@ void main() { remainingLocation: '/page-0', matchedLocation: '', pathParameters: params1, - extra: null, )!; final Map params2 = {}; @@ -50,7 +50,6 @@ void main() { remainingLocation: '/page-0', matchedLocation: '', pathParameters: params2, - extra: null, )!; final RouteMatchList matches1 = RouteMatchList( @@ -93,16 +92,17 @@ void main() { navigatorKey: GlobalKey(), topRedirect: (_, __) => null, ); - final RouteMatcher matcher = RouteMatcher(configuration); - final RouteMatchListCodec codec = RouteMatchListCodec(matcher); + final RouteMatchListCodec codec = RouteMatchListCodec(configuration); - final RouteMatchList list1 = matcher.findMatch('/a'); - final RouteMatchList list2 = matcher.findMatch('/b'); - list1.push(ImperativeRouteMatch( - pageKey: const ValueKey('/b-p0'), matches: list2)); + final RouteMatchList list1 = configuration.findMatch('/a'); + final RouteMatchList list2 = configuration.findMatch('/b'); + list1.push(ImperativeRouteMatch( + pageKey: const ValueKey('/b-p0'), + matches: list2, + completer: Completer())); - final Object? encoded = codec.encodeMatchList(list1); - final RouteMatchList? decoded = codec.decodeMatchList(encoded); + final Map encoded = codec.encode(list1); + final RouteMatchList decoded = codec.decode(encoded); expect(decoded, isNotNull); expect(decoded, equals(list1)); diff --git a/packages/go_router/test/parser_test.dart b/packages/go_router/test/parser_test.dart index 76a068b7d8..ee4279b515 100644 --- a/packages/go_router/test/parser_test.dart +++ b/packages/go_router/test/parser_test.dart @@ -6,15 +6,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:go_router/src/configuration.dart'; +import 'package:go_router/src/information_provider.dart'; import 'package:go_router/src/match.dart'; -import 'package:go_router/src/matching.dart'; import 'package:go_router/src/parser.dart'; -RouteInformation createRouteInformation(String location, [Object? state]) { - // TODO(chunhtai): remove this ignore and migrate the code - // https://github.com/flutter/flutter/issues/124045. - // ignore: deprecated_member_use - return RouteInformation(location: location, state: state); +RouteInformation createRouteInformation(String location, [Object? extra]) { + return RouteInformation( + // TODO(chunhtai): remove this ignore and migrate the code + // https://github.com/flutter/flutter/issues/124045. + // ignore: deprecated_member_use + location: location, + state: + RouteInformationState(type: NavigatingType.go, extra: extra)); } void main() { @@ -64,7 +67,7 @@ void main() { List matches = matchesObj.matches; expect(matches.length, 1); expect(matchesObj.uri.toString(), '/'); - expect(matches[0].extra, isNull); + expect(matchesObj.extra, isNull); expect(matches[0].matchedLocation, '/'); expect(matches[0].route, routes[0]); @@ -74,11 +77,10 @@ void main() { matches = matchesObj.matches; expect(matches.length, 2); expect(matchesObj.uri.toString(), '/abc?def=ghi'); - expect(matches[0].extra, extra); + expect(matchesObj.extra, extra); expect(matches[0].matchedLocation, '/'); expect(matches[0].route, routes[0]); - expect(matches[1].extra, extra); expect(matches[1].matchedLocation, '/abc'); expect(matches[1].route, routes[0].routes[0]); }); @@ -195,11 +197,10 @@ void main() { await parser.parseRouteInformationWithDependencies( createRouteInformation('/def'), context); final List matches = matchesObj.matches; - expect(matches.length, 1); + expect(matches.length, 0); expect(matchesObj.uri.toString(), '/def'); - expect(matches[0].extra, isNull); - expect(matches[0].matchedLocation, '/def'); - expect(matches[0].error!.toString(), + expect(matchesObj.extra, isNull); + expect(matchesObj.error!.toString(), 'Exception: no routes for location: /def'); }); @@ -267,10 +268,9 @@ void main() { expect(matchesObj.pathParameters.length, 2); expect(matchesObj.pathParameters['uid'], '123'); expect(matchesObj.pathParameters['fid'], '456'); - expect(matches[0].extra, isNull); + expect(matchesObj.extra, isNull); expect(matches[0].matchedLocation, '/'); - expect(matches[1].extra, isNull); expect(matches[1].matchedLocation, '/123/family/456'); }); @@ -399,8 +399,8 @@ void main() { createRouteInformation('/abd'), context); final List matches = matchesObj.matches; - expect(matches, hasLength(1)); - expect(matches.first.error, isNotNull); + expect(matches, hasLength(0)); + expect(matchesObj.error, isNotNull); }); testWidgets('Creates a match for ShellRoute', (WidgetTester tester) async { @@ -444,6 +444,6 @@ void main() { final List matches = matchesObj.matches; expect(matches, hasLength(2)); - expect(matches.first.error, isNull); + expect(matchesObj.error, isNull); }); } diff --git a/packages/go_router/test/test_helpers.dart b/packages/go_router/test/test_helpers.dart index 19f795ceda..53838382bf 100644 --- a/packages/go_router/test/test_helpers.dart +++ b/packages/go_router/test/test_helpers.dart @@ -301,8 +301,6 @@ RouteMatch createRouteMatch(RouteBase route, String location) { return RouteMatch( route: route, matchedLocation: location, - extra: null, - error: null, pageKey: ValueKey(location), ); }