[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();