diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md index 87d27d4d075..3534c4419ff 100644 --- a/packages/go_router/CHANGELOG.md +++ b/packages/go_router/CHANGELOG.md @@ -1,3 +1,7 @@ +## 6.4.0 + +- Adds `replace` method to that replaces the current route with a new one and keeps the same page key. This is useful for when you want to update the query params without changing the page key ([#115902]https://github.com/flutter/flutter/issues/115902). + ## 6.3.0 - Aligns Dart and Flutter SDK constraints. diff --git a/packages/go_router/lib/src/delegate.dart b/packages/go_router/lib/src/delegate.dart index 07bcdf0dbb4..94402661afc 100644 --- a/packages/go_router/lib/src/delegate.dart +++ b/packages/go_router/lib/src/delegate.dart @@ -50,7 +50,7 @@ class GoRouterDelegate extends RouterDelegate /// /// This is used to generate a unique key for each route. /// - /// For example, it would could be equal to: + /// For example, it could be equal to: /// ```dart /// { /// 'family': 1, @@ -75,15 +75,14 @@ class GoRouterDelegate extends RouterDelegate return false; } - /// Pushes the given location onto the page stack - void push(RouteMatchList matches) { - assert(matches.last.route is! ShellRoute); - + ValueKey _getNewKeyForPath(String path) { // Remap the pageKey to allow any number of the same page on the stack - final int count = (_pushCounts[matches.fullpath] ?? 0) + 1; - _pushCounts[matches.fullpath] = count; - final ValueKey pageKey = - ValueKey('${matches.fullpath}-p$count'); + final int count = (_pushCounts[path] ?? -1) + 1; + _pushCounts[path] = count; + return ValueKey('$path-p$count'); + } + + void _push(RouteMatchList matches, ValueKey pageKey) { final ImperativeRouteMatch newPageKeyMatch = ImperativeRouteMatch( route: matches.last.route, subloc: matches.last.subloc, @@ -94,6 +93,21 @@ class GoRouterDelegate extends RouterDelegate ); _matchList.push(newPageKeyMatch); + } + + /// Pushes the given location onto the page stack. + /// + /// See also: + /// * [pushReplacement] which replaces the top-most page of the page stack and + /// always use a new page key. + /// * [replace] which replaces the top-most page of the page stack but treats + /// it as the same page. The page key will be reused. This will preserve the + /// state and not run any page animation. + void push(RouteMatchList matches) { + assert(matches.last.route is! ShellRoute); + + final ValueKey pageKey = _getNewKeyForPath(matches.fullpath); + _push(matches, pageKey); notifyListeners(); } @@ -148,13 +162,38 @@ class GoRouterDelegate extends RouterDelegate /// Replaces the top-most page of the page stack with the given one. /// + /// The page key of the new page will always be different from the old one. + /// /// See also: /// * [push] which pushes the given location onto the page stack. + /// * [replace] which replaces the top-most page of the page stack but treats + /// it as the same page. The page key will be reused. This will preserve the + /// state and not run any page animation. void pushReplacement(RouteMatchList matches) { + assert(matches.last.route is! ShellRoute); _matchList.remove(_matchList.last); push(matches); // [push] will notify the listeners. } + /// Replaces the top-most page of the page stack with the given one but treats + /// it as the same page. + /// + /// The page key will be reused. This will preserve the state and not run any + /// page animation. + /// + /// See also: + /// * [push] which pushes the given location onto the page stack. + /// * [pushReplacement] which replaces the top-most page of the page stack but + /// always uses a new page key. + void replace(RouteMatchList matches) { + assert(matches.last.route is! ShellRoute); + final RouteMatch routeMatch = _matchList.last; + final ValueKey pageKey = routeMatch.pageKey; + _matchList.remove(routeMatch); + _push(matches, pageKey); + notifyListeners(); + } + /// For internal use; visible for testing only. @visibleForTesting RouteMatchList get matches => _matchList; diff --git a/packages/go_router/lib/src/misc/extensions.dart b/packages/go_router/lib/src/misc/extensions.dart index fe37e342fb1..0900588a4ff 100644 --- a/packages/go_router/lib/src/misc/extensions.dart +++ b/packages/go_router/lib/src/misc/extensions.dart @@ -37,6 +37,13 @@ extension GoRouterHelper on BuildContext { ); /// Push a location onto the page stack. + /// + /// See also: + /// * [pushReplacement] which replaces the top-most page of the page stack and + /// always uses a new page key. + /// * [replace] which replaces the top-most page of the page stack but treats + /// it as the same page. The page key will be reused. This will preserve the + /// state and not run any page animation. void push(String location, {Object? extra}) => GoRouter.of(this).push(location, extra: extra); @@ -66,7 +73,10 @@ extension GoRouterHelper on BuildContext { /// /// See also: /// * [go] which navigates to the location. - /// * [push] which pushes the location onto the page stack. + /// * [push] which pushes the given location onto the page stack. + /// * [replace] which replaces the top-most page of the page stack but treats + /// it as the same page. The page key will be reused. This will preserve the + /// state and not run any page animation. void pushReplacement(String location, {Object? extra}) => GoRouter.of(this).pushReplacement(location, extra: extra); @@ -89,4 +99,36 @@ extension GoRouterHelper on BuildContext { queryParams: queryParams, extra: extra, ); + + /// Replaces the top-most page of the page stack with the given one but treats + /// it as the same page. + /// + /// The page key will be reused. This will preserve the state and not run any + /// page animation. + /// + /// See also: + /// * [push] which pushes the given location onto the page stack. + /// * [pushReplacement] which replaces the top-most page of the page stack but + /// always uses a new page key. + void replace(String location, {Object? extra}) => + GoRouter.of(this).replace(location, extra: extra); + + /// Replaces the top-most page with the named route and optional parameters, + /// preserving the page key. + /// + /// This will preserve the state and not run any page animation. Optional + /// parameters can be providded to the named route, e.g. `name='person', + /// params={'fid': 'f2', 'pid': 'p1'}`. + /// + /// See also: + /// * [pushNamed] which pushes the given location onto the page stack. + /// * [pushReplacementNamed] which replaces the top-most page of the page + /// stack but always uses a new page key. + void replaceNamed( + String name, { + Map params = const {}, + Map queryParams = const {}, + Object? extra, + }) => + GoRouter.of(this).replaceNamed(name, extra: extra); } diff --git a/packages/go_router/lib/src/router.dart b/packages/go_router/lib/src/router.dart index b2a48e2d447..148ab6ee457 100644 --- a/packages/go_router/lib/src/router.dart +++ b/packages/go_router/lib/src/router.dart @@ -203,7 +203,14 @@ class GoRouter extends ChangeNotifier implements RouterConfig { ); /// Push a URI location onto the page stack w/ optional query parameters, e.g. - /// `/family/f2/person/p1?color=blue` + /// `/family/f2/person/p1?color=blue`. + /// + /// See also: + /// * [pushReplacement] which replaces the top-most page of the page stack and + /// always use a new page key. + /// * [replace] which replaces the top-most page of the page stack but treats + /// it as the same page. The page key will be reused. This will preserve the + /// state and not run any page animation. void push(String location, {Object? extra}) { assert(() { log.info('pushing $location'); @@ -239,7 +246,10 @@ class GoRouter extends ChangeNotifier implements RouterConfig { /// /// See also: /// * [go] which navigates to the location. - /// * [push] which pushes the location onto the page stack. + /// * [push] which pushes the given location onto the page stack. + /// * [replace] which replaces the top-most page of the page stack but treats + /// it as the same page. The page key will be reused. This will preserve the + /// state and not run any page animation. void pushReplacement(String location, {Object? extra}) { routeInformationParser .parseRouteInformationWithDependencies( @@ -272,6 +282,52 @@ class GoRouter extends ChangeNotifier implements RouterConfig { ); } + /// Replaces the top-most page of the page stack with the given one but treats + /// it as the same page. + /// + /// The page key will be reused. This will preserve the state and not run any + /// page animation. + /// + /// See also: + /// * [push] which pushes the given location onto the page stack. + /// * [pushReplacement] which replaces the top-most page of the page stack but + /// always uses a new page key. + void replace(String location, {Object? extra}) { + routeInformationParser + .parseRouteInformationWithDependencies( + RouteInformation(location: location, state: extra), + // TODO(chunhtai): avoid accessing the context directly through global key. + // https://github.com/flutter/flutter/issues/99112 + _routerDelegate.navigatorKey.currentContext!, + ) + .then((RouteMatchList matchList) { + routerDelegate.replace(matchList); + }); + } + + /// Replaces the top-most page with the named route and optional parameters, + /// preserving the page key. + /// + /// This will preserve the state and not run any page animation. Optional + /// parameters can be providded to the named route, e.g. `name='person', + /// params={'fid': 'f2', 'pid': 'p1'}`. + /// + /// See also: + /// * [pushNamed] which pushes the given location onto the page stack. + /// * [pushReplacementNamed] which replaces the top-most page of the page + /// stack but always uses a new page key. + void replaceNamed( + String name, { + Map params = const {}, + Map queryParams = const {}, + Object? extra, + }) { + replace( + namedLocation(name, params: params, queryParams: queryParams), + extra: extra, + ); + } + /// Pop the top-most route off the current screen. /// /// If the top-most route is a pop up or dialog, this method pops it instead diff --git a/packages/go_router/lib/src/state.dart b/packages/go_router/lib/src/state.dart index fd8b904cafb..57c68a28b43 100644 --- a/packages/go_router/lib/src/state.dart +++ b/packages/go_router/lib/src/state.dart @@ -4,7 +4,6 @@ import 'package:flutter/widgets.dart'; -import '../go_router.dart'; import 'configuration.dart'; import 'misc/errors.dart'; @@ -64,7 +63,11 @@ class GoRouterState { /// The error associated with this sub-route. final Exception? error; - /// A unique string key for this sub-route, e.g. ValueKey('/family/:fid') + /// A unique string key for this sub-route. + /// E.g. + /// ```dart + /// ValueKey('/family/:fid') + /// ``` final ValueKey pageKey; /// Gets the [GoRouterState] from context. diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml index c5f6ea14d35..aed2558848b 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: 6.3.0 +version: 6.4.0 repository: https://github.com/flutter/packages/tree/main/packages/go_router issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+go_router%22 diff --git a/packages/go_router/test/delegate_test.dart b/packages/go_router/test/delegate_test.dart index 62f4b691d98..b6cc6bc6b65 100644 --- a/packages/go_router/test/delegate_test.dart +++ b/packages/go_router/test/delegate_test.dart @@ -67,7 +67,7 @@ void main() { expect(goRouter.routerDelegate.matches.matches.length, 2); expect( goRouter.routerDelegate.matches.matches[1].pageKey, - const Key('/a-p1'), + const ValueKey('/a-p0'), ); goRouter.push('/a'); @@ -76,7 +76,7 @@ void main() { expect(goRouter.routerDelegate.matches.matches.length, 3); expect( goRouter.routerDelegate.matches.matches[2].pageKey, - const Key('/a-p2'), + const ValueKey('/a-p1'), ); }, ); @@ -151,7 +151,7 @@ void main() { }); testWidgets( - 'It should return different pageKey when replace is called', + 'It should return different pageKey when pushReplacement is called', (WidgetTester tester) async { final GoRouter goRouter = await createGoRouter(tester); expect(goRouter.routerDelegate.matches.matches.length, 1); @@ -166,7 +166,7 @@ void main() { expect(goRouter.routerDelegate.matches.matches.length, 2); expect( goRouter.routerDelegate.matches.matches.last.pageKey, - const Key('/a-p1'), + const ValueKey('/a-p0'), ); goRouter.pushReplacement('/a'); @@ -175,7 +175,7 @@ void main() { expect(goRouter.routerDelegate.matches.matches.length, 2); expect( goRouter.routerDelegate.matches.matches.last.pageKey, - const Key('/a-p2'), + const ValueKey('/a-p1'), ); }, ); @@ -235,6 +235,234 @@ void main() { ); }); + group('replace', () { + testWidgets('It should replace the last match with the given one', + (WidgetTester tester) async { + final GoRouter goRouter = GoRouter( + initialLocation: '/', + routes: [ + GoRoute(path: '/', builder: (_, __) => const SizedBox()), + GoRoute(path: '/page-0', builder: (_, __) => const SizedBox()), + GoRoute(path: '/page-1', builder: (_, __) => const SizedBox()), + ], + ); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: goRouter, + ), + ); + + goRouter.push('/page-0'); + + goRouter.routerDelegate.addListener(expectAsync0(() {})); + final RouteMatch first = goRouter.routerDelegate.matches.matches.first; + final RouteMatch last = goRouter.routerDelegate.matches.last; + goRouter.replace('/page-1'); + expect(goRouter.routerDelegate.matches.matches.length, 2); + expect( + goRouter.routerDelegate.matches.matches.first, + first, + reason: 'The first match should still be in the list of matches', + ); + expect( + goRouter.routerDelegate.matches.last, + isNot(last), + reason: 'The last match should have been removed', + ); + expect( + (goRouter.routerDelegate.matches.last as ImperativeRouteMatch) + .matches + .uri + .toString(), + '/page-1', + reason: 'The new location should have been pushed', + ); + }); + + testWidgets( + 'It should use the same pageKey when replace is called (with the same path)', + (WidgetTester tester) async { + final GoRouter goRouter = await createGoRouter(tester); + expect(goRouter.routerDelegate.matches.matches.length, 1); + expect( + goRouter.routerDelegate.matches.matches[0].pageKey, + isNotNull, + ); + + goRouter.push('/a'); + await tester.pumpAndSettle(); + + expect(goRouter.routerDelegate.matches.matches.length, 2); + expect( + goRouter.routerDelegate.matches.matches.last.pageKey, + const ValueKey('/a-p0'), + ); + + goRouter.replace('/a'); + await tester.pumpAndSettle(); + + expect(goRouter.routerDelegate.matches.matches.length, 2); + expect( + goRouter.routerDelegate.matches.matches.last.pageKey, + const ValueKey('/a-p0'), + ); + }, + ); + + testWidgets( + 'It should use the same pageKey when replace is called (with a different path)', + (WidgetTester tester) async { + final GoRouter goRouter = await createGoRouter(tester); + expect(goRouter.routerDelegate.matches.matches.length, 1); + expect( + goRouter.routerDelegate.matches.matches[0].pageKey, + isNotNull, + ); + + goRouter.push('/a'); + await tester.pumpAndSettle(); + + expect(goRouter.routerDelegate.matches.matches.length, 2); + expect( + goRouter.routerDelegate.matches.matches.last.pageKey, + const ValueKey('/a-p0'), + ); + + goRouter.replace('/'); + await tester.pumpAndSettle(); + + expect(goRouter.routerDelegate.matches.matches.length, 2); + expect( + goRouter.routerDelegate.matches.matches.last.pageKey, + const ValueKey('/a-p0'), + ); + }, + ); + }); + + group('replaceNamed', () { + Future createGoRouter( + WidgetTester tester, { + Listenable? refreshListenable, + }) async { + final GoRouter router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + name: 'home', + builder: (_, __) => const SizedBox(), + ), + GoRoute( + path: '/page-0', + name: 'page0', + builder: (_, __) => const SizedBox(), + ), + GoRoute( + path: '/page-1', + name: 'page1', + builder: (_, __) => const SizedBox(), + ), + ], + ); + await tester.pumpWidget(MaterialApp.router( + routerConfig: router, + )); + return router; + } + + testWidgets('It should replace the last match with the given one', + (WidgetTester tester) async { + final GoRouter goRouter = await createGoRouter(tester); + + goRouter.pushNamed('page0'); + + goRouter.routerDelegate.addListener(expectAsync0(() {})); + final RouteMatch first = goRouter.routerDelegate.matches.matches.first; + final RouteMatch last = goRouter.routerDelegate.matches.last; + goRouter.replaceNamed('page1'); + expect(goRouter.routerDelegate.matches.matches.length, 2); + expect( + goRouter.routerDelegate.matches.matches.first, + first, + reason: 'The first match should still be in the list of matches', + ); + expect( + goRouter.routerDelegate.matches.last, + isNot(last), + reason: 'The last match should have been removed', + ); + expect( + (goRouter.routerDelegate.matches.last as ImperativeRouteMatch) + .matches + .uri + .toString(), + '/page-1', + reason: 'The new location should have been pushed', + ); + }); + + testWidgets( + 'It should use the same pageKey when replace is called with the same path', + (WidgetTester tester) async { + final GoRouter goRouter = await createGoRouter(tester); + expect(goRouter.routerDelegate.matches.matches.length, 1); + expect( + goRouter.routerDelegate.matches.matches.first.pageKey, + isNotNull, + ); + + goRouter.pushNamed('page0'); + await tester.pumpAndSettle(); + + expect(goRouter.routerDelegate.matches.matches.length, 2); + expect( + goRouter.routerDelegate.matches.matches.last.pageKey, + const ValueKey('/page-0-p0'), + ); + + goRouter.replaceNamed('page0'); + await tester.pumpAndSettle(); + + expect(goRouter.routerDelegate.matches.matches.length, 2); + expect( + goRouter.routerDelegate.matches.matches.last.pageKey, + const ValueKey('/page-0-p0'), + ); + }, + ); + + testWidgets( + 'It should use a new pageKey when replace is called with a different path', + (WidgetTester tester) async { + final GoRouter goRouter = await createGoRouter(tester); + expect(goRouter.routerDelegate.matches.matches.length, 1); + expect( + goRouter.routerDelegate.matches.matches.first.pageKey, + isNotNull, + ); + + goRouter.pushNamed('page0'); + await tester.pumpAndSettle(); + + expect(goRouter.routerDelegate.matches.matches.length, 2); + expect( + goRouter.routerDelegate.matches.matches.last.pageKey, + const ValueKey('/page-0-p0'), + ); + + goRouter.replaceNamed('home'); + await tester.pumpAndSettle(); + + expect(goRouter.routerDelegate.matches.matches.length, 2); + expect( + goRouter.routerDelegate.matches.matches.last.pageKey, + const ValueKey('/page-0-p0'), + ); + }, + ); + }); + testWidgets('dispose unsubscribes from refreshListenable', (WidgetTester tester) async { final FakeRefreshListenable refreshListenable = FakeRefreshListenable();