diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index 36d5e840187..94fefe87e00 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,3 +1,7 @@ +## 13.2.0 + +- Exposes full `Uri` on `GoRouterState` in `GoRouterRedirect` + ## 13.1.0 - Adds `topRoute` to `GoRouterState` diff --git a/packages/go_router/lib/src/information_provider.dart b/packages/go_router/lib/src/information_provider.dart index 74440051d68..dc979193b32 100644 --- a/packages/go_router/lib/src/information_provider.dart +++ b/packages/go_router/lib/src/information_provider.dart @@ -11,10 +11,6 @@ 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 @@ -85,7 +81,7 @@ class GoRouteInformationProvider extends RouteInformationProvider Listenable? refreshListenable, }) : _refreshListenable = refreshListenable, _value = RouteInformation( - location: initialLocation, + uri: Uri.parse(initialLocation), state: RouteInformationState( extra: initialExtra, type: NavigatingType.go), ), @@ -96,8 +92,8 @@ class GoRouteInformationProvider extends RouteInformationProvider final Listenable? _refreshListenable; static WidgetsBinding get _binding => WidgetsBinding.instance; - static const RouteInformation _kEmptyRouteInformation = - RouteInformation(location: ''); + static final RouteInformation _kEmptyRouteInformation = + RouteInformation(uri: Uri.parse('')); @override void routerReportsNewRouteInformation(RouteInformation routeInformation, @@ -109,9 +105,9 @@ class GoRouteInformationProvider extends RouteInformationProvider final bool replace; switch (type) { case RouteInformationReportingType.none: - if (_valueInEngine.location == routeInformation.location && - const DeepCollectionEquality() - .equals(_valueInEngine.state, routeInformation.state)) { + if (!_valueHasChanged( + newLocationUri: routeInformation.uri, + newState: routeInformation.state)) { return; } replace = _valueInEngine == _kEmptyRouteInformation; @@ -122,10 +118,7 @@ class GoRouteInformationProvider extends RouteInformationProvider } SystemNavigator.selectMultiEntryHistory(); SystemNavigator.routeInformationUpdated( - // TODO(chunhtai): remove this ignore and migrate the code - // https://github.com/flutter/flutter/issues/124045. - // ignore: unnecessary_null_checks, unnecessary_non_null_assertion - location: routeInformation.location!, + uri: routeInformation.uri, state: routeInformation.state, replace: replace, ); @@ -137,17 +130,16 @@ class GoRouteInformationProvider extends RouteInformationProvider RouteInformation _value; @override - // TODO(chunhtai): remove this ignore once package minimum dart version is - // above 3. - // ignore: unnecessary_overrides void notifyListeners() { super.notifyListeners(); } void _setValue(String location, Object state) { + final Uri uri = Uri.parse(location); + final bool shouldNotify = - _value.location != location || _value.state != state; - _value = RouteInformation(location: location, state: state); + _valueHasChanged(newLocationUri: uri, newState: state); + _value = RouteInformation(uri: Uri.parse(location), state: state); if (shouldNotify) { notifyListeners(); } @@ -235,7 +227,7 @@ class GoRouteInformationProvider extends RouteInformationProvider _value = _valueInEngine = routeInformation; } else { _value = RouteInformation( - location: routeInformation.location, + uri: routeInformation.uri, state: RouteInformationState(type: NavigatingType.go), ); _valueInEngine = _kEmptyRouteInformation; @@ -243,6 +235,19 @@ class GoRouteInformationProvider extends RouteInformationProvider notifyListeners(); } + bool _valueHasChanged( + {required Uri newLocationUri, required Object? newState}) { + const DeepCollectionEquality deepCollectionEquality = + DeepCollectionEquality(); + return !deepCollectionEquality.equals( + _value.uri.path, newLocationUri.path) || + !deepCollectionEquality.equals( + _value.uri.queryParameters, newLocationUri.queryParameters) || + !deepCollectionEquality.equals( + _value.uri.fragment, newLocationUri.fragment) || + !deepCollectionEquality.equals(_value.state, newState); + } + @override void addListener(VoidCallback listener) { if (!hasListeners) { @@ -274,11 +279,4 @@ class GoRouteInformationProvider extends RouteInformationProvider _platformReportsNewRouteInformation(routeInformation); return SynchronousFuture(true); } - - @override - Future didPushRoute(String route) { - assert(hasListeners); - _platformReportsNewRouteInformation(RouteInformation(location: route)); - return SynchronousFuture(true); - } } diff --git a/packages/go_router/lib/src/parser.dart b/packages/go_router/lib/src/parser.dart index 2361470fdea..4ac4f03423c 100644 --- a/packages/go_router/lib/src/parser.dart +++ b/packages/go_router/lib/src/parser.dart @@ -79,19 +79,13 @@ class GoRouteInformationParser extends RouteInformationParser { } late final RouteMatchList initialMatches; - initialMatches = - // TODO(chunhtai): remove this ignore and migrate the code - // https://github.com/flutter/flutter/issues/124045. - // TODO(chunhtai): After the migration from routeInformation's location - // to uri, empty path check might be required here; see - // https://github.com/flutter/packages/pull/5113#discussion_r1374861070 - // ignore: deprecated_member_use, unnecessary_non_null_assertion - configuration.findMatch(routeInformation.location!, extra: state.extra); + initialMatches = configuration.findMatch( + routeInformation.uri.path.isEmpty + ? '${routeInformation.uri}/' + : routeInformation.uri.toString(), + 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('No initial matches: ${routeInformation.location}'); + log('No initial matches: ${routeInformation.uri.path}'); } return debugParserFuture = _redirect( @@ -142,10 +136,7 @@ class GoRouteInformationParser extends RouteInformationParser { location = configuration.uri.toString(); } return RouteInformation( - // TODO(chunhtai): remove this ignore and migrate the code - // https://github.com/flutter/flutter/issues/124045. - // ignore: deprecated_member_use - location: location, + uri: Uri.parse(location), state: _routeMatchListCodec.encode(configuration), ); } diff --git a/packages/go_router/lib/src/path_utils.dart b/packages/go_router/lib/src/path_utils.dart index 42b5f030a4f..37e76853e31 100644 --- a/packages/go_router/lib/src/path_utils.dart +++ b/packages/go_router/lib/src/path_utils.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'misc/errors.dart'; import 'route.dart'; final RegExp _parameterRegExp = RegExp(r':(\w+)(\((?:\\.|[^\\()])+\))?'); @@ -120,6 +121,9 @@ String concatenatePaths(String parentPath, String childPath) { /// Normalizes the location string. String canonicalUri(String loc) { + if (loc.isEmpty) { + throw GoException('Location cannot be empty.'); + } String canon = Uri.parse(loc).toString(); canon = canon.endsWith('?') ? canon.substring(0, canon.length - 1) : canon; @@ -131,9 +135,18 @@ String canonicalUri(String loc) { ? canon.substring(0, canon.length - 1) : canon; + // replace '/?', except for first occurrence, from path only // /login/?from=/ => /login?from=/ // /?from=/ => /?from=/ - canon = canon.replaceFirst('/?', '?', 1); + final Uri uri = Uri.parse(canon); + final int pathStartIndex = uri.host.isNotEmpty + ? uri.toString().indexOf(uri.host) + uri.host.length + : uri.hasScheme + ? uri.toString().indexOf(uri.scheme) + uri.scheme.length + : 0; + if (pathStartIndex < canon.length) { + canon = canon.replaceFirst('/?', '?', pathStartIndex + 1); + } return canon; } diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index 770107232ee..b93509a97ad 100644 --- a/packages/go_router/pubspec.yaml +++ b/packages/go_router/pubspec.yaml @@ -1,13 +1,13 @@ name: go_router description: A declarative router for Flutter based on Navigation 2 supporting deep linking, data-driven routes and more -version: 13.1.0 +version: 13.2.0 repository: https://github.com/flutter/packages/tree/main/packages/go_router issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+go_router%22 environment: - sdk: ">=3.0.0 <4.0.0" - flutter: ">=3.10.0" + sdk: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0" dependencies: collection: ^1.15.0 diff --git a/packages/go_router/test/go_router_test.dart b/packages/go_router/test/go_router_test.dart index 8f902e5aa25..852e7d9fec6 100644 --- a/packages/go_router/test/go_router_test.dart +++ b/packages/go_router/test/go_router_test.dart @@ -258,6 +258,95 @@ void main() { expect(find.byType(DummyScreen), findsOneWidget); }); + testWidgets( + 'match top level route when location has scheme/host and has trailing /', + (WidgetTester tester) async { + final List routes = [ + GoRoute( + path: '/', + builder: (BuildContext context, GoRouterState state) => + const HomeScreen(), + ), + ]; + + final GoRouter router = await createRouter(routes, tester); + router.go('https://www.domain.com/?bar=baz'); + await tester.pumpAndSettle(); + final List matches = + router.routerDelegate.currentConfiguration.matches; + expect(matches, hasLength(1)); + expect(matches.first.matchedLocation, '/'); + expect(find.byType(HomeScreen), findsOneWidget); + }); + + testWidgets( + 'match top level route when location has scheme/host and has trailing / (2)', + (WidgetTester tester) async { + final List routes = [ + GoRoute( + path: '/', + builder: (BuildContext context, GoRouterState state) => + const HomeScreen(), + ), + GoRoute( + path: '/login', + builder: (BuildContext context, GoRouterState state) => + const LoginScreen(), + ), + ]; + + final GoRouter router = await createRouter(routes, tester); + router.go('https://www.domain.com/login/'); + await tester.pumpAndSettle(); + final List matches = + router.routerDelegate.currentConfiguration.matches; + expect(matches, hasLength(1)); + expect(matches.first.matchedLocation, '/login'); + expect(find.byType(LoginScreen), findsOneWidget); + }); + + testWidgets( + 'match top level route when location has scheme/host and has trailing / (3)', + (WidgetTester tester) async { + final List routes = [ + GoRoute( + path: '/profile', + builder: dummy, + redirect: (_, __) => '/profile/foo'), + GoRoute(path: '/profile/:kind', builder: dummy), + ]; + + final GoRouter router = await createRouter(routes, tester); + router.go('https://www.domain.com/profile/'); + await tester.pumpAndSettle(); + final List matches = + router.routerDelegate.currentConfiguration.matches; + expect(matches, hasLength(1)); + expect(matches.first.matchedLocation, '/profile/foo'); + expect(find.byType(DummyScreen), findsOneWidget); + }); + + testWidgets( + 'match top level route when location has scheme/host and has trailing / (4)', + (WidgetTester tester) async { + final List routes = [ + GoRoute( + path: '/profile', + builder: dummy, + redirect: (_, __) => '/profile/foo'), + GoRoute(path: '/profile/:kind', builder: dummy), + ]; + + final GoRouter router = await createRouter(routes, tester); + router.go('https://www.domain.com/profile/?bar=baz'); + await tester.pumpAndSettle(); + final List matches = + router.routerDelegate.currentConfiguration.matches; + expect(matches, hasLength(1)); + expect(matches.first.matchedLocation, '/profile/foo'); + expect(find.byType(DummyScreen), findsOneWidget); + }); + testWidgets('repeatedly pops imperative route does not crash', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/123369. @@ -1301,10 +1390,7 @@ void main() { 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)); + RouteInformation(uri: Uri.parse(location), state: state)); await tester.pumpAndSettle(); // Make sure it has all the imperative routes. expect(find.byKey(const ValueKey('settings-1')), findsOneWidget); @@ -2435,10 +2521,7 @@ void main() { routes, tester, ); - // TODO(chunhtai): remove this ignore and migrate the code - // https://github.com/flutter/flutter/issues/124045. - // ignore: deprecated_member_use - expect(router.routeInformationProvider.value.location, '/dummy'); + expect(router.routeInformationProvider.value.uri.path, '/dummy'); TestWidgetsFlutterBinding.instance.platformDispatcher .clearDefaultRouteNameTestValue(); }); diff --git a/packages/go_router/test/information_provider_test.dart b/packages/go_router/test/information_provider_test.dart index 02711a77a5e..52629587bac 100644 --- a/packages/go_router/test/information_provider_test.dart +++ b/packages/go_router/test/information_provider_test.dart @@ -26,11 +26,46 @@ void main() { GoRouteInformationProvider( initialLocation: initialRoute, initialExtra: null); provider.addListener(expectAsync0(() {})); - // 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)); + .didPushRouteInformation(RouteInformation(uri: Uri.parse(newRoute))); + }); + + testWidgets('didPushRouteInformation maintains uri scheme and host', + (WidgetTester tester) async { + const String expectedScheme = 'https'; + const String expectedHost = 'www.example.com'; + const String expectedPath = '/some/path'; + const String expectedUriString = + '$expectedScheme://$expectedHost$expectedPath'; + late final GoRouteInformationProvider provider = + GoRouteInformationProvider( + initialLocation: initialRoute, initialExtra: null); + provider.addListener(expectAsync0(() {})); + provider.didPushRouteInformation( + RouteInformation(uri: Uri.parse(expectedUriString))); + expect(provider.value.uri.scheme, 'https'); + expect(provider.value.uri.host, 'www.example.com'); + expect(provider.value.uri.path, '/some/path'); + expect(provider.value.uri.toString(), expectedUriString); + }); + + testWidgets('didPushRoute maintains uri scheme and host', + (WidgetTester tester) async { + const String expectedScheme = 'https'; + const String expectedHost = 'www.example.com'; + const String expectedPath = '/some/path'; + const String expectedUriString = + '$expectedScheme://$expectedHost$expectedPath'; + late final GoRouteInformationProvider provider = + GoRouteInformationProvider( + initialLocation: initialRoute, initialExtra: null); + provider.addListener(expectAsync0(() {})); + provider.didPushRouteInformation( + RouteInformation(uri: Uri.parse(expectedUriString))); + expect(provider.value.uri.scheme, 'https'); + expect(provider.value.uri.host, 'www.example.com'); + expect(provider.value.uri.path, '/some/path'); + expect(provider.value.uri.toString(), expectedUriString); }); }); } diff --git a/packages/go_router/test/parser_test.dart b/packages/go_router/test/parser_test.dart index ddcacd0a4f4..47ea84c4f94 100644 --- a/packages/go_router/test/parser_test.dart +++ b/packages/go_router/test/parser_test.dart @@ -10,10 +10,7 @@ import 'test_helpers.dart'; 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, + uri: Uri.parse(location), state: RouteInformationState(type: NavigatingType.go, extra: extra)); } @@ -83,6 +80,52 @@ void main() { expect(matches[1].route, routes[0].routes[0]); }); + testWidgets( + "GoRouteInformationParser can parse deeplink route and maintain uri's scheme and host", + (WidgetTester tester) async { + const String expectedScheme = 'https'; + const String expectedHost = 'www.example.com'; + const String expectedPath = '/abc'; + const String expectedUriString = + '$expectedScheme://$expectedHost$expectedPath'; + final List routes = [ + GoRoute( + path: '/', + builder: (_, __) => const Placeholder(), + routes: [ + GoRoute( + path: 'abc', + builder: (_, __) => const Placeholder(), + ), + ], + ), + ]; + final GoRouteInformationParser parser = await createParser( + tester, + routes: routes, + redirectLimit: 100, + redirect: (_, __) => null, + ); + + final BuildContext context = tester.element(find.byType(Router)); + + final RouteMatchList matchesObj = + await parser.parseRouteInformationWithDependencies( + createRouteInformation(expectedUriString), context); + final List matches = matchesObj.matches; + expect(matches.length, 2); + expect(matchesObj.uri.toString(), expectedUriString); + expect(matchesObj.uri.scheme, expectedScheme); + expect(matchesObj.uri.host, expectedHost); + expect(matchesObj.uri.path, expectedPath); + + expect(matches[0].matchedLocation, '/'); + expect(matches[0].route, routes[0]); + + expect(matches[1].matchedLocation, '/abc'); + expect(matches[1].route, routes[0].routes[0]); + }); + testWidgets( 'GoRouteInformationParser can restore full route matches if optionURLReflectsImperativeAPIs is true', (WidgetTester tester) async { @@ -114,11 +157,7 @@ void main() { final RouteInformation restoredRouteInformation = router.routeInformationParser.restoreRouteInformation(matchList)!; - // URL reflects the latest push. - // TODO(chunhtai): remove this ignore and migrate the code - // https://github.com/flutter/flutter/issues/124045. - // ignore: deprecated_member_use - expect(restoredRouteInformation.location, '/'); + expect(restoredRouteInformation.uri.path, '/'); // Can restore back to original RouteMatchList. final RouteMatchList parsedRouteMatch = await router.routeInformationParser