[go_router]Fixes GoRouterState.location and GoRouterState.param to return correct value (#2786)

* Fixes GoRouterState.location and GoRouterState.param to return correct value

* update

* format
This commit is contained in:
chunhtai
2022-11-21 15:36:24 -08:00
committed by GitHub
parent e5c16e0740
commit 92c60ee420
18 changed files with 530 additions and 578 deletions

View File

@ -1,3 +1,8 @@
## 5.2.0
- Fixes `GoRouterState.location` and `GoRouterState.param` to return correct value.
- Cleans up `RouteMatch` and `RouteMatchList` API.
## 5.1.10 ## 5.1.10
- Fixes link of ShellRoute in README. - Fixes link of ShellRoute in README.

View File

@ -5,6 +5,7 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'configuration.dart'; import 'configuration.dart';
import 'delegate.dart';
import 'logging.dart'; import 'logging.dart';
import 'match.dart'; import 'match.dart';
import 'matching.dart'; import 'matching.dart';
@ -75,11 +76,7 @@ class RouteBuilder {
registry: _registry, child: result); registry: _registry, child: result);
} on _RouteBuilderError catch (e) { } on _RouteBuilderError catch (e) {
return _buildErrorNavigator( return _buildErrorNavigator(
context, context, e, matchList.uri, pop, configuration.navigatorKey);
e,
Uri.parse(matchList.location.toString()),
pop,
configuration.navigatorKey);
} }
}, },
), ),
@ -124,13 +121,12 @@ class RouteBuilder {
try { try {
final Map<GlobalKey<NavigatorState>, List<Page<Object?>>> keyToPage = final Map<GlobalKey<NavigatorState>, List<Page<Object?>>> keyToPage =
<GlobalKey<NavigatorState>, List<Page<Object?>>>{}; <GlobalKey<NavigatorState>, List<Page<Object?>>>{};
final Map<String, String> params = <String, String>{};
_buildRecursive(context, matchList, 0, onPop, routerNeglect, keyToPage, _buildRecursive(context, matchList, 0, onPop, routerNeglect, keyToPage,
params, navigatorKey, registry); navigatorKey, registry);
return keyToPage[navigatorKey]!; return keyToPage[navigatorKey]!;
} on _RouteBuilderError catch (e) { } on _RouteBuilderError catch (e) {
return <Page<Object?>>[ return <Page<Object?>>[
_buildErrorPage(context, e, matchList.location), _buildErrorPage(context, e, matchList.uri),
]; ];
} }
} }
@ -142,7 +138,6 @@ class RouteBuilder {
VoidCallback pop, VoidCallback pop,
bool routerNeglect, bool routerNeglect,
Map<GlobalKey<NavigatorState>, List<Page<Object?>>> keyToPages, Map<GlobalKey<NavigatorState>, List<Page<Object?>>> keyToPages,
Map<String, String> params,
GlobalKey<NavigatorState> navigatorKey, GlobalKey<NavigatorState> navigatorKey,
Map<Page<Object?>, GoRouterState> registry, Map<Page<Object?>, GoRouterState> registry,
) { ) {
@ -157,11 +152,7 @@ class RouteBuilder {
} }
final RouteBase route = match.route; final RouteBase route = match.route;
final Map<String, String> newParams = <String, String>{ final GoRouterState state = buildState(matchList, match);
...params,
...match.decodedParams
};
final GoRouterState state = buildState(match, newParams);
if (route is GoRoute) { if (route is GoRoute) {
final Page<Object?> page = _buildPageForRoute(context, state, match); final Page<Object?> page = _buildPageForRoute(context, state, match);
registry[page] = state; registry[page] = state;
@ -173,7 +164,7 @@ class RouteBuilder {
keyToPages.putIfAbsent(goRouteNavKey, () => <Page<Object?>>[]).add(page); keyToPages.putIfAbsent(goRouteNavKey, () => <Page<Object?>>[]).add(page);
_buildRecursive(context, matchList, startIndex + 1, pop, routerNeglect, _buildRecursive(context, matchList, startIndex + 1, pop, routerNeglect,
keyToPages, newParams, navigatorKey, registry); keyToPages, navigatorKey, registry);
} else if (route is ShellRoute) { } else if (route is ShellRoute) {
// The key for the Navigator that will display this ShellRoute's page. // The key for the Navigator that will display this ShellRoute's page.
final GlobalKey<NavigatorState> parentNavigatorKey = navigatorKey; final GlobalKey<NavigatorState> parentNavigatorKey = navigatorKey;
@ -194,7 +185,7 @@ class RouteBuilder {
// Build the remaining pages // Build the remaining pages
_buildRecursive(context, matchList, startIndex + 1, pop, routerNeglect, _buildRecursive(context, matchList, startIndex + 1, pop, routerNeglect,
keyToPages, newParams, shellNavigatorKey, registry); keyToPages, shellNavigatorKey, registry);
// Build the Navigator // Build the Navigator
final Widget child = _buildNavigator( final Widget child = _buildNavigator(
@ -235,25 +226,27 @@ class RouteBuilder {
/// Helper method that builds a [GoRouterState] object for the given [match] /// Helper method that builds a [GoRouterState] object for the given [match]
/// and [params]. /// and [params].
@visibleForTesting @visibleForTesting
GoRouterState buildState(RouteMatch match, Map<String, String> params) { GoRouterState buildState(RouteMatchList matchList, RouteMatch match) {
final RouteBase route = match.route; final RouteBase route = match.route;
String? name = ''; String? name;
String path = ''; String path = '';
if (route is GoRoute) { if (route is GoRoute) {
name = route.name; name = route.name;
path = route.path; path = route.path;
} }
final RouteMatchList effectiveMatchList =
match is ImperativeRouteMatch ? match.matches : matchList;
return GoRouterState( return GoRouterState(
configuration, configuration,
location: match.fullUriString, location: effectiveMatchList.uri.toString(),
subloc: match.subloc, subloc: match.subloc,
name: name, name: name,
path: path, path: path,
fullpath: match.fullpath, fullpath: effectiveMatchList.fullpath,
params: params, params: effectiveMatchList.pathParameters,
error: match.error, error: match.error,
queryParams: match.queryParams, queryParams: effectiveMatchList.uri.queryParameters,
queryParametersAll: match.queryParametersAll, queryParametersAll: effectiveMatchList.uri.queryParametersAll,
extra: match.extra, extra: match.extra,
pageKey: match.pageKey, pageKey: match.pageKey,
); );
@ -425,6 +418,7 @@ class RouteBuilder {
queryParams: uri.queryParameters, queryParams: uri.queryParameters,
queryParametersAll: uri.queryParametersAll, queryParametersAll: uri.queryParametersAll,
error: Exception(error), error: Exception(error),
pageKey: const ValueKey<String>('error'),
); );
// If the error page builder is provided, use that, otherwise, if the error // If the error page builder is provided, use that, otherwise, if the error

View File

@ -6,9 +6,9 @@ import 'package:flutter/widgets.dart';
import 'configuration.dart'; import 'configuration.dart';
import 'logging.dart'; import 'logging.dart';
import 'misc/errors.dart';
import 'path_utils.dart'; import 'path_utils.dart';
import 'typedefs.dart'; import 'typedefs.dart';
export 'route.dart'; export 'route.dart';
export 'state.dart'; export 'state.dart';
@ -20,34 +20,42 @@ class RouteConfiguration {
required this.redirectLimit, required this.redirectLimit,
required this.topRedirect, required this.topRedirect,
required this.navigatorKey, required this.navigatorKey,
}) { }) : assert(_debugCheckPath(routes, true)),
assert(
_debugVerifyNoDuplicatePathParameter(routes, <String, GoRoute>{})),
assert(_debugCheckParentNavigatorKeys(
routes, <GlobalKey<NavigatorState>>[navigatorKey])) {
_cacheNameToPath('', routes); _cacheNameToPath('', routes);
log.info(_debugKnownRoutes()); log.info(_debugKnownRoutes());
}
assert(() { static bool _debugCheckPath(List<RouteBase> routes, bool isTopLevel) {
for (final RouteBase route in routes) {
if (route is GoRoute && !route.path.startsWith('/')) {
assert(route.path.startsWith('/'),
'top-level path must start with "/": ${route.path}');
} else if (route is ShellRoute) {
for (final RouteBase route in routes) { for (final RouteBase route in routes) {
late bool subRouteIsTopLevel;
if (route is GoRoute) { if (route is GoRoute) {
if (isTopLevel) {
assert(route.path.startsWith('/'), assert(route.path.startsWith('/'),
'top-level path must start with "/": ${route.path}'); '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 ShellRoute) {
subRouteIsTopLevel = isTopLevel;
} }
_debugCheckPath(route.routes, subRouteIsTopLevel);
} }
return true;
} }
// Check that each parentNavigatorKey refers to either a ShellRoute's // Check that each parentNavigatorKey refers to either a ShellRoute's
// navigatorKey or the root navigator key. // navigatorKey or the root navigator key.
void checkParentNavigatorKeys( static bool _debugCheckParentNavigatorKeys(
List<RouteBase> routes, List<GlobalKey<NavigatorState>> allowedKeys) { List<RouteBase> routes, List<GlobalKey<NavigatorState>> allowedKeys) {
for (final RouteBase route in routes) { for (final RouteBase route in routes) {
if (route is GoRoute) { if (route is GoRoute) {
final GlobalKey<NavigatorState>? parentKey = final GlobalKey<NavigatorState>? parentKey = route.parentNavigatorKey;
route.parentNavigatorKey;
if (parentKey != null) { if (parentKey != null) {
// Verify that the root navigator or a ShellRoute ancestor has a // Verify that the root navigator or a ShellRoute ancestor has a
// matching navigator key. // matching navigator key.
@ -57,7 +65,7 @@ class RouteConfiguration {
" an ancestor ShellRoute's navigatorKey or GoRouter's" " an ancestor ShellRoute's navigatorKey or GoRouter's"
' navigatorKey'); ' navigatorKey');
checkParentNavigatorKeys( _debugCheckParentNavigatorKeys(
route.routes, route.routes,
<GlobalKey<NavigatorState>>[ <GlobalKey<NavigatorState>>[
// Once a parentNavigatorKey is used, only that navigator key // Once a parentNavigatorKey is used, only that navigator key
@ -66,7 +74,7 @@ class RouteConfiguration {
], ],
); );
} else { } else {
checkParentNavigatorKeys( _debugCheckParentNavigatorKeys(
route.routes, route.routes,
<GlobalKey<NavigatorState>>[ <GlobalKey<NavigatorState>>[
...allowedKeys, ...allowedKeys,
@ -74,20 +82,33 @@ class RouteConfiguration {
); );
} }
} else if (route is ShellRoute && route.navigatorKey != null) { } else if (route is ShellRoute && route.navigatorKey != null) {
checkParentNavigatorKeys( _debugCheckParentNavigatorKeys(
route.routes, route.routes,
<GlobalKey<NavigatorState>>[ <GlobalKey<NavigatorState>>[...allowedKeys..add(route.navigatorKey)],
...allowedKeys..add(route.navigatorKey)
],
); );
} }
} }
return true;
} }
checkParentNavigatorKeys( static bool _debugVerifyNoDuplicatePathParameter(
routes, <GlobalKey<NavigatorState>>[navigatorKey]); List<RouteBase> routes, Map<String, GoRoute> usedPathParams) {
for (final RouteBase route in routes) {
if (route is! GoRoute) {
continue;
}
for (final String pathParam in route.pathParams) {
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.pathParams.forEach(usedPathParams.remove);
}
return true; return true;
}());
} }
/// The list of top level routes used by [GoRouterDelegate]. /// The list of top level routes used by [GoRouterDelegate].

View File

@ -11,7 +11,6 @@ import 'builder.dart';
import 'configuration.dart'; import 'configuration.dart';
import 'match.dart'; import 'match.dart';
import 'matching.dart'; import 'matching.dart';
import 'misc/errors.dart';
import 'typedefs.dart'; import 'typedefs.dart';
/// GoRouter implementation of [RouterDelegate]. /// GoRouter implementation of [RouterDelegate].
@ -44,7 +43,7 @@ class GoRouterDelegate extends RouterDelegate<RouteMatchList>
/// Set to true to disable creating history entries on the web. /// Set to true to disable creating history entries on the web.
final bool routerNeglect; final bool routerNeglect;
RouteMatchList _matchList = RouteMatchList.empty(); RouteMatchList _matchList = RouteMatchList.empty;
/// Stores the number of times each route route has been pushed. /// Stores the number of times each route route has been pushed.
/// ///
@ -95,26 +94,21 @@ class GoRouterDelegate extends RouterDelegate<RouteMatchList>
} }
/// Pushes the given location onto the page stack /// Pushes the given location onto the page stack
void push(RouteMatch match) { void push(RouteMatchList matches) {
if (match.route is ShellRoute) { assert(matches.last.route is! ShellRoute);
throw GoError('ShellRoutes cannot be pushed');
}
// Remap the pageKey to allow any number of the same page on the stack // Remap the pageKey to allow any number of the same page on the stack
final String fullPath = match.fullpath; final int count = (_pushCounts[matches.fullpath] ?? 0) + 1;
final int count = (_pushCounts[fullPath] ?? 0) + 1; _pushCounts[matches.fullpath] = count;
_pushCounts[fullPath] = count; final ValueKey<String> pageKey =
final ValueKey<String> pageKey = ValueKey<String>('$fullPath-p$count'); ValueKey<String>('${matches.fullpath}-p$count');
final RouteMatch newPageKeyMatch = RouteMatch( final ImperativeRouteMatch newPageKeyMatch = ImperativeRouteMatch(
route: match.route, route: matches.last.route,
subloc: match.subloc, subloc: matches.last.subloc,
fullpath: match.fullpath, extra: matches.last.extra,
encodedParams: match.encodedParams, error: matches.last.error,
queryParams: match.queryParams,
queryParametersAll: match.queryParametersAll,
extra: match.extra,
error: match.error,
pageKey: pageKey, pageKey: pageKey,
matches: matches,
); );
_matchList.push(newPageKeyMatch); _matchList.push(newPageKeyMatch);
@ -170,9 +164,9 @@ class GoRouterDelegate extends RouterDelegate<RouteMatchList>
/// ///
/// See also: /// See also:
/// * [push] which pushes the given location onto the page stack. /// * [push] which pushes the given location onto the page stack.
void replace(RouteMatch match) { void replace(RouteMatchList matches) {
_matchList.pop(); _matchList.pop();
push(match); // [push] will notify the listeners. push(matches); // [push] will notify the listeners.
} }
/// For internal use; visible for testing only. /// For internal use; visible for testing only.
@ -209,3 +203,20 @@ class GoRouterDelegate extends RouterDelegate<RouteMatchList>
return SynchronousFuture<void>(null); return SynchronousFuture<void>(null);
} }
} }
/// The route match that represent route pushed through [GoRouter.push].
// TODO(chunhtai): Removes this once imperative API no longer insert route match.
class ImperativeRouteMatch extends RouteMatch {
/// Constructor for [ImperativeRouteMatch].
ImperativeRouteMatch({
required super.route,
required super.subloc,
required super.extra,
required super.error,
required super.pageKey,
required this.matches,
});
/// The matches that produces this route match.
final RouteMatchList matches;
}

View File

@ -15,46 +15,25 @@ class RouteMatch {
RouteMatch({ RouteMatch({
required this.route, required this.route,
required this.subloc, required this.subloc,
required this.fullpath,
required this.encodedParams,
required this.queryParams,
required this.queryParametersAll,
required this.extra, required this.extra,
required this.error, required this.error,
this.pageKey, required this.pageKey,
}) : fullUriString = _addQueryParams(subloc, queryParametersAll), });
assert(Uri.parse(subloc).queryParameters.isEmpty),
assert(Uri.parse(fullpath).queryParameters.isEmpty),
assert(() {
for (final MapEntry<String, String> p in encodedParams.entries) {
assert(p.value == Uri.encodeComponent(Uri.decodeComponent(p.value)),
'encodedParams[${p.key}] is not encoded properly: "${p.value}"');
}
return true;
}());
// ignore: public_member_api_docs // ignore: public_member_api_docs
static RouteMatch? match({ static RouteMatch? match({
required RouteBase route, required RouteBase route,
required String restLoc, // e.g. person/p1 required String restLoc, // e.g. person/p1
required String parentSubloc, // e.g. /family/f2 required String parentSubloc, // e.g. /family/f2
required String fullpath, // e.g. /family/:fid/person/:pid required Map<String, String> pathParameters,
required Map<String, String> queryParams,
required Map<String, List<String>> queryParametersAll,
required Object? extra, required Object? extra,
}) { }) {
if (route is ShellRoute) { if (route is ShellRoute) {
return RouteMatch( return RouteMatch(
route: route, route: route,
subloc: restLoc, subloc: restLoc,
fullpath: '',
encodedParams: <String, String>{},
queryParams: queryParams,
queryParametersAll: queryParametersAll,
extra: extra, extra: extra,
error: null, error: null,
// Provide a unique pageKey to ensure that the page for this ShellRoute is
// reused.
pageKey: ValueKey<String>(route.hashCode.toString()), pageKey: ValueKey<String>(route.hashCode.toString()),
); );
} else if (route is GoRoute) { } else if (route is GoRoute) {
@ -66,17 +45,17 @@ class RouteMatch {
} }
final Map<String, String> encodedParams = route.extractPathParams(match); final Map<String, String> encodedParams = route.extractPathParams(match);
for (final MapEntry<String, String> param in encodedParams.entries) {
pathParameters[param.key] = Uri.decodeComponent(param.value);
}
final String pathLoc = patternToPath(route.path, encodedParams); final String pathLoc = patternToPath(route.path, encodedParams);
final String subloc = concatenatePaths(parentSubloc, pathLoc); final String subloc = concatenatePaths(parentSubloc, pathLoc);
return RouteMatch( return RouteMatch(
route: route, route: route,
subloc: subloc, subloc: subloc,
fullpath: fullpath,
encodedParams: encodedParams,
queryParams: queryParams,
queryParametersAll: queryParametersAll,
extra: extra, extra: extra,
error: null, error: null,
pageKey: ValueKey<String>(route.hashCode.toString()),
); );
} }
throw MatcherError('Unexpected route type: $route', restLoc); throw MatcherError('Unexpected route type: $route', restLoc);
@ -88,41 +67,6 @@ class RouteMatch {
/// The matched location. /// The matched location.
final String subloc; // e.g. /family/f2 final String subloc; // e.g. /family/f2
/// The matched template.
final String fullpath; // e.g. /family/:fid
/// Parameters for the matched route, URI-encoded.
final Map<String, String> encodedParams;
/// The URI query split into a map according to the rules specified for FORM
/// post in the [HTML 4.01 specification section
/// 17.13.4](https://www.w3.org/TR/REC-html40/interact/forms.html#h-17.13.4
/// "HTML 4.01 section 17.13.4").
///
/// If a key occurs more than once in the query string, it is mapped to an
/// arbitrary choice of possible value.
///
/// If the request is `a/b/?q1=v1&q2=v2&q2=v3`, then [queryParameter] will be
/// `{q1: 'v1', q2: 'v2'}`.
///
/// See also
/// * [queryParametersAll] that can provide a map that maps keys to all of
/// their values.
final Map<String, String> queryParams;
/// Returns the URI query split into a map according to the rules specified
/// for FORM post in the [HTML 4.01 specification section
/// 17.13.4](https://www.w3.org/TR/REC-html40/interact/forms.html#h-17.13.4
/// "HTML 4.01 section 17.13.4").
///
/// Keys are mapped to lists of their values. If a key occurs only once, its
/// value is a singleton list. If a key occurs with no value, the empty string
/// is used as the value for that occurrence.
///
/// If the request is `a/b/?q1=v1&q2=v2&q2=v3`, then [queryParameterAll] with
/// be `{q1: ['v1'], q2: ['v2', 'v3']}`.
final Map<String, List<String>> queryParametersAll;
/// An extra object to pass along with the navigation. /// An extra object to pass along with the navigation.
final Object? extra; final Object? extra;
@ -130,29 +74,5 @@ class RouteMatch {
final Exception? error; final Exception? error;
/// Optional value key of type string, to hold a unique reference to a page. /// Optional value key of type string, to hold a unique reference to a page.
final ValueKey<String>? pageKey; final ValueKey<String> pageKey;
/// The full uri string
final String fullUriString; // e.g. /family/12?query=14
static String _addQueryParams(
String loc, Map<String, dynamic> queryParametersAll) {
final Uri uri = Uri.parse(loc);
assert(uri.queryParameters.isEmpty);
return Uri(
path: uri.path,
queryParameters:
queryParametersAll.isEmpty ? null : queryParametersAll)
.toString();
}
/// Parameters for the matched route, URI-decoded.
Map<String, String> get decodedParams => <String, String>{
for (final MapEntry<String, String> param in encodedParams.entries)
param.key: Uri.decodeComponent(param.value)
};
/// For use by the Router architecture as part of the RouteMatch
@override
String toString() => 'RouteMatch($fullpath, $encodedParams)';
} }

View File

@ -18,27 +18,27 @@ class RouteMatcher {
/// Finds the routes that matched the given URL. /// Finds the routes that matched the given URL.
RouteMatchList findMatch(String location, {Object? extra}) { RouteMatchList findMatch(String location, {Object? extra}) {
final String canonicalLocation = canonicalUri(location); final Uri uri = Uri.parse(canonicalUri(location));
final Map<String, String> pathParameters = <String, String>{};
final List<RouteMatch> matches = final List<RouteMatch> matches =
_getLocRouteMatches(canonicalLocation, extra); _getLocRouteMatches(uri, extra, pathParameters);
return RouteMatchList(matches); return RouteMatchList(matches, uri, pathParameters);
} }
List<RouteMatch> _getLocRouteMatches(String location, Object? extra) { List<RouteMatch> _getLocRouteMatches(
final Uri uri = Uri.parse(location); Uri uri, Object? extra, Map<String, String> pathParameters) {
final List<RouteMatch> result = _getLocRouteRecursively( final List<RouteMatch>? result = _getLocRouteRecursively(
loc: uri.path, loc: uri.path,
restLoc: uri.path, restLoc: uri.path,
routes: configuration.routes, routes: configuration.routes,
parentFullpath: '',
parentSubloc: '', parentSubloc: '',
queryParams: uri.queryParameters, pathParameters: pathParameters,
queryParametersAll: uri.queryParametersAll,
extra: extra, extra: extra,
); );
if (result.isEmpty) { if (result == null) {
throw MatcherError('no routes for location', location); throw MatcherError('no routes for location', uri.toString());
} }
return result; return result;
@ -48,23 +48,48 @@ class RouteMatcher {
/// The list of [RouteMatch] objects. /// The list of [RouteMatch] objects.
class RouteMatchList { class RouteMatchList {
/// RouteMatchList constructor. /// RouteMatchList constructor.
RouteMatchList(this._matches); RouteMatchList(List<RouteMatch> matches, this.uri, this.pathParameters)
: _matches = matches,
fullpath = _generateFullPath(matches);
/// Constructs an empty matches object. /// Constructs an empty matches object.
factory RouteMatchList.empty() => RouteMatchList(<RouteMatch>[]); static RouteMatchList empty =
RouteMatchList(<RouteMatch>[], Uri.parse(''), const <String, String>{});
static String _generateFullPath(List<RouteMatch> matches) {
final StringBuffer buffer = StringBuffer();
bool addsSlash = false;
for (final RouteMatch match in matches) {
final RouteBase route = match.route;
if (route is GoRoute) {
if (addsSlash) {
buffer.write('/');
}
buffer.write(route.path);
addsSlash = addsSlash || route.path != '/';
}
}
return buffer.toString();
}
final List<RouteMatch> _matches; final List<RouteMatch> _matches;
/// the full path pattern that matches the uri.
/// /family/:fid/person/:pid
final String fullpath;
/// Parameters for the matched route, URI-encoded.
final Map<String, String> pathParameters;
/// The uri of the current match.
final Uri uri;
/// Returns true if there are no matches. /// Returns true if there are no matches.
bool get isEmpty => _matches.isEmpty; bool get isEmpty => _matches.isEmpty;
/// Returns true if there are matches. /// Returns true if there are matches.
bool get isNotEmpty => _matches.isNotEmpty; bool get isNotEmpty => _matches.isNotEmpty;
/// The original URL that was matched.
Uri get location =>
_matches.isEmpty ? Uri() : Uri.parse(_matches.last.fullUriString);
/// Pushes a match onto the list of matches. /// Pushes a match onto the list of matches.
void push(RouteMatch match) { void push(RouteMatch match) {
_matches.add(match); _matches.add(match);
@ -113,38 +138,25 @@ class MatcherError extends Error {
} }
} }
List<RouteMatch> _getLocRouteRecursively({ List<RouteMatch>? _getLocRouteRecursively({
required String loc, required String loc,
required String restLoc, required String restLoc,
required String parentSubloc, required String parentSubloc,
required List<RouteBase> routes, required List<RouteBase> routes,
required String parentFullpath, required Map<String, String> pathParameters,
required Map<String, String> queryParams,
required Map<String, List<String>> queryParametersAll,
required Object? extra, required Object? extra,
}) { }) {
bool debugGatherAllMatches = false; List<RouteMatch>? result;
assert(() { late Map<String, String> subPathParameters;
debugGatherAllMatches = true;
return true;
}());
final List<List<RouteMatch>> result = <List<RouteMatch>>[];
// find the set of matches at this level of the tree // find the set of matches at this level of the tree
for (final RouteBase route in routes) { for (final RouteBase route in routes) {
late final String fullpath; subPathParameters = <String, String>{};
if (route is GoRoute) {
fullpath = concatenatePaths(parentFullpath, route.path);
} else if (route is ShellRoute) {
fullpath = parentFullpath;
}
final RouteMatch? match = RouteMatch.match( final RouteMatch? match = RouteMatch.match(
route: route, route: route,
restLoc: restLoc, restLoc: restLoc,
parentSubloc: parentSubloc, parentSubloc: parentSubloc,
fullpath: fullpath, pathParameters: subPathParameters,
queryParams: queryParams,
queryParametersAll: queryParametersAll,
extra: extra, extra: extra,
); );
@ -157,7 +169,7 @@ List<RouteMatch> _getLocRouteRecursively({
// If it is a complete match, then return the matched route // If it is a complete match, then return the matched route
// NOTE: need a lower case match because subloc is canonicalized to match // NOTE: need a lower case match because subloc is canonicalized to match
// the path case whereas the location can be of any case and still match // the path case whereas the location can be of any case and still match
result.add(<RouteMatch>[match]); result = <RouteMatch>[match];
} else if (route.routes.isEmpty) { } else if (route.routes.isEmpty) {
// If it is partial match but no sub-routes, bail. // If it is partial match but no sub-routes, bail.
continue; continue;
@ -177,51 +189,37 @@ List<RouteMatch> _getLocRouteRecursively({
newParentSubLoc = match.subloc; newParentSubLoc = match.subloc;
} }
final List<RouteMatch> subRouteMatch = _getLocRouteRecursively( final List<RouteMatch>? subRouteMatch = _getLocRouteRecursively(
loc: loc, loc: loc,
restLoc: childRestLoc, restLoc: childRestLoc,
parentSubloc: newParentSubLoc, parentSubloc: newParentSubLoc,
routes: route.routes, routes: route.routes,
parentFullpath: fullpath, pathParameters: subPathParameters,
queryParams: queryParams,
queryParametersAll: queryParametersAll,
extra: extra, extra: extra,
).toList(); );
// If there's no sub-route matches, there is no match for this location // If there's no sub-route matches, there is no match for this location
if (subRouteMatch.isEmpty) { if (subRouteMatch == null) {
continue; continue;
} }
result.add(<RouteMatch>[match, ...subRouteMatch]); result = <RouteMatch>[match, ...subRouteMatch];
} }
// Should only reach here if there is a match. // Should only reach here if there is a match.
if (debugGatherAllMatches) {
continue;
} else {
break; break;
} }
if (result != null) {
pathParameters.addAll(subPathParameters);
} }
return result;
if (result.isEmpty) {
return <RouteMatch>[];
}
// If there are multiple routes that match the location, returning the first one.
// To make predefined routes to take precedence over dynamic routes eg. '/:id'
// consider adding the dynamic route at the end of the routes
return result.first;
} }
/// The match used when there is an error during parsing. /// The match used when there is an error during parsing.
RouteMatchList errorScreen(Uri uri, String errorMessage) { RouteMatchList errorScreen(Uri uri, String errorMessage) {
final Exception error = Exception(errorMessage); final Exception error = Exception(errorMessage);
return RouteMatchList(<RouteMatch>[ return RouteMatchList(
<RouteMatch>[
RouteMatch( RouteMatch(
subloc: uri.path, subloc: uri.path,
fullpath: uri.path,
encodedParams: <String, String>{},
queryParams: uri.queryParameters,
queryParametersAll: uri.queryParametersAll,
extra: null, extra: null,
error: error, error: error,
route: GoRoute( route: GoRoute(
@ -230,6 +228,9 @@ RouteMatchList errorScreen(Uri uri, String errorMessage) {
throw UnimplementedError(); throw UnimplementedError();
}, },
), ),
pageKey: const ValueKey<String>('error'),
), ),
]); ],
uri,
const <String, String>{});
} }

View File

@ -62,7 +62,7 @@ class GoRouteInformationParser extends RouteInformationParser<RouteMatchList> {
// If there is a matching error for the initial location, we should // If there is a matching error for the initial location, we should
// still try to process the top-level redirects. // still try to process the top-level redirects.
initialMatches = RouteMatchList.empty(); initialMatches = RouteMatchList.empty;
} }
Future<RouteMatchList> processRedirectorResult(RouteMatchList matches) { Future<RouteMatchList> processRedirectorResult(RouteMatchList matches) {
if (matches.isEmpty) { if (matches.isEmpty) {
@ -99,7 +99,7 @@ class GoRouteInformationParser extends RouteInformationParser<RouteMatchList> {
@override @override
RouteInformation restoreRouteInformation(RouteMatchList configuration) { RouteInformation restoreRouteInformation(RouteMatchList configuration) {
return RouteInformation( return RouteInformation(
location: configuration.location.toString(), location: configuration.uri.toString(),
state: configuration.extra, state: configuration.extra,
); );
} }

View File

@ -26,13 +26,13 @@ FutureOr<RouteMatchList> redirect(
{List<RouteMatchList>? redirectHistory, {List<RouteMatchList>? redirectHistory,
Object? extra}) { Object? extra}) {
FutureOr<RouteMatchList> processRedirect(RouteMatchList prevMatchList) { FutureOr<RouteMatchList> processRedirect(RouteMatchList prevMatchList) {
final String prevLocation = prevMatchList.location.toString(); final String prevLocation = prevMatchList.uri.toString();
FutureOr<RouteMatchList> processTopLevelRedirect( FutureOr<RouteMatchList> processTopLevelRedirect(
String? topRedirectLocation) { String? topRedirectLocation) {
if (topRedirectLocation != null && topRedirectLocation != prevLocation) { if (topRedirectLocation != null && topRedirectLocation != prevLocation) {
final RouteMatchList newMatch = _getNewMatches( final RouteMatchList newMatch = _getNewMatches(
topRedirectLocation, topRedirectLocation,
prevMatchList.location, prevMatchList.uri,
configuration, configuration,
matcher, matcher,
redirectHistory!, redirectHistory!,
@ -50,24 +50,13 @@ FutureOr<RouteMatchList> redirect(
); );
} }
// Merge new params to keep params from previously matched paths, e.g.
// /users/:userId/book/:bookId provides userId and bookId to bookgit /:bookId
Map<String, String> previouslyMatchedParams = <String, String>{};
for (final RouteMatch match in prevMatchList.matches) {
assert(
!previouslyMatchedParams.keys.any(match.encodedParams.containsKey),
'Duplicated parameter names',
);
match.encodedParams.addAll(previouslyMatchedParams);
previouslyMatchedParams = match.encodedParams;
}
FutureOr<RouteMatchList> processRouteLevelRedirect( FutureOr<RouteMatchList> processRouteLevelRedirect(
String? routeRedirectLocation) { String? routeRedirectLocation) {
if (routeRedirectLocation != null && if (routeRedirectLocation != null &&
routeRedirectLocation != prevLocation) { routeRedirectLocation != prevLocation) {
final RouteMatchList newMatch = _getNewMatches( final RouteMatchList newMatch = _getNewMatches(
routeRedirectLocation, routeRedirectLocation,
prevMatchList.location, prevMatchList.uri,
configuration, configuration,
matcher, matcher,
redirectHistory!, redirectHistory!,
@ -99,7 +88,6 @@ FutureOr<RouteMatchList> redirect(
redirectHistory ??= <RouteMatchList>[prevMatchList]; redirectHistory ??= <RouteMatchList>[prevMatchList];
// Check for top-level redirect // Check for top-level redirect
final Uri uri = prevMatchList.location;
final FutureOr<String?> topRedirectResult = configuration.topRedirect( final FutureOr<String?> topRedirectResult = configuration.topRedirect(
context, context,
GoRouterState( GoRouterState(
@ -108,10 +96,11 @@ FutureOr<RouteMatchList> redirect(
name: null, name: null,
// No name available at the top level trim the query params off the // No name available at the top level trim the query params off the
// sub-location to match route.redirect // sub-location to match route.redirect
subloc: uri.path, subloc: prevMatchList.uri.path,
queryParams: uri.queryParameters, queryParams: prevMatchList.uri.queryParameters,
queryParametersAll: uri.queryParametersAll, queryParametersAll: prevMatchList.uri.queryParametersAll,
extra: extra, extra: extra,
pageKey: const ValueKey<String>('topLevel'),
), ),
); );
@ -148,15 +137,16 @@ FutureOr<String?> _getRouteLevelRedirect(
context, context,
GoRouterState( GoRouterState(
configuration, configuration,
location: matchList.location.toString(), location: matchList.uri.toString(),
subloc: match.subloc, subloc: match.subloc,
name: route.name, name: route.name,
path: route.path, path: route.path,
fullpath: match.fullpath, fullpath: matchList.fullpath,
extra: match.extra, extra: match.extra,
params: match.decodedParams, params: matchList.pathParameters,
queryParams: match.queryParams, queryParams: matchList.uri.queryParameters,
queryParametersAll: match.queryParametersAll, queryParametersAll: matchList.uri.queryParametersAll,
pageKey: match.pageKey,
), ),
); );
} }
@ -216,8 +206,8 @@ class RedirectionError extends Error implements UnsupportedError {
@override @override
String toString() => '${super.toString()} ${<String>[ String toString() => '${super.toString()} ${<String>[
...matches.map( ...matches
(RouteMatchList routeMatches) => routeMatches.location.toString()), .map((RouteMatchList routeMatches) => routeMatches.uri.toString()),
].join(' => ')}'; ].join(' => ')}';
} }

View File

@ -2,8 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:meta/meta.dart';
import 'configuration.dart'; import 'configuration.dart';
import 'pages/custom_transition_page.dart'; import 'pages/custom_transition_page.dart';
@ -131,40 +131,14 @@ class GoRoute extends RouteBase {
this.pageBuilder, this.pageBuilder,
this.parentNavigatorKey, this.parentNavigatorKey,
this.redirect, this.redirect,
List<RouteBase> routes = const <RouteBase>[], super.routes = const <RouteBase>[],
}) : assert(path.isNotEmpty, 'GoRoute path cannot be empty'), }) : assert(path.isNotEmpty, 'GoRoute path cannot be empty'),
assert(name == null || name.isNotEmpty, 'GoRoute name cannot be empty'), assert(name == null || name.isNotEmpty, 'GoRoute name cannot be empty'),
assert(pageBuilder != null || builder != null || redirect != null, assert(pageBuilder != null || builder != null || redirect != null,
'builder, pageBuilder, or redirect must be provided'), 'builder, pageBuilder, or redirect must be provided'),
super._( super._() {
routes: routes,
) {
// cache the path regexp and parameters // cache the path regexp and parameters
_pathRE = patternToRegExp(path, _pathParams); _pathRE = patternToRegExp(path, pathParams);
assert(() {
// check path params
final Map<String, List<String>> groupedParams =
_pathParams.groupListsBy<String>((String p) => p);
final Map<String, List<String>> dupParams =
Map<String, List<String>>.fromEntries(
groupedParams.entries
.where((MapEntry<String, List<String>> e) => e.value.length > 1),
);
assert(dupParams.isEmpty,
'duplicate path params: ${dupParams.keys.join(', ')}');
// check sub-routes
for (final RouteBase route in routes) {
// check paths
if (route is GoRoute) {
assert(
route.path == '/' ||
(!route.path.startsWith('/') && !route.path.endsWith('/')),
'sub-route path may not start or end with /: ${route.path}');
}
}
return true;
}());
} }
/// Optional name of the route. /// Optional name of the route.
@ -332,9 +306,16 @@ class GoRoute extends RouteBase {
/// Extract the path parameters from a match. /// Extract the path parameters from a match.
Map<String, String> extractPathParams(RegExpMatch match) => Map<String, String> extractPathParams(RegExpMatch match) =>
extractPathParameters(_pathParams, match); extractPathParameters(pathParams, match);
final List<String> _pathParams = <String>[]; /// The path parameters in this route.
@internal
final List<String> pathParams = <String>[];
@override
String toString() {
return 'GoRoute(name: $name, path: $path)';
}
late final RegExp _pathRE; late final RegExp _pathRE;
} }

View File

@ -146,8 +146,18 @@ class GoRouter extends ChangeNotifier implements RouterConfig<RouteMatchList> {
bool canPop() => _routerDelegate.canPop(); bool canPop() => _routerDelegate.canPop();
void _handleStateMayChange() { void _handleStateMayChange() {
final String newLocation = final String newLocation;
_routerDelegate.currentConfiguration.location.toString(); if (routerDelegate.currentConfiguration.isNotEmpty &&
routerDelegate.currentConfiguration.matches.last
is ImperativeRouteMatch) {
newLocation = (routerDelegate.currentConfiguration.matches.last
as ImperativeRouteMatch)
.matches
.uri
.toString();
} else {
newLocation = _routerDelegate.currentConfiguration.uri.toString();
}
if (_location != newLocation) { if (_location != newLocation) {
_location = newLocation; _location = newLocation;
notifyListeners(); notifyListeners();
@ -207,7 +217,7 @@ class GoRouter extends ChangeNotifier implements RouterConfig<RouteMatchList> {
_routerDelegate.navigatorKey.currentContext!, _routerDelegate.navigatorKey.currentContext!,
) )
.then<void>((RouteMatchList matches) { .then<void>((RouteMatchList matches) {
_routerDelegate.push(matches.last); _routerDelegate.push(matches);
}); });
} }
@ -239,7 +249,7 @@ class GoRouter extends ChangeNotifier implements RouterConfig<RouteMatchList> {
_routerDelegate.navigatorKey.currentContext!, _routerDelegate.navigatorKey.currentContext!,
) )
.then<void>((RouteMatchList matchList) { .then<void>((RouteMatchList matchList) {
routerDelegate.replace(matchList.matches.last); routerDelegate.replace(matchList);
}); });
} }

View File

@ -14,7 +14,7 @@ import 'misc/errors.dart';
@immutable @immutable
class GoRouterState { class GoRouterState {
/// Default constructor for creating route state during routing. /// Default constructor for creating route state during routing.
GoRouterState( const GoRouterState(
this._configuration, { this._configuration, {
required this.location, required this.location,
required this.subloc, required this.subloc,
@ -26,13 +26,8 @@ class GoRouterState {
this.queryParametersAll = const <String, List<String>>{}, this.queryParametersAll = const <String, List<String>>{},
this.extra, this.extra,
this.error, this.error,
ValueKey<String>? pageKey, required this.pageKey,
}) : pageKey = pageKey ?? });
ValueKey<String>(error != null
? 'error'
: fullpath != null && fullpath.isNotEmpty
? fullpath
: subloc);
// TODO(johnpryan): remove once namedLocation is removed from go_router. // TODO(johnpryan): remove once namedLocation is removed from go_router.
// See https://github.com/flutter/flutter/issues/107729 // See https://github.com/flutter/flutter/issues/107729

View File

@ -1,7 +1,7 @@
name: go_router name: go_router
description: A declarative router for Flutter based on Navigation 2 supporting description: A declarative router for Flutter based on Navigation 2 supporting
deep linking, data-driven routes and more deep linking, data-driven routes and more
version: 5.1.10 version: 5.2.0
repository: https://github.com/flutter/packages/tree/main/packages/go_router 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 issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+go_router%22

View File

@ -28,18 +28,18 @@ void main() {
navigatorKey: GlobalKey<NavigatorState>(), navigatorKey: GlobalKey<NavigatorState>(),
); );
final RouteMatchList matches = RouteMatchList(<RouteMatch>[ final RouteMatchList matches = RouteMatchList(
<RouteMatch>[
RouteMatch( RouteMatch(
route: config.routes.first as GoRoute, route: config.routes.first as GoRoute,
subloc: '/', subloc: '/',
fullpath: '/',
encodedParams: <String, String>{},
queryParams: <String, String>{},
queryParametersAll: <String, List<String>>{},
extra: null, extra: null,
error: null, error: null,
pageKey: const ValueKey<String>('/'),
), ),
]); ],
Uri.parse('/'),
const <String, String>{});
await tester.pumpWidget( await tester.pumpWidget(
_BuilderTestWidget( _BuilderTestWidget(
@ -75,18 +75,18 @@ void main() {
navigatorKey: GlobalKey<NavigatorState>(), navigatorKey: GlobalKey<NavigatorState>(),
); );
final RouteMatchList matches = RouteMatchList(<RouteMatch>[ final RouteMatchList matches = RouteMatchList(
<RouteMatch>[
RouteMatch( RouteMatch(
route: config.routes.first, route: config.routes.first,
subloc: '/', subloc: '/',
fullpath: '/',
encodedParams: <String, String>{},
queryParams: <String, String>{},
queryParametersAll: <String, List<String>>{},
extra: null, extra: null,
error: null, error: null,
pageKey: const ValueKey<String>('/'),
), ),
]); ],
Uri.parse('/'),
<String, String>{});
await tester.pumpWidget( await tester.pumpWidget(
_BuilderTestWidget( _BuilderTestWidget(
@ -117,18 +117,18 @@ void main() {
}, },
); );
final RouteMatchList matches = RouteMatchList(<RouteMatch>[ final RouteMatchList matches = RouteMatchList(
<RouteMatch>[
RouteMatch( RouteMatch(
route: config.routes.first as GoRoute, route: config.routes.first as GoRoute,
subloc: '/', subloc: '/',
fullpath: '/',
encodedParams: <String, String>{},
queryParams: <String, String>{},
queryParametersAll: <String, List<String>>{},
extra: null, extra: null,
error: null, error: null,
pageKey: const ValueKey<String>('/'),
), ),
]); ],
Uri.parse('/'),
<String, String>{});
await tester.pumpWidget( await tester.pumpWidget(
_BuilderTestWidget( _BuilderTestWidget(
@ -172,28 +172,25 @@ void main() {
}, },
); );
final RouteMatchList matches = RouteMatchList(<RouteMatch>[ final RouteMatchList matches = RouteMatchList(
<RouteMatch>[
RouteMatch( RouteMatch(
route: config.routes.first, route: config.routes.first,
subloc: '', subloc: '',
fullpath: '',
encodedParams: <String, String>{},
queryParams: <String, String>{},
queryParametersAll: <String, List<String>>{},
extra: null, extra: null,
error: null, error: null,
pageKey: const ValueKey<String>(''),
), ),
RouteMatch( RouteMatch(
route: config.routes.first.routes.first, route: config.routes.first.routes.first,
subloc: '/details', subloc: '/details',
fullpath: '/details',
encodedParams: <String, String>{},
queryParams: <String, String>{},
queryParametersAll: <String, List<String>>{},
extra: null, extra: null,
error: null, error: null,
pageKey: const ValueKey<String>('/details'),
), ),
]); ],
Uri.parse('/details'),
<String, String>{});
await tester.pumpWidget( await tester.pumpWidget(
_BuilderTestWidget( _BuilderTestWidget(
@ -250,18 +247,18 @@ void main() {
}, },
); );
final RouteMatchList matches = RouteMatchList(<RouteMatch>[ final RouteMatchList matches = RouteMatchList(
<RouteMatch>[
RouteMatch( RouteMatch(
route: config.routes.first.routes.first as GoRoute, route: config.routes.first.routes.first as GoRoute,
subloc: '/a/details', subloc: '/a/details',
fullpath: '/a/details',
encodedParams: <String, String>{},
queryParams: <String, String>{},
queryParametersAll: <String, List<String>>{},
extra: null, extra: null,
error: null, error: null,
pageKey: const ValueKey<String>('/a/details'),
), ),
]); ],
Uri.parse('/a/details'),
<String, String>{});
await tester.pumpWidget( await tester.pumpWidget(
_BuilderTestWidget( _BuilderTestWidget(

View File

@ -5,6 +5,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:go_router/src/delegate.dart';
import 'package:go_router/src/match.dart'; import 'package:go_router/src/match.dart';
import 'package:go_router/src/misc/error_screen.dart'; import 'package:go_router/src/misc/error_screen.dart';
@ -62,10 +63,6 @@ void main() {
(WidgetTester tester) async { (WidgetTester tester) async {
final GoRouter goRouter = await createGoRouter(tester); final GoRouter goRouter = await createGoRouter(tester);
expect(goRouter.routerDelegate.matches.matches.length, 1); expect(goRouter.routerDelegate.matches.matches.length, 1);
expect(
goRouter.routerDelegate.matches.matches[0].pageKey,
null,
);
goRouter.push('/a'); goRouter.push('/a');
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -113,8 +110,7 @@ void main() {
}); });
group('replace', () { group('replace', () {
testWidgets( testWidgets('It should replace the last match with the given one',
'It should replace the last match with the given one',
(WidgetTester tester) async { (WidgetTester tester) async {
final GoRouter goRouter = GoRouter( final GoRouter goRouter = GoRouter(
initialLocation: '/', initialLocation: '/',
@ -148,12 +144,15 @@ void main() {
reason: 'The last match should have been removed', reason: 'The last match should have been removed',
); );
expect( expect(
goRouter.routerDelegate.matches.last.fullpath, (goRouter.routerDelegate.matches.last as ImperativeRouteMatch)
.matches
.uri
.toString(),
'/page-1', '/page-1',
reason: 'The new location should have been pushed', reason: 'The new location should have been pushed',
); );
}, });
);
testWidgets( testWidgets(
'It should return different pageKey when replace is called', 'It should return different pageKey when replace is called',
(WidgetTester tester) async { (WidgetTester tester) async {
@ -161,7 +160,7 @@ void main() {
expect(goRouter.routerDelegate.matches.matches.length, 1); expect(goRouter.routerDelegate.matches.matches.length, 1);
expect( expect(
goRouter.routerDelegate.matches.matches[0].pageKey, goRouter.routerDelegate.matches.matches[0].pageKey,
null, isNotNull,
); );
goRouter.push('/a'); goRouter.push('/a');
@ -228,13 +227,7 @@ void main() {
); );
expect( expect(
goRouter.routerDelegate.matches.last, goRouter.routerDelegate.matches.last,
isA<RouteMatch>() isA<RouteMatch>().having(
.having(
(RouteMatch match) => match.fullpath,
'match.fullpath',
'/page-1',
)
.having(
(RouteMatch match) => (match.route as GoRoute).name, (RouteMatch match) => (match.route as GoRoute).name,
'match.route.name', 'match.route.name',
'page1', 'page1',

View File

@ -42,7 +42,7 @@ void main() {
path: '/', path: '/',
builder: (_, __) { builder: (_, __) {
return Builder(builder: (BuildContext context) { return Builder(builder: (BuildContext context) {
return Text(GoRouterState.of(context).location); return Text('1 ${GoRouterState.of(context).location}');
}); });
}, },
routes: <GoRoute>[ routes: <GoRoute>[
@ -50,7 +50,7 @@ void main() {
path: 'a', path: 'a',
builder: (_, __) { builder: (_, __) {
return Builder(builder: (BuildContext context) { return Builder(builder: (BuildContext context) {
return Text(GoRouterState.of(context).location); return Text('2 ${GoRouterState.of(context).location}');
}); });
}), }),
]), ]),
@ -58,13 +58,13 @@ void main() {
final GoRouter router = await createRouter(routes, tester); final GoRouter router = await createRouter(routes, tester);
router.go('/?p=123'); router.go('/?p=123');
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('/?p=123'), findsOneWidget); expect(find.text('1 /?p=123'), findsOneWidget);
router.go('/a'); router.go('/a');
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('/a'), findsOneWidget); expect(find.text('2 /a'), findsOneWidget);
// The query parameter is removed, so is the location in first page. // The query parameter is removed, so is the location in first page.
expect(find.text('/', skipOffstage: false), findsOneWidget); expect(find.text('1 /a', skipOffstage: false), findsOneWidget);
}); });
testWidgets('registry retains GoRouterState for exiting route', testWidgets('registry retains GoRouterState for exiting route',

View File

@ -9,7 +9,9 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:go_router/src/delegate.dart';
import 'package:go_router/src/match.dart'; import 'package:go_router/src/match.dart';
import 'package:go_router/src/matching.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'test_helpers.dart'; import 'test_helpers.dart';
@ -43,24 +45,24 @@ void main() {
]; ];
final GoRouter router = await createRouter(routes, tester); final GoRouter router = await createRouter(routes, tester);
final List<RouteMatch> matches = router.routerDelegate.matches.matches; final RouteMatchList matches = router.routerDelegate.matches;
expect(matches, hasLength(1)); expect(matches.matches, hasLength(1));
expect(matches.first.fullpath, '/'); expect(matches.uri.toString(), '/');
expect(find.byType(HomeScreen), findsOneWidget); expect(find.byType(HomeScreen), findsOneWidget);
}); });
testWidgets('If there is more than one route to match, use the first match', testWidgets('If there is more than one route to match, use the first match',
(WidgetTester tester) async { (WidgetTester tester) async {
final List<GoRoute> routes = <GoRoute>[ final List<GoRoute> routes = <GoRoute>[
GoRoute(path: '/', builder: dummy), GoRoute(name: '1', path: '/', builder: dummy),
GoRoute(path: '/', builder: dummy), GoRoute(name: '2', path: '/', builder: dummy),
]; ];
final GoRouter router = await createRouter(routes, tester); final GoRouter router = await createRouter(routes, tester);
router.go('/'); router.go('/');
final List<RouteMatch> matches = router.routerDelegate.matches.matches; final List<RouteMatch> matches = router.routerDelegate.matches.matches;
expect(matches, hasLength(1)); expect(matches, hasLength(1));
expect(matches.first.fullpath, '/'); expect((matches.first.route as GoRoute).name, '1');
expect(find.byType(DummyScreen), findsOneWidget); expect(find.byType(DummyScreen), findsOneWidget);
}); });
@ -72,6 +74,8 @@ void main() {
test('leading / on sub-route', () { test('leading / on sub-route', () {
expect(() { expect(() {
GoRouter(
routes: <RouteBase>[
GoRoute( GoRoute(
path: '/', path: '/',
builder: dummy, builder: dummy,
@ -81,12 +85,16 @@ void main() {
builder: dummy, builder: dummy,
), ),
], ],
),
],
); );
}, throwsA(isAssertionError)); }, throwsA(isAssertionError));
}); });
test('trailing / on sub-route', () { test('trailing / on sub-route', () {
expect(() { expect(() {
GoRouter(
routes: <RouteBase>[
GoRoute( GoRoute(
path: '/', path: '/',
builder: dummy, builder: dummy,
@ -96,6 +104,8 @@ void main() {
builder: dummy, builder: dummy,
), ),
], ],
),
],
); );
}, throwsA(isAssertionError)); }, throwsA(isAssertionError));
}); });
@ -328,44 +338,44 @@ void main() {
final GoRouter router = await createRouter(routes, tester); final GoRouter router = await createRouter(routes, tester);
{ {
final List<RouteMatch> matches = router.routerDelegate.matches.matches; final RouteMatchList matches = router.routerDelegate.matches;
expect(matches, hasLength(1)); expect(matches.matches, hasLength(1));
expect(matches.first.fullpath, '/'); expect(matches.uri.toString(), '/');
expect(find.byType(HomeScreen), findsOneWidget); expect(find.byType(HomeScreen), findsOneWidget);
} }
router.go('/login'); router.go('/login');
await tester.pumpAndSettle(); await tester.pumpAndSettle();
{ {
final List<RouteMatch> matches = router.routerDelegate.matches.matches; final RouteMatchList matches = router.routerDelegate.matches;
expect(matches.length, 2); expect(matches.matches.length, 2);
expect(matches.first.subloc, '/'); expect(matches.matches.first.subloc, '/');
expect(find.byType(HomeScreen, skipOffstage: false), findsOneWidget); expect(find.byType(HomeScreen, skipOffstage: false), findsOneWidget);
expect(matches[1].subloc, '/login'); expect(matches.matches[1].subloc, '/login');
expect(find.byType(LoginScreen), findsOneWidget); expect(find.byType(LoginScreen), findsOneWidget);
} }
router.go('/family/f2'); router.go('/family/f2');
await tester.pumpAndSettle(); await tester.pumpAndSettle();
{ {
final List<RouteMatch> matches = router.routerDelegate.matches.matches; final RouteMatchList matches = router.routerDelegate.matches;
expect(matches.length, 2); expect(matches.matches.length, 2);
expect(matches.first.subloc, '/'); expect(matches.matches.first.subloc, '/');
expect(find.byType(HomeScreen, skipOffstage: false), findsOneWidget); expect(find.byType(HomeScreen, skipOffstage: false), findsOneWidget);
expect(matches[1].subloc, '/family/f2'); expect(matches.matches[1].subloc, '/family/f2');
expect(find.byType(FamilyScreen), findsOneWidget); expect(find.byType(FamilyScreen), findsOneWidget);
} }
router.go('/family/f2/person/p1'); router.go('/family/f2/person/p1');
await tester.pumpAndSettle(); await tester.pumpAndSettle();
{ {
final List<RouteMatch> matches = router.routerDelegate.matches.matches; final RouteMatchList matches = router.routerDelegate.matches;
expect(matches.length, 3); expect(matches.matches.length, 3);
expect(matches.first.subloc, '/'); expect(matches.matches.first.subloc, '/');
expect(find.byType(HomeScreen, skipOffstage: false), findsOneWidget); expect(find.byType(HomeScreen, skipOffstage: false), findsOneWidget);
expect(matches[1].subloc, '/family/f2'); expect(matches.matches[1].subloc, '/family/f2');
expect(find.byType(FamilyScreen, skipOffstage: false), findsOneWidget); expect(find.byType(FamilyScreen, skipOffstage: false), findsOneWidget);
expect(matches[2].subloc, '/family/f2/person/p1'); expect(matches.matches[2].subloc, '/family/f2/person/p1');
expect(find.byType(PersonScreen), findsOneWidget); expect(find.byType(PersonScreen), findsOneWidget);
} }
}); });
@ -1134,14 +1144,12 @@ void main() {
final GoRouter router = await createRouter(routes, tester); final GoRouter router = await createRouter(routes, tester);
final String loc = router final String loc = router
.namedLocation('page1', params: <String, String>{'param1': param1}); .namedLocation('page1', params: <String, String>{'param1': param1});
log.info('loc= $loc');
router.go(loc); router.go(loc);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final List<RouteMatch> matches = router.routerDelegate.matches.matches; final RouteMatchList matches = router.routerDelegate.matches;
log.info('param1= ${matches.first.decodedParams['param1']}');
expect(find.byType(DummyScreen), findsOneWidget); expect(find.byType(DummyScreen), findsOneWidget);
expect(matches.first.decodedParams['param1'], param1); expect(matches.pathParameters['param1'], param1);
}); });
testWidgets('preserve query param spaces and slashes', testWidgets('preserve query param spaces and slashes',
@ -1163,9 +1171,9 @@ void main() {
queryParams: <String, String>{'param1': param1}); queryParams: <String, String>{'param1': param1});
router.go(loc); router.go(loc);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final List<RouteMatch> matches = router.routerDelegate.matches.matches; final RouteMatchList matches = router.routerDelegate.matches;
expect(find.byType(DummyScreen), findsOneWidget); expect(find.byType(DummyScreen), findsOneWidget);
expect(matches.first.queryParams['param1'], param1); expect(matches.uri.queryParameters['param1'], param1);
}); });
}); });
@ -1835,12 +1843,12 @@ void main() {
final String loc = '/family/$fid'; final String loc = '/family/$fid';
router.go(loc); router.go(loc);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final List<RouteMatch> matches = router.routerDelegate.matches.matches; final RouteMatchList matches = router.routerDelegate.matches;
expect(router.location, loc); expect(router.location, loc);
expect(matches, hasLength(1)); expect(matches.matches, hasLength(1));
expect(find.byType(FamilyScreen), findsOneWidget); expect(find.byType(FamilyScreen), findsOneWidget);
expect(matches.first.decodedParams['fid'], fid); expect(matches.pathParameters['fid'], fid);
} }
}); });
@ -1864,12 +1872,12 @@ void main() {
final String loc = '/family?fid=$fid'; final String loc = '/family?fid=$fid';
router.go(loc); router.go(loc);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final List<RouteMatch> matches = router.routerDelegate.matches.matches; final RouteMatchList matches = router.routerDelegate.matches;
expect(router.location, loc); expect(router.location, loc);
expect(matches, hasLength(1)); expect(matches.matches, hasLength(1));
expect(find.byType(FamilyScreen), findsOneWidget); expect(find.byType(FamilyScreen), findsOneWidget);
expect(matches.first.queryParams['fid'], fid); expect(matches.uri.queryParameters['fid'], fid);
} }
}); });
@ -1891,10 +1899,9 @@ void main() {
router.go(loc); router.go(loc);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final List<RouteMatch> matches = router.routerDelegate.matches.matches; final RouteMatchList matches = router.routerDelegate.matches;
log.info('param1= ${matches.first.decodedParams['param1']}');
expect(find.byType(DummyScreen), findsOneWidget); expect(find.byType(DummyScreen), findsOneWidget);
expect(matches.first.decodedParams['param1'], param1); expect(matches.pathParameters['param1'], param1);
}); });
testWidgets('preserve query param spaces and slashes', testWidgets('preserve query param spaces and slashes',
@ -1914,17 +1921,17 @@ void main() {
router.go('/page1?param1=$param1'); router.go('/page1?param1=$param1');
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final List<RouteMatch> matches = router.routerDelegate.matches.matches; final RouteMatchList matches = router.routerDelegate.matches;
expect(find.byType(DummyScreen), findsOneWidget); expect(find.byType(DummyScreen), findsOneWidget);
expect(matches.first.queryParams['param1'], param1); expect(matches.uri.queryParameters['param1'], param1);
final String loc = '/page1?param1=${Uri.encodeQueryComponent(param1)}'; final String loc = '/page1?param1=${Uri.encodeQueryComponent(param1)}';
router.go(loc); router.go(loc);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final List<RouteMatch> matches2 = router.routerDelegate.matches.matches; final RouteMatchList matches2 = router.routerDelegate.matches;
expect(find.byType(DummyScreen), findsOneWidget); expect(find.byType(DummyScreen), findsOneWidget);
expect(matches2[0].queryParams['param1'], param1); expect(matches2.uri.queryParameters['param1'], param1);
}); });
test('error: duplicate path param', () { test('error: duplicate path param', () {
@ -1963,9 +1970,9 @@ void main() {
tester, tester,
initialLocation: '/?id=0&id=1', initialLocation: '/?id=0&id=1',
); );
final List<RouteMatch> matches = router.routerDelegate.matches.matches; final RouteMatchList matches = router.routerDelegate.matches;
expect(matches, hasLength(1)); expect(matches.matches, hasLength(1));
expect(matches.first.fullpath, '/'); expect(matches.fullpath, '/');
expect(find.byType(HomeScreen), findsOneWidget); expect(find.byType(HomeScreen), findsOneWidget);
}); });
@ -1986,9 +1993,9 @@ void main() {
router.go('/0?id=1'); router.go('/0?id=1');
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final List<RouteMatch> matches = router.routerDelegate.matches.matches; final RouteMatchList matches = router.routerDelegate.matches;
expect(matches, hasLength(1)); expect(matches.matches, hasLength(1));
expect(matches.first.fullpath, '/:id'); expect(matches.fullpath, '/:id');
expect(find.byType(HomeScreen), findsOneWidget); expect(find.byType(HomeScreen), findsOneWidget);
}); });
@ -2098,13 +2105,15 @@ void main() {
router.push(loc); router.push(loc);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final List<RouteMatch> matches = router.routerDelegate.matches.matches; final RouteMatchList matches = router.routerDelegate.matches;
expect(router.location, loc); expect(router.location, loc);
expect(matches, hasLength(2)); expect(matches.matches, hasLength(2));
expect(find.byType(PersonScreen), findsOneWidget); expect(find.byType(PersonScreen), findsOneWidget);
expect(matches.last.decodedParams['fid'], fid); final ImperativeRouteMatch imperativeRouteMatch =
expect(matches.last.decodedParams['pid'], pid); matches.matches.last as ImperativeRouteMatch;
expect(imperativeRouteMatch.matches.pathParameters['fid'], fid);
expect(imperativeRouteMatch.matches.pathParameters['pid'], pid);
}); });
testWidgets('goNames should allow dynamics values for queryParams', testWidgets('goNames should allow dynamics values for queryParams',

View File

@ -14,47 +14,36 @@ void main() {
path: '/users/:userId', path: '/users/:userId',
builder: _builder, builder: _builder,
); );
final Map<String, String> pathParameters = <String, String>{};
final RouteMatch? match = RouteMatch.match( final RouteMatch? match = RouteMatch.match(
route: route, route: route,
restLoc: '/users/123', restLoc: '/users/123',
parentSubloc: '', parentSubloc: '',
fullpath: '/users/:userId', pathParameters: pathParameters,
queryParams: <String, String>{},
extra: const _Extra('foo'), extra: const _Extra('foo'),
queryParametersAll: <String, List<String>>{
'bar': <String>['baz', 'biz'],
},
); );
if (match == null) { if (match == null) {
fail('Null match'); fail('Null match');
} }
expect(match.route, route); expect(match.route, route);
expect(match.subloc, '/users/123'); expect(match.subloc, '/users/123');
expect(match.fullpath, '/users/:userId'); expect(pathParameters['userId'], '123');
expect(match.encodedParams['userId'], '123');
expect(match.queryParams['foo'], isNull);
expect(match.queryParametersAll['bar'], <String>['baz', 'biz']);
expect(match.extra, const _Extra('foo')); expect(match.extra, const _Extra('foo'));
expect(match.error, isNull); expect(match.error, isNull);
expect(match.pageKey, isNull); expect(match.pageKey, isNotNull);
expect(match.fullUriString, '/users/123?bar=baz&bar=biz');
}); });
test('subloc', () { test('subloc', () {
final GoRoute route = GoRoute( final GoRoute route = GoRoute(
path: 'users/:userId', path: 'users/:userId',
builder: _builder, builder: _builder,
); );
final Map<String, String> pathParameters = <String, String>{};
final RouteMatch? match = RouteMatch.match( final RouteMatch? match = RouteMatch.match(
route: route, route: route,
restLoc: 'users/123', restLoc: 'users/123',
parentSubloc: '/home', parentSubloc: '/home',
fullpath: '/home/users/:userId', pathParameters: pathParameters,
queryParams: <String, String>{
'foo': 'bar',
},
queryParametersAll: <String, List<String>>{
'foo': <String>['bar'],
},
extra: const _Extra('foo'), extra: const _Extra('foo'),
); );
if (match == null) { if (match == null) {
@ -62,14 +51,12 @@ void main() {
} }
expect(match.route, route); expect(match.route, route);
expect(match.subloc, '/home/users/123'); expect(match.subloc, '/home/users/123');
expect(match.fullpath, '/home/users/:userId'); expect(pathParameters['userId'], '123');
expect(match.encodedParams['userId'], '123');
expect(match.queryParams['foo'], 'bar');
expect(match.extra, const _Extra('foo')); expect(match.extra, const _Extra('foo'));
expect(match.error, isNull); expect(match.error, isNull);
expect(match.pageKey, isNull); expect(match.pageKey, isNotNull);
expect(match.fullUriString, '/home/users/123?foo=bar');
}); });
test('ShellRoute has a unique pageKey', () { test('ShellRoute has a unique pageKey', () {
final ShellRoute route = ShellRoute( final ShellRoute route = ShellRoute(
builder: _shellBuilder, builder: _shellBuilder,
@ -80,17 +67,12 @@ void main() {
), ),
], ],
); );
final Map<String, String> pathParameters = <String, String>{};
final RouteMatch? match = RouteMatch.match( final RouteMatch? match = RouteMatch.match(
route: route, route: route,
restLoc: 'users/123', restLoc: 'users/123',
parentSubloc: '/home', parentSubloc: '/home',
fullpath: '/home/users/:userId', pathParameters: pathParameters,
queryParams: <String, String>{
'foo': 'bar',
},
queryParametersAll: <String, List<String>>{
'foo': <String>['bar'],
},
extra: const _Extra('foo'), extra: const _Extra('foo'),
); );
if (match == null) { if (match == null) {
@ -98,6 +80,61 @@ void main() {
} }
expect(match.pageKey, isNotNull); expect(match.pageKey, isNotNull);
}); });
test('ShellRoute Match has stable unique key', () {
final ShellRoute route = ShellRoute(
builder: _shellBuilder,
routes: <GoRoute>[
GoRoute(
path: '/users/:userId',
builder: _builder,
),
],
);
final Map<String, String> pathParameters = <String, String>{};
final RouteMatch? match1 = RouteMatch.match(
route: route,
restLoc: 'users/123',
parentSubloc: '/home',
pathParameters: pathParameters,
extra: const _Extra('foo'),
);
final RouteMatch? match2 = RouteMatch.match(
route: route,
restLoc: 'users/1234',
parentSubloc: '/home',
pathParameters: pathParameters,
extra: const _Extra('foo1'),
);
expect(match1!.pageKey, match2!.pageKey);
});
test('GoRoute Match has stable unique key', () {
final GoRoute route = GoRoute(
path: 'users/:userId',
builder: _builder,
);
final Map<String, String> pathParameters = <String, String>{};
final RouteMatch? match1 = RouteMatch.match(
route: route,
restLoc: 'users/123',
parentSubloc: '/home',
pathParameters: pathParameters,
extra: const _Extra('foo'),
);
final RouteMatch? match2 = RouteMatch.match(
route: route,
restLoc: 'users/1234',
parentSubloc: '/home',
pathParameters: pathParameters,
extra: const _Extra('foo1'),
);
expect(match1!.pageKey, match2!.pageKey);
});
}); });
} }

View File

@ -56,9 +56,8 @@ void main() {
const RouteInformation(location: '/'), context); const RouteInformation(location: '/'), context);
List<RouteMatch> matches = matchesObj.matches; List<RouteMatch> matches = matchesObj.matches;
expect(matches.length, 1); expect(matches.length, 1);
expect(matches[0].queryParams.isEmpty, isTrue); expect(matchesObj.uri.toString(), '/');
expect(matches[0].extra, isNull); expect(matches[0].extra, isNull);
expect(matches[0].fullUriString, '/');
expect(matches[0].subloc, '/'); expect(matches[0].subloc, '/');
expect(matches[0].route, routes[0]); expect(matches[0].route, routes[0]);
@ -67,17 +66,12 @@ void main() {
RouteInformation(location: '/abc?def=ghi', state: extra), context); RouteInformation(location: '/abc?def=ghi', state: extra), context);
matches = matchesObj.matches; matches = matchesObj.matches;
expect(matches.length, 2); expect(matches.length, 2);
expect(matches[0].queryParams.length, 1); expect(matchesObj.uri.toString(), '/abc?def=ghi');
expect(matches[0].queryParams['def'], 'ghi');
expect(matches[0].extra, extra); expect(matches[0].extra, extra);
expect(matches[0].fullUriString, '/?def=ghi');
expect(matches[0].subloc, '/'); expect(matches[0].subloc, '/');
expect(matches[0].route, routes[0]); expect(matches[0].route, routes[0]);
expect(matches[1].queryParams.length, 1);
expect(matches[1].queryParams['def'], 'ghi');
expect(matches[1].extra, extra); expect(matches[1].extra, extra);
expect(matches[1].fullUriString, '/abc?def=ghi');
expect(matches[1].subloc, '/abc'); expect(matches[1].subloc, '/abc');
expect(matches[1].route, routes[0].routes[0]); expect(matches[1].route, routes[0].routes[0]);
}); });
@ -195,9 +189,8 @@ void main() {
const RouteInformation(location: '/def'), context); const RouteInformation(location: '/def'), context);
final List<RouteMatch> matches = matchesObj.matches; final List<RouteMatch> matches = matchesObj.matches;
expect(matches.length, 1); expect(matches.length, 1);
expect(matches[0].queryParams.isEmpty, isTrue); expect(matchesObj.uri.toString(), '/def');
expect(matches[0].extra, isNull); expect(matches[0].extra, isNull);
expect(matches[0].fullUriString, '/def');
expect(matches[0].subloc, '/def'); expect(matches[0].subloc, '/def');
expect(matches[0].error!.toString(), expect(matches[0].error!.toString(),
'Exception: no routes for location: /def'); 'Exception: no routes for location: /def');
@ -231,18 +224,15 @@ void main() {
final List<RouteMatch> matches = matchesObj.matches; final List<RouteMatch> matches = matchesObj.matches;
expect(matches.length, 2); expect(matches.length, 2);
expect(matches[0].queryParams.isEmpty, isTrue); expect(matchesObj.uri.toString(), '/123/family/456');
expect(matchesObj.pathParameters.length, 2);
expect(matchesObj.pathParameters['uid'], '123');
expect(matchesObj.pathParameters['fid'], '456');
expect(matches[0].extra, isNull); expect(matches[0].extra, isNull);
expect(matches[0].fullUriString, '/');
expect(matches[0].subloc, '/'); expect(matches[0].subloc, '/');
expect(matches[1].queryParams.isEmpty, isTrue);
expect(matches[1].extra, isNull); expect(matches[1].extra, isNull);
expect(matches[1].fullUriString, '/123/family/456');
expect(matches[1].subloc, '/123/family/456'); expect(matches[1].subloc, '/123/family/456');
expect(matches[1].encodedParams.length, 2);
expect(matches[1].encodedParams['uid'], '123');
expect(matches[1].encodedParams['fid'], '456');
}); });
testWidgets( testWidgets(
@ -279,10 +269,9 @@ void main() {
final List<RouteMatch> matches = matchesObj.matches; final List<RouteMatch> matches = matchesObj.matches;
expect(matches.length, 2); expect(matches.length, 2);
expect(matches[0].fullUriString, '/'); expect(matchesObj.uri.toString(), '/123/family/345');
expect(matches[0].subloc, '/'); expect(matches[0].subloc, '/');
expect(matches[1].fullUriString, '/123/family/345');
expect(matches[1].subloc, '/123/family/345'); expect(matches[1].subloc, '/123/family/345');
}); });
@ -320,10 +309,9 @@ void main() {
final List<RouteMatch> matches = matchesObj.matches; final List<RouteMatch> matches = matchesObj.matches;
expect(matches.length, 2); expect(matches.length, 2);
expect(matches[0].fullUriString, '/'); expect(matchesObj.uri.toString(), '/123/family/345');
expect(matches[0].subloc, '/'); expect(matches[0].subloc, '/');
expect(matches[1].fullUriString, '/123/family/345');
expect(matches[1].subloc, '/123/family/345'); expect(matches[1].subloc, '/123/family/345');
}); });