[go_router] When `replace` is called, reuse the same key when possible (#2846)
[go_router] When `replace` is called, reuse the same key when possible
diff --git a/packages/go_router/CHANGELOG.md b/packages/go_router/CHANGELOG.md
index 87d27d4..3534c44 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 07bcdf0..9440266 100644
--- a/packages/go_router/lib/src/delegate.dart
+++ b/packages/go_router/lib/src/delegate.dart
@@ -50,7 +50,7 @@
///
/// 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 @@
return false;
}
- /// Pushes the given location onto the page stack
- void push(RouteMatchList matches) {
- assert(matches.last.route is! ShellRoute);
-
+ ValueKey<String> _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<String> pageKey =
- ValueKey<String>('${matches.fullpath}-p$count');
+ final int count = (_pushCounts[path] ?? -1) + 1;
+ _pushCounts[path] = count;
+ return ValueKey<String>('$path-p$count');
+ }
+
+ void _push(RouteMatchList matches, ValueKey<String> pageKey) {
final ImperativeRouteMatch newPageKeyMatch = ImperativeRouteMatch(
route: matches.last.route,
subloc: matches.last.subloc,
@@ -94,6 +93,21 @@
);
_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<String> pageKey = _getNewKeyForPath(matches.fullpath);
+ _push(matches, pageKey);
notifyListeners();
}
@@ -148,13 +162,38 @@
/// 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<String> 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 fe37e34..0900588 100644
--- a/packages/go_router/lib/src/misc/extensions.dart
+++ b/packages/go_router/lib/src/misc/extensions.dart
@@ -37,6 +37,13 @@
);
/// 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 @@
///
/// 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 @@
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<String, String> params = const <String, String>{},
+ Map<String, dynamic> queryParams = const <String, dynamic>{},
+ 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 b2a48e2..148ab6e 100644
--- a/packages/go_router/lib/src/router.dart
+++ b/packages/go_router/lib/src/router.dart
@@ -203,7 +203,14 @@
);
/// 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 @@
///
/// 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 @@
);
}
+ /// 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<void>((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<String, String> params = const <String, String>{},
+ Map<String, dynamic> queryParams = const <String, dynamic>{},
+ 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 fd8b904..57c68a2 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 @@
/// 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<String> pageKey;
/// Gets the [GoRouterState] from context.
diff --git a/packages/go_router/pubspec.yaml b/packages/go_router/pubspec.yaml
index c5f6ea1..aed2558 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 62f4b69..b6cc6bc 100644
--- a/packages/go_router/test/delegate_test.dart
+++ b/packages/go_router/test/delegate_test.dart
@@ -67,7 +67,7 @@
expect(goRouter.routerDelegate.matches.matches.length, 2);
expect(
goRouter.routerDelegate.matches.matches[1].pageKey,
- const Key('/a-p1'),
+ const ValueKey<String>('/a-p0'),
);
goRouter.push('/a');
@@ -76,7 +76,7 @@
expect(goRouter.routerDelegate.matches.matches.length, 3);
expect(
goRouter.routerDelegate.matches.matches[2].pageKey,
- const Key('/a-p2'),
+ const ValueKey<String>('/a-p1'),
);
},
);
@@ -151,7 +151,7 @@
});
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 @@
expect(goRouter.routerDelegate.matches.matches.length, 2);
expect(
goRouter.routerDelegate.matches.matches.last.pageKey,
- const Key('/a-p1'),
+ const ValueKey<String>('/a-p0'),
);
goRouter.pushReplacement('/a');
@@ -175,7 +175,7 @@
expect(goRouter.routerDelegate.matches.matches.length, 2);
expect(
goRouter.routerDelegate.matches.matches.last.pageKey,
- const Key('/a-p2'),
+ const ValueKey<String>('/a-p1'),
);
},
);
@@ -235,6 +235,234 @@
);
});
+ group('replace', () {
+ testWidgets('It should replace the last match with the given one',
+ (WidgetTester tester) async {
+ final GoRouter goRouter = GoRouter(
+ initialLocation: '/',
+ routes: <GoRoute>[
+ 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<String>('/a-p0'),
+ );
+
+ goRouter.replace('/a');
+ await tester.pumpAndSettle();
+
+ expect(goRouter.routerDelegate.matches.matches.length, 2);
+ expect(
+ goRouter.routerDelegate.matches.matches.last.pageKey,
+ const ValueKey<String>('/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<String>('/a-p0'),
+ );
+
+ goRouter.replace('/');
+ await tester.pumpAndSettle();
+
+ expect(goRouter.routerDelegate.matches.matches.length, 2);
+ expect(
+ goRouter.routerDelegate.matches.matches.last.pageKey,
+ const ValueKey<String>('/a-p0'),
+ );
+ },
+ );
+ });
+
+ group('replaceNamed', () {
+ Future<GoRouter> createGoRouter(
+ WidgetTester tester, {
+ Listenable? refreshListenable,
+ }) async {
+ final GoRouter router = GoRouter(
+ initialLocation: '/',
+ routes: <GoRoute>[
+ 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<String>('/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<String>('/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<String>('/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<String>('/page-0-p0'),
+ );
+ },
+ );
+ });
+
testWidgets('dispose unsubscribes from refreshListenable',
(WidgetTester tester) async {
final FakeRefreshListenable refreshListenable = FakeRefreshListenable();