diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index ef70f93afed4..5729e196428c 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,3 +1,7 @@ +## 14.2.9 + +- Relaxes route path requirements. Both root and child routes can now start with or without '/'. + ## 14.2.8 - Updated custom_stateful_shell_route example to better support swiping in TabView as well as demonstration of the use of PageView. diff --git a/packages/go_router/lib/src/configuration.dart b/packages/go_router/lib/src/configuration.dart index 8be984ecb6f5..cc671066218d 100644 --- a/packages/go_router/lib/src/configuration.dart +++ b/packages/go_router/lib/src/configuration.dart @@ -36,12 +36,9 @@ class RouteConfiguration { 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'); + if (route.path != '/') { + assert(!route.path.endsWith('/'), + 'route path may not end with "/" except for the top "/" route. Found: $route'); } subRouteIsTopLevel = false; } else if (route is ShellRouteBase) { diff --git a/packages/go_router/lib/src/match.dart b/packages/go_router/lib/src/match.dart index 4d24416284ec..696f9d4c50d9 100644 --- a/packages/go_router/lib/src/match.dart +++ b/packages/go_router/lib/src/match.dart @@ -203,6 +203,7 @@ abstract class RouteMatchBase with Diagnosticable { final RegExpMatch? regExpMatch = route.matchPatternAsPrefix(remainingLocation); + if (regExpMatch == null) { return _empty; } @@ -555,13 +556,9 @@ class RouteMatchList with Diagnosticable { /// [RouteMatchA(), RouteMatchB(), RouteMatchC()] /// ``` static String _generateFullPath(Iterable matches) { - final StringBuffer buffer = StringBuffer(); - bool addsSlash = false; + String fullPath = ''; for (final RouteMatchBase match in matches .where((RouteMatchBase match) => match is! ImperativeRouteMatch)) { - if (addsSlash) { - buffer.write('/'); - } final String pathSegment; if (match is RouteMatch) { pathSegment = match.route.path; @@ -571,10 +568,9 @@ class RouteMatchList with Diagnosticable { assert(false, 'Unexpected match type: $match'); continue; } - buffer.write(pathSegment); - addsSlash = pathSegment.isNotEmpty && (addsSlash || pathSegment != '/'); + fullPath = concatenatePaths(fullPath, pathSegment); } - return buffer.toString(); + return fullPath; } /// Returns true if there are no matches. diff --git a/packages/go_router/lib/src/path_utils.dart b/packages/go_router/lib/src/path_utils.dart index 872f7786b263..4684c75badc4 100644 --- a/packages/go_router/lib/src/path_utils.dart +++ b/packages/go_router/lib/src/path_utils.dart @@ -102,20 +102,14 @@ Map extractPathParameters( /// Concatenates two paths. /// -/// e.g: pathA = /a, pathB = c/d, concatenatePaths(pathA, pathB) = /a/c/d. +/// e.g: pathA = /a, pathB = /c/d, concatenatePaths(pathA, pathB) = /a/c/d. +/// or: pathA = a, pathB = c/d, concatenatePaths(pathA, pathB) = /a/c/d. String concatenatePaths(String parentPath, String childPath) { - // at the root, just return the path - if (parentPath.isEmpty) { - assert(childPath.startsWith('/')); - assert(childPath == '/' || !childPath.endsWith('/')); - return childPath; - } - - // not at the root, so append the parent path - assert(childPath.isNotEmpty); - assert(!childPath.startsWith('/')); - assert(!childPath.endsWith('/')); - return '${parentPath == '/' ? '' : parentPath}/$childPath'; + final Iterable segments = [ + ...parentPath.split('/'), + ...childPath.split('/') + ].where((String segment) => segment.isNotEmpty); + return '/${segments.join('/')}'; } /// Builds an absolute path for the provided route. diff --git a/packages/go_router/lib/src/route.dart b/packages/go_router/lib/src/route.dart index 0183f91ea543..6fa9b8bb7489 100644 --- a/packages/go_router/lib/src/route.dart +++ b/packages/go_router/lib/src/route.dart @@ -432,8 +432,10 @@ class GoRoute extends RouteBase { // TODO(chunhtai): move all regex related help methods to path_utils.dart. /// Match this route against a location. - RegExpMatch? matchPatternAsPrefix(String loc) => - _pathRE.matchAsPrefix(loc) as RegExpMatch?; + RegExpMatch? matchPatternAsPrefix(String loc) { + return _pathRE.matchAsPrefix('/$loc') as RegExpMatch? ?? + _pathRE.matchAsPrefix(loc) as RegExpMatch?; + } /// Extract the path parameters from a match. Map extractPathParams(RegExpMatch match) => diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index 7c494f15aea1..e27cb29dbb35 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: 14.2.8 +version: 14.2.9 repository: https://github.com/flutter/packages/tree/main/packages/go_router issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+go_router%22 diff --git a/packages/go_router/test/go_router_test.dart b/packages/go_router/test/go_router_test.dart index 10e6bb58d5bd..e2c2d1b5cfc1 100644 --- a/packages/go_router/test/go_router_test.dart +++ b/packages/go_router/test/go_router_test.dart @@ -96,25 +96,6 @@ void main() { }, throwsA(isAssertionError)); }); - test('leading / on sub-route', () { - expect(() { - GoRouter( - routes: [ - GoRoute( - path: '/', - builder: dummy, - routes: [ - GoRoute( - path: '/foo', - builder: dummy, - ), - ], - ), - ], - ); - }, throwsA(isAssertionError)); - }); - test('trailing / on sub-route', () { expect(() { GoRouter( @@ -134,16 +115,6 @@ void main() { }, throwsA(isAssertionError)); }); - testWidgets('lack of leading / on top-level route', - (WidgetTester tester) async { - await expectLater(() async { - final List routes = [ - GoRoute(path: 'foo', builder: dummy), - ]; - await createRouter(routes, tester); - }, throwsA(isAssertionError)); - }); - testWidgets('match no routes', (WidgetTester tester) async { final List routes = [ GoRoute(path: '/', builder: dummy), @@ -5502,6 +5473,94 @@ void main() { ), ); }); + + testWidgets('should allow route paths without leading /', + (WidgetTester tester) async { + final List routes = [ + GoRoute( + path: '/', // root cannot be empty (existing assert) + builder: (BuildContext context, GoRouterState state) => + const HomeScreen(), + routes: [ + GoRoute( + path: 'child-route', + builder: (BuildContext context, GoRouterState state) => + const Text('/child-route'), + routes: [ + GoRoute( + path: 'grand-child-route', + builder: (BuildContext context, GoRouterState state) => + const Text('/grand-child-route'), + ), + GoRoute( + path: 'redirected-grand-child-route', + redirect: (BuildContext context, GoRouterState state) => + '/child-route', + ), + ], + ) + ], + ), + ]; + + final GoRouter router = await createRouter(routes, tester, + initialLocation: '/child-route/grand-child-route'); + RouteMatchList matches = router.routerDelegate.currentConfiguration; + expect(matches.matches, hasLength(3)); + expect(matches.uri.toString(), '/child-route/grand-child-route'); + expect(find.text('/grand-child-route'), findsOneWidget); + + router.go('/child-route/redirected-grand-child-route'); + await tester.pumpAndSettle(); + matches = router.routerDelegate.currentConfiguration; + expect(matches.matches, hasLength(2)); + expect(matches.uri.toString(), '/child-route'); + expect(find.text('/child-route'), findsOneWidget); + }); + + testWidgets('should allow route paths with leading /', + (WidgetTester tester) async { + final List routes = [ + GoRoute( + path: '/', + builder: (BuildContext context, GoRouterState state) => + const HomeScreen(), + routes: [ + GoRoute( + path: '/child-route', + builder: (BuildContext context, GoRouterState state) => + const Text('/child-route'), + routes: [ + GoRoute( + path: '/grand-child-route', + builder: (BuildContext context, GoRouterState state) => + const Text('/grand-child-route'), + ), + GoRoute( + path: '/redirected-grand-child-route', + redirect: (BuildContext context, GoRouterState state) => + '/child-route', + ), + ], + ) + ], + ), + ]; + + final GoRouter router = await createRouter(routes, tester, + initialLocation: '/child-route/grand-child-route'); + RouteMatchList matches = router.routerDelegate.currentConfiguration; + expect(matches.matches, hasLength(3)); + expect(matches.uri.toString(), '/child-route/grand-child-route'); + expect(find.text('/grand-child-route'), findsOneWidget); + + router.go('/child-route/redirected-grand-child-route'); + await tester.pumpAndSettle(); + matches = router.routerDelegate.currentConfiguration; + expect(matches.matches, hasLength(2)); + expect(matches.uri.toString(), '/child-route'); + expect(find.text('/child-route'), findsOneWidget); + }); } class TestInheritedNotifier extends InheritedNotifier> { diff --git a/packages/go_router/test/path_utils_test.dart b/packages/go_router/test/path_utils_test.dart index a495e4d1a052..5b0b1234329b 100644 --- a/packages/go_router/test/path_utils_test.dart +++ b/packages/go_router/test/path_utils_test.dart @@ -79,17 +79,11 @@ void main() { expect(result, expected); } - void verifyThrows(String pathA, String pathB) { - expect( - () => concatenatePaths(pathA, pathB), throwsA(isA())); - } - verify('/a', 'b/c', '/a/b/c'); verify('/', 'b', '/b'); - verifyThrows('/a', '/b'); - verifyThrows('/a', '/'); - verifyThrows('/', '/'); - verifyThrows('/', ''); - verifyThrows('', ''); + verify('/a', '/b/c/', '/a/b/c'); + verify('/a', 'b/c', '/a/b/c'); + verify('/', '/', '/'); + verify('', '', '/'); }); }